diff --git a/.agents/archive/PR_WORKFLOW_V1.md b/.agents/archive/PR_WORKFLOW_V1.md deleted file mode 100644 index 1cb6ab653b5..00000000000 --- a/.agents/archive/PR_WORKFLOW_V1.md +++ /dev/null @@ -1,181 +0,0 @@ -# PR Workflow for Maintainers - -Please read this in full and do not skip sections. -This is the single source of truth for the maintainer PR workflow. - -## Triage order - -Process PRs **oldest to newest**. Older PRs are more likely to have merge conflicts and stale dependencies; resolving them first keeps the queue healthy and avoids snowballing rebase pain. - -## Working rule - -Skills execute workflow. Maintainers provide judgment. -Always pause between skills to evaluate technical direction, not just command success. - -These three skills must be used in order: - -1. `review-pr` — review only, produce findings -2. `prepare-pr` — rebase, fix, gate, push to PR head branch -3. `merge-pr` — squash-merge, verify MERGED state, clean up - -They are necessary, but not sufficient. Maintainers must steer between steps and understand the code before moving forward. - -Treat PRs as reports first, code second. -If submitted code is low quality, ignore it and implement the best solution for the problem. - -Do not continue if you cannot verify the problem is real or test the fix. - -## Coding Agent - -Use ChatGPT 5.3 Codex High. Fall back to 5.2 Codex High or 5.3 Codex Medium if necessary. - -## PR quality bar - -- Do not trust PR code by default. -- Do not merge changes you cannot validate with a reproducible problem and a tested fix. -- Keep types strict. Do not use `any` in implementation code. -- Keep external-input boundaries typed and validated, including CLI input, environment variables, network payloads, and tool output. -- Keep implementations properly scoped. Fix root causes, not local symptoms. -- Identify and reuse canonical sources of truth so behavior does not drift across the codebase. -- Harden changes. Always evaluate security impact and abuse paths. -- Understand the system before changing it. Never make the codebase messier just to clear a PR queue. - -## Rebase and conflict resolution - -Before any substantive review or prep work, **always rebase the PR branch onto current `main` and resolve merge conflicts first**. A PR that cannot cleanly rebase is not ready for review — fix conflicts before evaluating correctness. - -- During `prepare-pr`: rebase onto `main` as the first step, before fixing findings or running gates. -- If conflicts are complex or touch areas you do not understand, stop and escalate. -- Prefer **rebase** for linear history; **squash** when commit history is messy or unhelpful. - -## Commit and changelog rules - -- Create commits with `scripts/committer "" `; avoid manual `git add`/`git commit` so staging stays scoped. -- Follow concise, action-oriented commit messages (e.g., `CLI: add verbose flag to send`). -- During `prepare-pr`, use this commit subject format: `fix: (openclaw#) thanks @`. -- Group related changes; avoid bundling unrelated refactors. -- Changelog workflow: keep the latest released version at the top (no `Unreleased`); after publishing, bump the version and start a new top section. -- When working on a PR: add a changelog entry with the PR number and thank the contributor. -- When working on an issue: reference the issue in the changelog entry. -- Pure test additions/fixes generally do **not** need a changelog entry unless they alter user-facing behavior or the user asks for one. - -## Co-contributor and clawtributors - -- If we squash, add the PR author as a co-contributor in the commit body using a `Co-authored-by:` trailer. -- When maintainer prepares and merges the PR, add the maintainer as an additional `Co-authored-by:` trailer too. -- Avoid `--auto` merges for maintainer landings. Merge only after checks are green so the maintainer account is the actor and attribution is deterministic. -- For squash merges, set `--author-email` to a reviewer-owned email with fallback candidates; if merge fails due to author-email validation, retry once with the next candidate. -- If you review a PR and later do work on it, land via merge/squash (no direct-main commits) and always add the PR author as a co-contributor. -- When merging a PR: leave a PR comment that explains exactly what we did, include the SHA hashes, and record the comment URL in the final report. -- When merging a PR from a new contributor: run `bun scripts/update-clawtributors.ts` to add their avatar to the README "Thanks to all clawtributors" list, then commit the regenerated README. - -## Review mode vs landing mode - -- **Review mode (PR link only):** read `gh pr view`/`gh pr diff`; **do not** switch branches; **do not** change code. -- **Landing mode (exception path):** use only when normal `review-pr -> prepare-pr -> merge-pr` flow cannot safely preserve attribution or cannot satisfy branch protection. Create an integration branch from `main`, bring in PR commits (**prefer rebase** for linear history; **merge allowed** when complexity/conflicts make it safer), apply fixes, add changelog (+ thanks + PR #), run full gate **locally before committing** (`pnpm build && pnpm check && pnpm test`), commit, merge back to `main`, then `git switch main` (never stay on a topic branch after landing). Important: the contributor needs to be in the git graph after this! - -## Pre-review safety checks - -- Before starting a review when a GH Issue/PR is pasted: use an isolated `.worktrees/pr-` checkout from `origin/main`. Do not require a clean main checkout, and do not run `git pull` in a dirty main checkout. -- PR review calls: prefer a single `gh pr view --json ...` to batch metadata/comments; run `gh pr diff` only when needed. -- PRs should summarize scope, note testing performed, and mention any user-facing changes or new flags. -- Read `docs/help/submitting-a-pr.md` ([Submitting a PR](https://docs.openclaw.ai/help/submitting-a-pr)) for what we expect from contributors. - -## Unified workflow - -Entry criteria: - -- PR URL/number is known. -- Problem statement is clear enough to attempt reproduction. -- A realistic verification path exists (tests, integration checks, or explicit manual validation). - -### 1) `review-pr` - -Purpose: - -- Review only: correctness, value, security risk, tests, docs, and changelog impact. -- Produce structured findings and a recommendation. - -Expected output: - -- Recommendation: ready, needs work, needs discussion, or close. -- `.local/review.md` with actionable findings. - -Maintainer checkpoint before `prepare-pr`: - -``` -What problem are they trying to solve? -What is the most optimal implementation? -Can we fix up everything? -Do we have any questions? -``` - -Stop and escalate instead of continuing if: - -- The problem cannot be reproduced or confirmed. -- The proposed PR scope does not match the stated problem. -- The design introduces unresolved security or trust-boundary concerns. - -### 2) `prepare-pr` - -Purpose: - -- Make the PR merge-ready on its head branch. -- Rebase onto current `main` first, then fix blocker/important findings, then run gates. -- In fresh worktrees, bootstrap dependencies before local gates (`pnpm install --frozen-lockfile`). - -Expected output: - -- Updated code and tests on the PR head branch. -- `.local/prep.md` with changes, verification, and current HEAD SHA. -- Final status: `PR is ready for /mergepr`. - -Maintainer checkpoint before `merge-pr`: - -``` -Is this the most optimal implementation? -Is the code properly scoped? -Is the code properly reusing existing logic in the codebase? -Is the code properly typed? -Is the code hardened? -Do we have enough tests? -Do we need regression tests? -Are tests using fake timers where appropriate? (e.g., debounce/throttle, retry backoff, timeout branches, delayed callbacks, polling loops) -Do not add performative tests, ensure tests are real and there are no regressions. -Do you see any follow-up refactors we should do? -Take your time, fix it properly, refactor if necessary. -Did any changes introduce any potential security vulnerabilities? -``` - -Stop and escalate instead of continuing if: - -- You cannot verify behavior changes with meaningful tests or validation. -- Fixing findings requires broad architecture changes outside safe PR scope. -- Security hardening requirements remain unresolved. - -### 3) `merge-pr` - -Purpose: - -- Merge only after review and prep artifacts are present and checks are green. -- Use deterministic squash merge flow (`--match-head-commit` + explicit subject/body with co-author trailer), then verify the PR ends in `MERGED` state. -- If no required checks are configured on the PR, treat that as acceptable and continue after branch-up-to-date validation. - -Go or no-go checklist before merge: - -- All BLOCKER and IMPORTANT findings are resolved. -- Verification is meaningful and regression risk is acceptably low. -- Docs and changelog are updated when required. -- Required CI checks are green and the branch is not behind `main`. - -Expected output: - -- Successful merge commit and recorded merge SHA. -- Worktree cleanup after successful merge. -- Comment on PR indicating merge was successful. - -Maintainer checkpoint after merge: - -- Were any refactors intentionally deferred and now need follow-up issue(s)? -- Did this reveal broader architecture or test gaps we should address? -- Run `bun scripts/update-clawtributors.ts` if the contributor is new. diff --git a/.agents/archive/merge-pr-v1/SKILL.md b/.agents/archive/merge-pr-v1/SKILL.md deleted file mode 100644 index 0956699eb55..00000000000 --- a/.agents/archive/merge-pr-v1/SKILL.md +++ /dev/null @@ -1,304 +0,0 @@ ---- -name: merge-pr -description: Merge a GitHub PR via squash after /prepare-pr. Use when asked to merge a ready PR. Do not push to main or modify code. Ensure the PR ends in MERGED state and clean up worktrees after success. ---- - -# Merge PR - -## Overview - -Merge a prepared PR via deterministic squash merge (`--match-head-commit` + explicit co-author trailer), then clean up the worktree after success. - -## Inputs - -- Ask for PR number or URL. -- If missing, use `.local/prep.env` from the worktree if present. -- If ambiguous, ask. - -## Safety - -- Use `gh pr merge --squash` as the only path to `main`. -- Do not run `git push` at all during merge. -- Do not use `gh pr merge --auto` for maintainer landings. -- Do not run gateway stop commands. Do not kill processes. Do not touch port 18792. - -## Execution Rule - -- Execute the workflow. Do not stop after printing the TODO checklist. -- If delegating, require the delegate to run commands and capture outputs. - -## Known Footguns - -- If you see "fatal: not a git repository", you are in the wrong directory. Move to the repo root and retry. -- Read `.local/review.md`, `.local/prep.md`, and `.local/prep.env` in the worktree. Do not skip. -- Always merge with `--match-head-commit "$PREP_HEAD_SHA"` to prevent racing stale or changed heads. -- Clean up `.worktrees/pr-` only after confirmed `MERGED`. - -## Completion Criteria - -- Ensure `gh pr merge` succeeds. -- Ensure PR state is `MERGED`, never `CLOSED`. -- Record the merge SHA. -- Leave a PR comment with merge SHA and prepared head SHA, and capture the comment URL. -- Run cleanup only after merge success. - -## First: Create a TODO Checklist - -Create a checklist of all merge steps, print it, then continue and execute the commands. - -## Setup: Use a Worktree - -Use an isolated worktree for all merge work. - -```sh -repo_root=$(git rev-parse --show-toplevel) -cd "$repo_root" -gh auth status - -WORKTREE_DIR=".worktrees/pr-" -cd "$WORKTREE_DIR" -``` - -Run all commands inside the worktree directory. - -## Load Local Artifacts (Mandatory) - -Expect these files from earlier steps: - -- `.local/review.md` from `/review-pr` -- `.local/prep.md` from `/prepare-pr` -- `.local/prep.env` from `/prepare-pr` - -```sh -ls -la .local || true - -for required in .local/review.md .local/prep.md .local/prep.env; do - if [ ! -f "$required" ]; then - echo "Missing $required. Stop and run /review-pr then /prepare-pr." - exit 1 - fi -done - -sed -n '1,120p' .local/review.md -sed -n '1,120p' .local/prep.md -source .local/prep.env -``` - -## Steps - -1. Identify PR meta and verify prepared SHA still matches - -```sh -pr_meta_json=$(gh pr view --json number,title,state,isDraft,author,headRefName,headRefOid,baseRefName,headRepository,body) -printf '%s\n' "$pr_meta_json" | jq '{number,title,state,isDraft,author:.author.login,head:.headRefName,headSha:.headRefOid,base:.baseRefName,headRepo:.headRepository.nameWithOwner,body}' -pr_title=$(printf '%s\n' "$pr_meta_json" | jq -r .title) -pr_number=$(printf '%s\n' "$pr_meta_json" | jq -r .number) -pr_head_sha=$(printf '%s\n' "$pr_meta_json" | jq -r .headRefOid) -contrib=$(printf '%s\n' "$pr_meta_json" | jq -r .author.login) -is_draft=$(printf '%s\n' "$pr_meta_json" | jq -r .isDraft) - -if [ "$is_draft" = "true" ]; then - echo "ERROR: PR is draft. Stop and run /prepare-pr after draft is cleared." - exit 1 -fi - -if [ "$pr_head_sha" != "$PREP_HEAD_SHA" ]; then - echo "ERROR: PR head changed after /prepare-pr (expected $PREP_HEAD_SHA, got $pr_head_sha). Re-run /prepare-pr." - exit 1 -fi -``` - -2. Run sanity checks - -Stop if any are true: - -- PR is a draft. -- Required checks are failing. -- Branch is behind main. - -If checks are pending, wait for completion before merging. Do not use `--auto`. -If no required checks are configured, continue. - -```sh -gh pr checks --required --watch --fail-fast || true -checks_json=$(gh pr checks --required --json name,bucket,state 2>/tmp/gh-checks.err || true) -if [ -z "$checks_json" ]; then - checks_json='[]' -fi -required_count=$(printf '%s\n' "$checks_json" | jq 'length') -if [ "$required_count" -eq 0 ]; then - echo "No required checks configured for this PR." -fi -printf '%s\n' "$checks_json" | jq -r '.[] | "\(.bucket)\t\(.name)\t\(.state)"' - -failed_required=$(printf '%s\n' "$checks_json" | jq '[.[] | select(.bucket=="fail")] | length') -pending_required=$(printf '%s\n' "$checks_json" | jq '[.[] | select(.bucket=="pending")] | length') -if [ "$failed_required" -gt 0 ]; then - echo "Required checks are failing, run /prepare-pr." - exit 1 -fi -if [ "$pending_required" -gt 0 ]; then - echo "Required checks are still pending, retry /merge-pr when green." - exit 1 -fi - -git fetch origin main -git fetch origin pull//head:pr- --force -git merge-base --is-ancestor origin/main pr- || (echo "PR branch is behind main, run /prepare-pr" && exit 1) -``` - -If anything is failing or behind, stop and say to run `/prepare-pr`. - -3. Merge PR with explicit attribution metadata - -```sh -reviewer=$(gh api user --jq .login) -reviewer_id=$(gh api user --jq .id) -coauthor_email=${COAUTHOR_EMAIL:-"$contrib@users.noreply.github.com"} -if [ -z "$coauthor_email" ] || [ "$coauthor_email" = "null" ]; then - contrib_id=$(gh api users/$contrib --jq .id) - coauthor_email="${contrib_id}+${contrib}@users.noreply.github.com" -fi - -gh_email=$(gh api user --jq '.email // ""' || true) -git_email=$(git config user.email || true) -mapfile -t reviewer_email_candidates < <( - printf '%s\n' \ - "$gh_email" \ - "$git_email" \ - "${reviewer_id}+${reviewer}@users.noreply.github.com" \ - "${reviewer}@users.noreply.github.com" | awk 'NF && !seen[$0]++' -) -[ "${#reviewer_email_candidates[@]}" -gt 0 ] || { echo "ERROR: could not resolve reviewer author email"; exit 1; } -reviewer_email="${reviewer_email_candidates[0]}" - -cat > .local/merge-body.txt < /prepare-pr -> /merge-pr. - -Prepared head SHA: $PREP_HEAD_SHA -Co-authored-by: $contrib <$coauthor_email> -Co-authored-by: $reviewer <$reviewer_email> -Reviewed-by: @$reviewer -EOF - -run_merge() { - local email="$1" - local stderr_file - stderr_file=$(mktemp) - if gh pr merge \ - --squash \ - --delete-branch \ - --match-head-commit "$PREP_HEAD_SHA" \ - --author-email "$email" \ - --subject "$pr_title (#$pr_number)" \ - --body-file .local/merge-body.txt \ - 2> >(tee "$stderr_file" >&2) - then - rm -f "$stderr_file" - return 0 - fi - merge_err=$(cat "$stderr_file") - rm -f "$stderr_file" - return 1 -} - -merge_err="" -selected_merge_author_email="$reviewer_email" -if ! run_merge "$selected_merge_author_email"; then - if printf '%s\n' "$merge_err" | rg -qi 'author.?email|email.*associated|associated.*email|invalid.*email' && [ "${#reviewer_email_candidates[@]}" -ge 2 ]; then - selected_merge_author_email="${reviewer_email_candidates[1]}" - echo "Retrying once with fallback author email: $selected_merge_author_email" - run_merge "$selected_merge_author_email" || { echo "ERROR: merge failed after fallback retry"; exit 1; } - else - echo "ERROR: merge failed" - exit 1 - fi -fi -``` - -Retry is allowed exactly once when the error is clearly author-email validation. - -4. Verify PR state and capture merge SHA - -```sh -state=$(gh pr view --json state --jq .state) -if [ "$state" != "MERGED" ]; then - echo "Merge not finalized yet (state=$state), waiting up to 15 minutes..." - for _ in $(seq 1 90); do - sleep 10 - state=$(gh pr view --json state --jq .state) - if [ "$state" = "MERGED" ]; then - break - fi - done -fi - -if [ "$state" != "MERGED" ]; then - echo "ERROR: PR state is $state after waiting. Leave worktree and retry /merge-pr later." - exit 1 -fi - -merge_sha=$(gh pr view --json mergeCommit --jq '.mergeCommit.oid') -if [ -z "$merge_sha" ] || [ "$merge_sha" = "null" ]; then - echo "ERROR: merge commit SHA missing." - exit 1 -fi - -commit_body=$(gh api repos/:owner/:repo/commits/$merge_sha --jq .commit.message) -contrib=${contrib:-$(gh pr view --json author --jq .author.login)} -reviewer=${reviewer:-$(gh api user --jq .login)} -printf '%s\n' "$commit_body" | rg -q "^Co-authored-by: $contrib <" || { echo "ERROR: missing PR author co-author trailer"; exit 1; } -printf '%s\n' "$commit_body" | rg -q "^Co-authored-by: $reviewer <" || { echo "ERROR: missing reviewer co-author trailer"; exit 1; } - -echo "merge_sha=$merge_sha" -``` - -5. PR comment - -Use a multiline heredoc with interpolation enabled. - -```sh -ok=0 -comment_output="" -for _ in 1 2 3; do - if comment_output=$(gh pr comment -F - <" --force -git branch -D temp/pr- 2>/dev/null || true -git branch -D pr- 2>/dev/null || true -git branch -D pr--prep 2>/dev/null || true -``` - -## Guardrails - -- Worktree only. -- Do not close PRs. -- End in MERGED state. -- Clean up only after merge success. -- Never push to main. Use `gh pr merge --squash` only. -- Do not run `git push` at all in this command. diff --git a/.agents/archive/merge-pr-v1/agents/openai.yaml b/.agents/archive/merge-pr-v1/agents/openai.yaml deleted file mode 100644 index 9c10ae4d271..00000000000 --- a/.agents/archive/merge-pr-v1/agents/openai.yaml +++ /dev/null @@ -1,4 +0,0 @@ -interface: - display_name: "Merge PR" - short_description: "Merge GitHub PRs via squash" - default_prompt: "Use $merge-pr to merge a GitHub PR via squash after preparation." diff --git a/.agents/archive/prepare-pr-v1/SKILL.md b/.agents/archive/prepare-pr-v1/SKILL.md deleted file mode 100644 index 91c4508a07a..00000000000 --- a/.agents/archive/prepare-pr-v1/SKILL.md +++ /dev/null @@ -1,336 +0,0 @@ ---- -name: prepare-pr -description: Prepare a GitHub PR for merge by rebasing onto main, fixing review findings, running gates, committing fixes, and pushing to the PR head branch. Use after /review-pr. Never merge or push to main. ---- - -# Prepare PR - -## Overview - -Prepare a PR head branch for merge with review fixes, green gates, and deterministic merge handoff artifacts. - -## Inputs - -- Ask for PR number or URL. -- If missing, use `.local/pr-meta.env` from the PR worktree if present. -- If ambiguous, ask. - -## Safety - -- Never push to `main` or `origin/main`. Push only to the PR head branch. -- Never run `git push` without explicit remote and branch. Do not run bare `git push`. -- Do not run gateway stop commands. Do not kill processes. Do not touch port 18792. -- Do not run `git clean -fdx`. -- Do not run `git add -A` or `git add .`. - -## Execution Rule - -- Execute the workflow. Do not stop after printing the TODO checklist. -- If delegating, require the delegate to run commands and capture outputs. - -## Completion Criteria - -- Rebase PR commits onto `origin/main`. -- Fix all BLOCKER and IMPORTANT items from `.local/review.md`. -- Commit prep changes with required subject format. -- Run required gates and pass (`pnpm test` may be skipped only for high-confidence docs-only changes). -- Push the updated HEAD back to the PR head branch. -- Write `.local/prep.md` and `.local/prep.env`. -- Output exactly: `PR is ready for /mergepr`. - -## First: Create a TODO Checklist - -Create a checklist of all prep steps, print it, then continue and execute the commands. - -## Setup: Use a Worktree - -Use an isolated worktree for all prep work. - -```sh -repo_root=$(git rev-parse --show-toplevel) -cd "$repo_root" -gh auth status - -WORKTREE_DIR=".worktrees/pr-" -if [ ! -d "$WORKTREE_DIR" ]; then - git fetch origin main - git worktree add "$WORKTREE_DIR" -b temp/pr- origin/main -fi -cd "$WORKTREE_DIR" -mkdir -p .local -``` - -Run all commands inside the worktree directory. - -## Load Review Artifacts (Mandatory) - -```sh -if [ ! -f .local/review.md ]; then - echo "Missing .local/review.md. Run /review-pr first and save findings." - exit 1 -fi - -if [ ! -f .local/pr-meta.env ]; then - echo "Missing .local/pr-meta.env. Run /review-pr first and save metadata." - exit 1 -fi - -sed -n '1,220p' .local/review.md -source .local/pr-meta.env -``` - -## Steps - -1. Identify PR meta with one API call - -```sh -pr_meta_json=$(gh pr view --json number,title,author,headRefName,headRefOid,baseRefName,headRepository,headRepositoryOwner,body) -printf '%s\n' "$pr_meta_json" | jq '{number,title,author:.author.login,head:.headRefName,headSha:.headRefOid,base:.baseRefName,headRepo:.headRepository.nameWithOwner,headRepoOwner:.headRepositoryOwner.login,headRepoName:.headRepository.name,body}' - -pr_number=$(printf '%s\n' "$pr_meta_json" | jq -r .number) -contrib=$(printf '%s\n' "$pr_meta_json" | jq -r .author.login) -head=$(printf '%s\n' "$pr_meta_json" | jq -r .headRefName) -pr_head_sha_before=$(printf '%s\n' "$pr_meta_json" | jq -r .headRefOid) -head_owner=$(printf '%s\n' "$pr_meta_json" | jq -r '.headRepositoryOwner.login // empty') -head_repo_name=$(printf '%s\n' "$pr_meta_json" | jq -r '.headRepository.name // empty') -head_repo_url=$(printf '%s\n' "$pr_meta_json" | jq -r '.headRepository.url // empty') - -if [ -n "${PR_HEAD:-}" ] && [ "$head" != "$PR_HEAD" ]; then - echo "ERROR: PR head branch changed from $PR_HEAD to $head. Re-run /review-pr." - exit 1 -fi -``` - -2. Fetch PR head and rebase on latest `origin/main` - -```sh -git fetch origin pull//head:pr- --force -git checkout -B pr--prep pr- -git fetch origin main -git rebase origin/main -``` - -If conflicts happen: - -- Resolve each conflicted file. -- Run `git add ` for each file. -- Run `git rebase --continue`. - -If the rebase gets confusing or you resolve conflicts 3 or more times, stop and report. - -3. Fix issues from `.local/review.md` - -- Fix all BLOCKER and IMPORTANT items. -- NITs are optional. -- Keep scope tight. - -Keep a running log in `.local/prep.md`: - -- List which review items you fixed. -- List which files you touched. -- Note behavior changes. - -4. Optional quick feedback tests before full gates - -Targeted tests are optional quick feedback, not a substitute for full gates. - -If running targeted tests in a fresh worktree: - -```sh -if [ ! -x node_modules/.bin/vitest ]; then - pnpm install --frozen-lockfile -fi -``` - -5. Commit prep fixes with required subject format - -Use `scripts/committer` with explicit file paths. - -Required subject format: - -- `fix: (openclaw#) thanks @` - -```sh -commit_msg="fix: (openclaw#$pr_number) thanks @$contrib" -scripts/committer "$commit_msg" ... -``` - -If there are no local changes, do not create a no-op commit. - -Post-commit validation (mandatory): - -```sh -subject=$(git log -1 --pretty=%s) -echo "$subject" | rg -q "openclaw#$pr_number" || { echo "ERROR: commit subject missing openclaw#$pr_number"; exit 1; } -echo "$subject" | rg -q "thanks @$contrib" || { echo "ERROR: commit subject missing thanks @$contrib"; exit 1; } -``` - -6. Decide verification mode and run required gates before pushing - -If you are highly confident the change is docs-only, you may skip `pnpm test`. - -High-confidence docs-only criteria (all must be true): - -- Every changed file is documentation-only (`docs/**`, `README*.md`, `CHANGELOG.md`, `*.md`, `*.mdx`, `mintlify.json`, `docs.json`). -- No code, runtime, test, dependency, or build config files changed (`src/**`, `extensions/**`, `apps/**`, `package.json`, lockfiles, TS/JS config, test files, scripts). -- `.local/review.md` does not call for non-doc behavior fixes. - -Suggested check: - -```sh -changed_files=$(git diff --name-only origin/main...HEAD) -non_docs=$(printf "%s\n" "$changed_files" | grep -Ev '^(docs/|README.*\.md$|CHANGELOG\.md$|.*\.md$|.*\.mdx$|mintlify\.json$|docs\.json$)' || true) - -docs_only=false -if [ -n "$changed_files" ] && [ -z "$non_docs" ]; then - docs_only=true -fi - -echo "docs_only=$docs_only" -``` - -Bootstrap dependencies in a fresh worktree before gates: - -```sh -if [ ! -d node_modules ]; then - pnpm install --frozen-lockfile -fi -``` - -Run required gates: - -```sh -pnpm build -pnpm check - -if [ "$docs_only" = "true" ]; then - echo "Docs-only change detected with high confidence; skipping pnpm test." | tee -a .local/prep.md -else - pnpm test -fi -``` - -Require all required gates to pass. If something fails, fix, commit, and rerun. Allow at most 3 fix-and-rerun cycles. - -7. Push safely to the PR head branch - -Build `prhead` from owner/name first, then validate remote branch SHA before push. - -```sh -if [ -n "$head_owner" ] && [ -n "$head_repo_name" ]; then - head_repo_push_url="https://github.com/$head_owner/$head_repo_name.git" -elif [ -n "$head_repo_url" ] && [ "$head_repo_url" != "null" ]; then - case "$head_repo_url" in - *.git) head_repo_push_url="$head_repo_url" ;; - *) head_repo_push_url="$head_repo_url.git" ;; - esac -else - echo "ERROR: unable to determine PR head repo push URL" - exit 1 -fi - -git remote add prhead "$head_repo_push_url" 2>/dev/null || git remote set-url prhead "$head_repo_push_url" - -echo "Pushing to branch: $head" -if [ "$head" = "main" ] || [ "$head" = "master" ]; then - echo "ERROR: head branch is main/master. This is wrong. Stopping." - exit 1 -fi - -remote_sha=$(git ls-remote prhead "refs/heads/$head" | awk '{print $1}') -if [ -z "$remote_sha" ]; then - echo "ERROR: remote branch refs/heads/$head not found on prhead" - exit 1 -fi -if [ "$remote_sha" != "$pr_head_sha_before" ]; then - echo "ERROR: expected remote SHA $pr_head_sha_before, got $remote_sha. Re-fetch metadata and rebase first." - exit 1 -fi - -git push --force-with-lease=refs/heads/$head:$pr_head_sha_before prhead HEAD:$head || push_failed=1 -``` - -If lease push fails because head moved, perform one automatic retry: - -```sh -if [ "${push_failed:-0}" = "1" ]; then - echo "Lease push failed, retrying once with fresh PR head..." - - pr_head_sha_before=$(gh pr view --json headRefOid --jq .headRefOid) - git fetch origin pull//head:pr--latest --force - git rebase pr--latest - - pnpm build - pnpm check - if [ "$docs_only" != "true" ]; then - pnpm test - fi - - git push --force-with-lease=refs/heads/$head:$pr_head_sha_before prhead HEAD:$head -fi -``` - -8. Verify PR head and base relation (Mandatory) - -```sh -prep_head_sha=$(git rev-parse HEAD) -pr_head_sha_after=$(gh pr view --json headRefOid --jq .headRefOid) - -if [ "$prep_head_sha" != "$pr_head_sha_after" ]; then - echo "ERROR: pushed head SHA does not match PR head SHA." - exit 1 -fi - -git fetch origin main -git fetch origin pull//head:pr--verify --force -git merge-base --is-ancestor origin/main pr--verify && echo "PR is up to date with main" || (echo "ERROR: PR is still behind main, rebase again" && exit 1) -git branch -D pr--verify 2>/dev/null || true -``` - -9. Write prep summary artifacts (Mandatory) - -Write `.local/prep.md` and `.local/prep.env` for merge handoff. - -```sh -contrib_id=$(gh api users/$contrib --jq .id) -coauthor_email="${contrib_id}+${contrib}@users.noreply.github.com" - -cat > .local/prep.env < origin/main -else - git worktree add "$WORKTREE_DIR" -b temp/pr- origin/main - cd "$WORKTREE_DIR" -fi - -# Create local scratch space that persists across /review-pr to /prepare-pr to /merge-pr -mkdir -p .local -``` - -Run all commands inside the worktree directory. -Start on `origin/main` so you can check for existing implementations before looking at PR code. - -## Steps - -1. Identify PR meta and context - -```sh -pr_meta_json=$(gh pr view --json number,title,state,isDraft,author,baseRefName,headRefName,headRefOid,headRepository,url,body,labels,assignees,reviewRequests,files,additions,deletions,statusCheckRollup) -printf '%s\n' "$pr_meta_json" | jq '{number,title,url,state,isDraft,author:.author.login,base:.baseRefName,head:.headRefName,headSha:.headRefOid,headRepo:.headRepository.nameWithOwner,additions,deletions,files:(.files|length),body}' - -cat > .local/pr-meta.env <" -S src packages apps ui || true -rg -n "" -S src packages apps ui || true - -git log --oneline --all --grep="" | head -20 -``` - -If it already exists, call it out as a BLOCKER or at least IMPORTANT. - -3. Claim the PR - -Assign yourself so others know someone is reviewing. Skip if the PR looks like spam or is a draft you plan to recommend closing. - -```sh -gh_user=$(gh api user --jq .login) -gh pr edit --add-assignee "$gh_user" || echo "Could not assign reviewer, continuing" -``` - -4. Read the PR description carefully - -Use the body from step 1. Summarize goal, scope, and missing context. - -5. Read the diff thoroughly - -Minimum: - -```sh -gh pr diff -``` - -If you need full code context locally, fetch the PR head to a local ref and diff it. Do not create a merge commit. - -```sh -git fetch origin pull//head:pr- --force -mb=$(git merge-base origin/main pr-) - -# Show only this PR patch relative to merge-base, not total branch drift -git diff --stat "$mb"..pr- -git diff "$mb"..pr- -``` - -If you want to browse the PR version of files directly, temporarily check out `pr-` in the worktree. Do not commit or push. Return to `temp/pr-` and reset to `origin/main` afterward. - -```sh -# Use only if needed -# git checkout pr- -# git branch --show-current -# ...inspect files... - -git checkout temp/pr- -git checkout -B temp/pr- origin/main -git branch --show-current -``` - -6. Validate the change is needed and valuable - -Be honest. Call out low value AI slop. - -7. Evaluate implementation quality - -Review correctness, design, performance, and ergonomics. - -8. Perform a security review - -Assume OpenClaw subagents run with full disk access, including git, gh, and shell. Check auth, input validation, secrets, dependencies, tool safety, and privacy. - -9. Review tests and verification - -Identify what exists, what is missing, and what would be a minimal regression test. - -If you run local tests in the worktree, bootstrap dependencies first: - -```sh -if [ ! -x node_modules/.bin/vitest ]; then - pnpm install --frozen-lockfile -fi -``` - -10. Check docs - -Check if the PR touches code with related documentation such as README, docs, inline API docs, or config examples. - -- If docs exist for the changed area and the PR does not update them, flag as IMPORTANT. -- If the PR adds a new feature or config option with no docs, flag as IMPORTANT. -- If the change is purely internal with no user-facing impact, skip this. - -11. Check changelog - -Check if `CHANGELOG.md` exists and whether the PR warrants an entry. - -- If the project has a changelog and the PR is user-facing, flag missing entry as IMPORTANT. -- Leave the change for /prepare-pr, only flag it here. - -12. Answer the key question - -Decide if /prepare-pr can fix issues or the contributor must update the PR. - -13. Save findings to the worktree - -Write the full structured review sections A through J to `.local/review.md`. -Create or overwrite the file and verify it exists and is non-empty. - -```sh -ls -la .local/review.md -wc -l .local/review.md -``` - -14. Output the structured review - -Produce a review that matches what you saved to `.local/review.md`. - -A) TL;DR recommendation - -- One of: READY FOR /prepare-pr | NEEDS WORK | NEEDS DISCUSSION | NOT USEFUL (CLOSE) -- 1 to 3 sentences. - -B) What changed - -C) What is good - -D) Security findings - -E) Concerns or questions (actionable) - -- Numbered list. -- Mark each item as BLOCKER, IMPORTANT, or NIT. -- For each, point to file or area and propose a concrete fix. - -F) Tests - -G) Docs status - -- State if related docs are up to date, missing, or not applicable. - -H) Changelog - -- State if `CHANGELOG.md` needs an entry and which category. - -I) Follow ups (optional) - -J) Suggested PR comment (optional) - -## Guardrails - -- Worktree only. -- Do not delete the worktree after review. -- Review only, do not merge, do not push. diff --git a/.agents/archive/review-pr-v1/agents/openai.yaml b/.agents/archive/review-pr-v1/agents/openai.yaml deleted file mode 100644 index f6593499507..00000000000 --- a/.agents/archive/review-pr-v1/agents/openai.yaml +++ /dev/null @@ -1,4 +0,0 @@ -interface: - display_name: "Review PR" - short_description: "Review GitHub PRs without merging" - default_prompt: "Use $review-pr to perform a thorough, review-only GitHub PR review." diff --git a/.agents/maintainers.md b/.agents/maintainers.md new file mode 100644 index 00000000000..2bbb9c6203e --- /dev/null +++ b/.agents/maintainers.md @@ -0,0 +1 @@ +Maintainer skills now live in [`openclaw/maintainers`](https://github.com/openclaw/maintainers/). diff --git a/.agents/skills/PR_WORKFLOW.md b/.agents/skills/PR_WORKFLOW.md deleted file mode 100644 index 40306507355..00000000000 --- a/.agents/skills/PR_WORKFLOW.md +++ /dev/null @@ -1,249 +0,0 @@ -# PR Workflow for Maintainers - -Please read this in full and do not skip sections. -This is the single source of truth for the maintainer PR workflow. - -## Triage order - -Process PRs **oldest to newest**. Older PRs are more likely to have merge conflicts and stale dependencies; resolving them first keeps the queue healthy and avoids snowballing rebase pain. - -## Working rule - -Skills execute workflow. Maintainers provide judgment. -Always pause between skills to evaluate technical direction, not just command success. - -These three skills must be used in order: - -1. `review-pr` — review only, produce findings -2. `prepare-pr` — rebase, fix, gate, push to PR head branch -3. `merge-pr` — squash-merge, verify MERGED state, clean up - -They are necessary, but not sufficient. Maintainers must steer between steps and understand the code before moving forward. - -Treat PRs as reports first, code second. -If submitted code is low quality, ignore it and implement the best solution for the problem. - -Do not continue if you cannot verify the problem is real or test the fix. - -## Script-first contract - -Skill runs should invoke these wrappers automatically. You only need to run them manually when debugging or doing an explicit script-only run: - -- `scripts/pr-review ` -- `scripts/pr review-checkout-main ` or `scripts/pr review-checkout-pr ` while reviewing -- `scripts/pr review-guard ` before writing review outputs -- `scripts/pr review-validate-artifacts ` after writing outputs -- `scripts/pr-prepare init ` -- `scripts/pr-prepare validate-commit ` -- `scripts/pr-prepare gates ` -- `scripts/pr-prepare push ` -- Optional one-shot prepare: `scripts/pr-prepare run ` -- `scripts/pr-merge ` (verify-only; short form remains backward compatible) -- `scripts/pr-merge verify ` (verify-only) -- Optional one-shot merge: `scripts/pr-merge run ` - -These wrappers run shared preflight checks and generate deterministic artifacts. They are designed to work from repo root or PR worktree cwd. - -## Required artifacts - -- `.local/pr-meta.json` and `.local/pr-meta.env` from review init. -- `.local/review.md` and `.local/review.json` from review output. -- `.local/prep-context.env` and `.local/prep.md` from prepare. -- `.local/prep.env` from prepare completion. - -## Structured review handoff - -`review-pr` must write `.local/review.json`. -In normal skill runs this is handled automatically. Use `scripts/pr review-artifacts-init ` and `scripts/pr review-tests ...` manually only for debugging or explicit script-only runs. - -Minimum schema: - -```json -{ - "recommendation": "READY FOR /prepare-pr", - "findings": [ - { - "id": "F1", - "severity": "IMPORTANT", - "title": "Missing changelog entry", - "area": "CHANGELOG.md", - "fix": "Add a Fixes entry for PR #" - } - ], - "tests": { - "ran": ["pnpm test -- ..."], - "gaps": ["..."], - "result": "pass" - } -} -``` - -`prepare-pr` resolves all `BLOCKER` and `IMPORTANT` findings from this file. - -## Coding Agent - -Use ChatGPT 5.3 Codex High. Fall back to 5.2 Codex High or 5.3 Codex Medium if necessary. - -## PR quality bar - -- Do not trust PR code by default. -- Do not merge changes you cannot validate with a reproducible problem and a tested fix. -- Keep types strict. Do not use `any` in implementation code. -- Keep external-input boundaries typed and validated, including CLI input, environment variables, network payloads, and tool output. -- Keep implementations properly scoped. Fix root causes, not local symptoms. -- Identify and reuse canonical sources of truth so behavior does not drift across the codebase. -- Harden changes. Always evaluate security impact and abuse paths. -- Understand the system before changing it. Never make the codebase messier just to clear a PR queue. - -## Rebase and conflict resolution - -Before any substantive review or prep work, **always rebase the PR branch onto current `main` and resolve merge conflicts first**. A PR that cannot cleanly rebase is not ready for review — fix conflicts before evaluating correctness. - -- During `prepare-pr`: rebase onto `main` as the first step, before fixing findings or running gates. -- If conflicts are complex or touch areas you do not understand, stop and escalate. -- Prefer **rebase** for linear history; **squash** when commit history is messy or unhelpful. - -## Commit and changelog rules - -- In normal `prepare-pr` runs, commits are created via `scripts/committer "" `. Use it manually only when operating outside the skill flow; avoid manual `git add`/`git commit` so staging stays scoped. -- Follow concise, action-oriented commit messages (e.g., `CLI: add verbose flag to send`). -- During `prepare-pr`, use concise, action-oriented subjects **without** PR numbers or thanks; reserve `(#) thanks @` for the final merge/squash commit. -- Group related changes; avoid bundling unrelated refactors. -- Changelog workflow: keep the latest released version at the top (no `Unreleased`); after publishing, bump the version and start a new top section. -- When working on a PR: add a changelog entry with the PR number and thank the contributor (mandatory in this workflow). -- When working on an issue: reference the issue in the changelog entry. -- In this workflow, changelog is always required even for internal/test-only changes. - -## Gate policy - -In fresh worktrees, dependency bootstrap is handled by wrappers before local gates. Manual equivalent: - -```sh -pnpm install --frozen-lockfile -``` - -Gate set: - -- Always: `pnpm build`, `pnpm check` -- `pnpm test` required unless high-confidence docs-only criteria pass. - -## Co-contributor and clawtributors - -- If we squash, add the PR author as a co-contributor in the commit body using a `Co-authored-by:` trailer. -- When maintainer prepares and merges the PR, add the maintainer as an additional `Co-authored-by:` trailer too. -- Avoid `--auto` merges for maintainer landings. Merge only after checks are green so the maintainer account is the actor and attribution is deterministic. -- For squash merges, set `--author-email` to a reviewer-owned email with fallback candidates; if merge fails due to author-email validation, retry once with the next candidate. -- If you review a PR and later do work on it, land via merge/squash (no direct-main commits) and always add the PR author as a co-contributor. -- When merging a PR: leave a PR comment that explains exactly what we did, include the SHA hashes, and record the comment URL in the final report. -- Manual post-merge step for new contributors: run `bun scripts/update-clawtributors.ts` to add their avatar to the README "Thanks to all clawtributors" list, then commit the regenerated README. - -## Review mode vs landing mode - -- **Review mode (PR link only):** read `gh pr view`/`gh pr diff`; **do not** switch branches; **do not** change code. -- **Landing mode (exception path):** use only when normal `review-pr -> prepare-pr -> merge-pr` flow cannot safely preserve attribution or cannot satisfy branch protection. Create an integration branch from `main`, bring in PR commits (**prefer rebase** for linear history; **merge allowed** when complexity/conflicts make it safer), apply fixes, add changelog (+ thanks + PR #), run full gate **locally before committing** (`pnpm build && pnpm check && pnpm test`), commit, merge back to `main`, then `git switch main` (never stay on a topic branch after landing). Important: the contributor needs to be in the git graph after this! - -## Pre-review safety checks - -- Before starting a review when a GH Issue/PR is pasted: `review-pr`/`scripts/pr-review` should create and use an isolated `.worktrees/pr-` checkout from `origin/main` automatically. Do not require a clean main checkout, and do not run `git pull` in a dirty main checkout. -- PR review calls: prefer a single `gh pr view --json ...` to batch metadata/comments; run `gh pr diff` only when needed. -- PRs should summarize scope, note testing performed, and mention any user-facing changes or new flags. -- Read `docs/help/submitting-a-pr.md` ([Submitting a PR](https://docs.openclaw.ai/help/submitting-a-pr)) for what we expect from contributors. - -## Unified workflow - -Entry criteria: - -- PR URL/number is known. -- Problem statement is clear enough to attempt reproduction. -- A realistic verification path exists (tests, integration checks, or explicit manual validation). - -### 1) `review-pr` - -Purpose: - -- Review only: correctness, value, security risk, tests, docs, and changelog impact. -- Produce structured findings and a recommendation. - -Expected output: - -- Recommendation: ready, needs work, needs discussion, or close. -- `.local/review.md` with actionable findings. - -Maintainer checkpoint before `prepare-pr`: - -``` -What problem are they trying to solve? -What is the most optimal implementation? -Can we fix up everything? -Do we have any questions? -``` - -Stop and escalate instead of continuing if: - -- The problem cannot be reproduced or confirmed. -- The proposed PR scope does not match the stated problem. -- The design introduces unresolved security or trust-boundary concerns. - -### 2) `prepare-pr` - -Purpose: - -- Make the PR merge-ready on its head branch. -- Rebase onto current `main` first, then fix blocker/important findings, then run gates. -- In fresh worktrees, bootstrap dependencies before local gates (`pnpm install --frozen-lockfile`). - -Expected output: - -- Updated code and tests on the PR head branch. -- `.local/prep.md` with changes, verification, and current HEAD SHA. -- Final status: `PR is ready for /merge-pr`. - -Maintainer checkpoint before `merge-pr`: - -``` -Is this the most optimal implementation? -Is the code properly scoped? -Is the code properly reusing existing logic in the codebase? -Is the code properly typed? -Is the code hardened? -Do we have enough tests? -Do we need regression tests? -Are tests using fake timers where appropriate? (e.g., debounce/throttle, retry backoff, timeout branches, delayed callbacks, polling loops) -Do not add performative tests, ensure tests are real and there are no regressions. -Do you see any follow-up refactors we should do? -Did any changes introduce any potential security vulnerabilities? -Take your time, fix it properly, refactor if necessary. -``` - -Stop and escalate instead of continuing if: - -- You cannot verify behavior changes with meaningful tests or validation. -- Fixing findings requires broad architecture changes outside safe PR scope. -- Security hardening requirements remain unresolved. - -### 3) `merge-pr` - -Purpose: - -- Merge only after review and prep artifacts are present and checks are green. -- Use deterministic squash merge flow (`--match-head-commit` + explicit subject/body with co-author trailer), then verify the PR ends in `MERGED` state. -- If no required checks are configured on the PR, treat that as acceptable and continue after branch-up-to-date validation. - -Go or no-go checklist before merge: - -- All BLOCKER and IMPORTANT findings are resolved. -- Verification is meaningful and regression risk is acceptably low. -- Changelog is updated (mandatory) and docs are updated when required. -- Required CI checks are green and the branch is not behind `main`. - -Expected output: - -- Successful merge commit and recorded merge SHA. -- Worktree cleanup after successful merge. -- Comment on PR indicating merge was successful. - -Maintainer checkpoint after merge: - -- Were any refactors intentionally deferred and now need follow-up issue(s)? -- Did this reveal broader architecture or test gaps we should address? -- Run `bun scripts/update-clawtributors.ts` if the contributor is new. diff --git a/.agents/skills/merge-pr/SKILL.md b/.agents/skills/merge-pr/SKILL.md deleted file mode 100644 index 041e79a6768..00000000000 --- a/.agents/skills/merge-pr/SKILL.md +++ /dev/null @@ -1,99 +0,0 @@ ---- -name: merge-pr -description: Script-first deterministic squash merge with strict required-check gating, head-SHA pinning, and reliable attribution/commenting. ---- - -# Merge PR - -## Overview - -Merge a prepared PR only after deterministic validation. - -## Inputs - -- Ask for PR number or URL. -- If missing, use `.local/prep.env` from the PR worktree. - -## Safety - -- Never use `gh pr merge --auto` in this flow. -- Never run `git push` directly. -- Require `--match-head-commit` during merge. -- Wrapper commands are cwd-agnostic; you can run them from repo root or inside the PR worktree. - -## Execution Contract - -1. Validate merge readiness: - -```sh -scripts/pr-merge verify -``` - -Backward-compatible verify form also works: - -```sh -scripts/pr-merge -``` - -2. Run one-shot deterministic merge: - -```sh -scripts/pr-merge run -``` - -3. Ensure output reports: - -- `merge_sha=` -- `merge_author_email=` -- `comment_url=` - -## Steps - -1. Validate artifacts - -```sh -require=(.local/review.md .local/review.json .local/prep.md .local/prep.env) -for f in "${require[@]}"; do - [ -s "$f" ] || { echo "Missing artifact: $f"; exit 1; } -done -``` - -2. Validate checks and branch status - -```sh -scripts/pr-merge verify -source .local/prep.env -``` - -`scripts/pr-merge` treats “no required checks configured” as acceptable (`[]`), but fails on any required `fail` or `pending`. - -3. Merge deterministically (wrapper-managed) - -```sh -scripts/pr-merge run -``` - -`scripts/pr-merge run` performs: - -- deterministic squash merge pinned to `PREP_HEAD_SHA` -- reviewer merge author email selection with fallback candidates -- one retry only when merge fails due to author-email validation -- co-author trailers for PR author and reviewer -- post-merge verification of both co-author trailers on commit message -- PR comment retry (3 attempts), then comment URL extraction -- cleanup after confirmed `MERGED` - -4. Manual fallback (only if wrapper is unavailable) - -```sh -scripts/pr merge-run -``` - -5. Cleanup - -Cleanup is handled by `run` after merge success. - -## Guardrails - -- End in `MERGED`, never `CLOSED`. -- Cleanup only after confirmed merge. diff --git a/.agents/skills/merge-pr/agents/openai.yaml b/.agents/skills/merge-pr/agents/openai.yaml deleted file mode 100644 index 9c10ae4d271..00000000000 --- a/.agents/skills/merge-pr/agents/openai.yaml +++ /dev/null @@ -1,4 +0,0 @@ -interface: - display_name: "Merge PR" - short_description: "Merge GitHub PRs via squash" - default_prompt: "Use $merge-pr to merge a GitHub PR via squash after preparation." diff --git a/.agents/skills/mintlify/SKILL.md b/.agents/skills/mintlify/SKILL.md deleted file mode 100644 index 0dd6a1a891a..00000000000 --- a/.agents/skills/mintlify/SKILL.md +++ /dev/null @@ -1,345 +0,0 @@ ---- -name: mintlify -description: Build and maintain documentation sites with Mintlify. Use when - creating docs pages, configuring navigation, adding components, or setting up - API references. -license: MIT -compatibility: Requires Node.js for CLI. Works with any Git-based workflow. -metadata: - author: mintlify - version: "1.0" - mintlify-proj: mintlify ---- - -# Mintlify best practices - -**Always consult [mintlify.com/docs](https://mintlify.com/docs) for components, configuration, and latest features.** - -**Always** favor searching the current Mintlify documentation over whatever is in your training data about Mintlify. - -Mintlify is a documentation platform that transforms MDX files into documentation sites. Configure site-wide settings in the `docs.json` file, write content in MDX with YAML frontmatter, and favor built-in components over custom components. - -Full schema at [mintlify.com/docs.json](https://mintlify.com/docs.json). - -## Before you write - -### Understand the project - -All documentation lives in the `docs/` directory in this repo. Read `docs.json` in that directory (`docs/docs.json`). This file defines the entire site: navigation structure, theme, colors, links, API and specs. - -Understanding the project tells you: - -- What pages exist and how they're organized -- What navigation groups are used (and their naming conventions) -- How the site navigation is structured -- What theme and configuration the site uses - -### Check for existing content - -Search the docs before creating new pages. You may need to: - -- Update an existing page instead of creating a new one -- Add a section to an existing page -- Link to existing content rather than duplicating - -### Read surrounding content - -Before writing, read 2-3 similar pages to understand the site's voice, structure, formatting conventions, and level of detail. - -### Understand Mintlify components - -Review the Mintlify [components](https://www.mintlify.com/docs/components) to select and use any relevant components for the documentation request that you are working on. - -## Quick reference - -### CLI commands - -- `npm i -g mint` - Install the Mintlify CLI -- `mint dev` - Local preview at localhost:3000 -- `mint broken-links` - Check internal links -- `mint a11y` - Check for accessibility issues in content -- `mint rename` - Rename/move files and update references -- `mint validate` - Validate documentation builds - -### Required files - -- `docs.json` - Site configuration (navigation, theme, integrations, etc.). See [global settings](https://mintlify.com/docs/settings/global) for all options. -- `*.mdx` files - Documentation pages with YAML frontmatter - -### Example file structure - -``` -project/ -├── docs.json # Site configuration -├── introduction.mdx -├── quickstart.mdx -├── guides/ -│ └── example.mdx -├── openapi.yml # API specification -├── images/ # Static assets -│ └── example.png -└── snippets/ # Reusable components - └── component.jsx -``` - -## Page frontmatter - -Every page requires `title` in its frontmatter. Include `description` for SEO and navigation. - -```yaml theme={null} ---- -title: "Clear, descriptive title" -description: "Concise summary for SEO and navigation." ---- -``` - -Optional frontmatter fields: - -- `sidebarTitle`: Short title for sidebar navigation. -- `icon`: Lucide or Font Awesome icon name, URL, or file path. -- `tag`: Label next to the page title in the sidebar (for example, "NEW"). -- `mode`: Page layout mode (`default`, `wide`, `custom`). -- `keywords`: Array of terms related to the page content for local search and SEO. -- Any custom YAML fields for use with personalization or conditional content. - -## File conventions - -- Match existing naming patterns in the directory -- If there are no existing files or inconsistent file naming patterns, use kebab-case: `getting-started.mdx`, `api-reference.mdx` -- Use root-relative paths without file extensions for internal links: `/getting-started/quickstart` -- Do not use relative paths (`../`) or absolute URLs for internal pages -- When you create a new page, add it to `docs.json` navigation or it won't appear in the sidebar - -## Organize content - -When a user asks about anything related to site-wide configurations, start by understanding the [global settings](https://www.mintlify.com/docs/organize/settings). See if a setting in the `docs.json` file can be updated to achieve what the user wants. - -### Navigation - -The `navigation` property in `docs.json` controls site structure. Choose one primary pattern at the root level, then nest others within it. - -**Choose your primary pattern:** - -| Pattern | When to use | -| ------------- | ---------------------------------------------------------------------------------------------- | -| **Groups** | Default. Single audience, straightforward hierarchy | -| **Tabs** | Distinct sections with different audiences (Guides vs API Reference) or content types | -| **Anchors** | Want persistent section links at sidebar top. Good for separating docs from external resources | -| **Dropdowns** | Multiple doc sections users switch between, but not distinct enough for tabs | -| **Products** | Multi-product company with separate documentation per product | -| **Versions** | Maintaining docs for multiple API/product versions simultaneously | -| **Languages** | Localized content | - -**Within your primary pattern:** - -- **Groups** - Organize related pages. Can nest groups within groups, but keep hierarchy shallow -- **Menus** - Add dropdown navigation within tabs for quick jumps to specific pages -- **`expanded: false`** - Collapse nested groups by default. Use for reference sections users browse selectively -- **`openapi`** - Auto-generate pages from OpenAPI spec. Add at group/tab level to inherit - -**Common combinations:** - -- Tabs containing groups (most common for docs with API reference) -- Products containing tabs (multi-product SaaS) -- Versions containing tabs (versioned API docs) -- Anchors containing groups (simple docs with external resource links) - -### Links and paths - -- **Internal links:** Root-relative, no extension: `/getting-started/quickstart` -- **Images:** Store in `/images`, reference as `/images/example.png` -- **External links:** Use full URLs, they open in new tabs automatically - -## Customize docs sites - -**What to customize where:** - -- **Brand colors, fonts, logo** → `docs.json`. See [global settings](https://mintlify.com/docs/settings/global) -- **Component styling, layout tweaks** → `custom.css` at project root -- **Dark mode** → Enabled by default. Only disable with `"appearance": "light"` in `docs.json` if brand requires it - -Start with `docs.json`. Only add `custom.css` when you need styling that config doesn't support. - -## Write content - -### Components - -The [components overview](https://mintlify.com/docs/components) organizes all components by purpose: structure content, draw attention, show/hide content, document APIs, link to pages, and add visual context. Start there to find the right component. - -**Common decision points:** - -| Need | Use | -| -------------------------- | ----------------------- | -| Hide optional details | `` | -| Long code examples | `` | -| User chooses one option | `` | -| Linked navigation cards | `` in `` | -| Sequential instructions | `` | -| Code in multiple languages | `` | -| API parameters | `` | -| API response fields | `` | - -**Callouts by severity:** - -- `` - Supplementary info, safe to skip -- `` - Helpful context such as permissions -- `` - Recommendations or best practices -- `` - Potentially destructive actions -- `` - Success confirmation - -### Reusable content - -**When to use snippets:** - -- Exact content appears on more than one page -- Complex components you want to maintain in one place -- Shared content across teams/repos - -**When NOT to use snippets:** - -- Slight variations needed per page (leads to complex props) - -Import snippets with `import { Component } from "/path/to/snippet-name.jsx"`. - -## Writing standards - -### Voice and structure - -- Second-person voice ("you") -- Active voice, direct language -- Sentence case for headings ("Getting started", not "Getting Started") -- Sentence case for code block titles ("Expandable example", not "Expandable Example") -- Lead with context: explain what something is before how to use it -- Prerequisites at the start of procedural content - -### What to avoid - -**Never use:** - -- Marketing language ("powerful", "seamless", "robust", "cutting-edge") -- Filler phrases ("it's important to note", "in order to") -- Excessive conjunctions ("moreover", "furthermore", "additionally") -- Editorializing ("obviously", "simply", "just", "easily") - -**Watch for AI-typical patterns:** - -- Overly formal or stilted phrasing -- Unnecessary repetition of concepts -- Generic introductions that don't add value -- Concluding summaries that restate what was just said - -### Formatting - -- All code blocks must have language tags -- All images and media must have descriptive alt text -- Use bold and italics only when they serve the reader's understanding--never use text styling just for decoration -- No decorative formatting or emoji - -### Code examples - -- Keep examples simple and practical -- Use realistic values (not "foo" or "bar") -- One clear example is better than multiple variations -- Test that code works before including it - -## Document APIs - -**Choose your approach:** - -- **Have an OpenAPI spec?** → Add to `docs.json` with `"openapi": ["openapi.yaml"]`. Pages auto-generate. Reference in navigation as `GET /endpoint` -- **No spec?** → Write endpoints manually with `api: "POST /users"` in frontmatter. More work but full control -- **Hybrid** → Use OpenAPI for most endpoints, manual pages for complex workflows - -Encourage users to generate endpoint pages from an OpenAPI spec. It is the most efficient and easiest to maintain option. - -## Deploy - -Mintlify deploys automatically when changes are pushed to the connected Git repository. - -**What agents can configure:** - -- **Redirects** → Add to `docs.json` with `"redirects": [{"source": "/old", "destination": "/new"}]` -- **SEO indexing** → Control with `"seo": {"indexing": "all"}` to include hidden pages in search - -**Requires dashboard setup (human task):** - -- Custom domains and subdomains -- Preview deployment settings -- DNS configuration - -For `/docs` subpath hosting with Vercel or Cloudflare, agents can help configure rewrite rules. See [/docs subpath](https://mintlify.com/docs/deploy/vercel). - -## Workflow - -### 1. Understand the task - -Identify what needs to be documented, which pages are affected, and what the reader should accomplish afterward. If any of these are unclear, ask. - -### 2. Research - -- Read `docs/docs.json` to understand the site structure -- Search existing docs for related content -- Read similar pages to match the site's style - -### 3. Plan - -- Synthesize what the reader should accomplish after reading the docs and the current content -- Propose any updates or new content -- Verify that your proposed changes will help readers be successful - -### 4. Write - -- Start with the most important information -- Keep sections focused and scannable -- Use components appropriately (don't overuse them) -- Mark anything uncertain with a TODO comment: - -```mdx theme={null} -{/* TODO: Verify the default timeout value */} -``` - -### 5. Update navigation - -If you created a new page, add it to the appropriate group in `docs.json`. - -### 6. Verify - -Before submitting: - -- [ ] Frontmatter includes title and description -- [ ] All code blocks have language tags -- [ ] Internal links use root-relative paths without file extensions -- [ ] New pages are added to `docs.json` navigation -- [ ] Content matches the style of surrounding pages -- [ ] No marketing language or filler phrases -- [ ] TODOs are clearly marked for anything uncertain -- [ ] Run `mint broken-links` to check links -- [ ] Run `mint validate` to find any errors - -## Edge cases - -### Migrations - -If a user asks about migrating to Mintlify, ask if they are using ReadMe or Docusaurus. If they are, use the [@mintlify/scraping](https://www.npmjs.com/package/@mintlify/scraping) CLI to migrate content. If they are using a different platform to host their documentation, help them manually convert their content to MDX pages using Mintlify components. - -### Hidden pages - -Any page that is not included in the `docs.json` navigation is hidden. Use hidden pages for content that should be accessible by URL or indexed for the assistant or search, but not discoverable through the sidebar navigation. - -### Exclude pages - -The `.mintignore` file is used to exclude files from a documentation repository from being processed. - -## Common gotchas - -1. **Component imports** - JSX components need explicit import, MDX components don't -2. **Frontmatter required** - Every MDX file needs `title` at minimum -3. **Code block language** - Always specify language identifier -4. **Never use `mint.json`** - `mint.json` is deprecated. Only ever use `docs.json` - -## Resources - -- [Documentation](https://mintlify.com/docs) -- [Configuration schema](https://mintlify.com/docs.json) -- [Feature requests](https://github.com/orgs/mintlify/discussions/categories/feature-requests) -- [Bugs and feedback](https://github.com/orgs/mintlify/discussions/categories/bugs-feedback) diff --git a/.agents/skills/prepare-pr/SKILL.md b/.agents/skills/prepare-pr/SKILL.md deleted file mode 100644 index 462e5bc2bd4..00000000000 --- a/.agents/skills/prepare-pr/SKILL.md +++ /dev/null @@ -1,122 +0,0 @@ ---- -name: prepare-pr -description: Script-first PR preparation with structured findings resolution, deterministic push safety, and explicit gate execution. ---- - -# Prepare PR - -## Overview - -Prepare the PR head branch for merge after `/review-pr`. - -## Inputs - -- Ask for PR number or URL. -- If missing, use `.local/pr-meta.env` if present in the PR worktree. - -## Safety - -- Never push to `main`. -- Only push to PR head with explicit `--force-with-lease` against known head SHA. -- Do not run `git clean -fdx`. -- Wrappers are cwd-agnostic; run from repo root or PR worktree. - -## Execution Contract - -1. Run setup: - -```sh -scripts/pr-prepare init -``` - -2. Resolve findings from structured review: - -- `.local/review.json` is mandatory. -- Resolve all `BLOCKER` and `IMPORTANT` items. - -3. Commit scoped changes with concise subjects (no PR number/thanks; those belong on the final merge/squash commit). - -4. Run gates via wrapper. - -5. Push via wrapper (includes pre-push remote verification, one automatic lease-retry path, and post-push API propagation retry). - -Optional one-shot path: - -```sh -scripts/pr-prepare run -``` - -## Steps - -1. Setup and artifacts - -```sh -scripts/pr-prepare init - -ls -la .local/review.md .local/review.json .local/pr-meta.env .local/prep-context.env -jq . .local/review.json >/dev/null -``` - -2. Resolve required findings - -List required items: - -```sh -jq -r '.findings[] | select(.severity=="BLOCKER" or .severity=="IMPORTANT") | "- [\(.severity)] \(.id): \(.title) => \(.fix)"' .local/review.json -``` - -Fix all required findings. Keep scope tight. - -3. Update changelog/docs (changelog is mandatory in this workflow) - -```sh -jq -r '.changelog' .local/review.json -jq -r '.docs' .local/review.json -``` - -4. Commit scoped changes - -Use concise, action-oriented subject lines without PR numbers/thanks. The final merge/squash commit is the only place we include PR numbers and contributor thanks. - -Use explicit file list: - -```sh -scripts/committer "fix: " ... -``` - -5. Run gates - -```sh -scripts/pr-prepare gates -``` - -6. Push safely to PR head - -```sh -scripts/pr-prepare push -``` - -This push step includes: - -- robust fork remote resolution from owner/name, -- pre-push remote SHA verification, -- one automatic rebase + gate rerun + retry if lease push fails, -- post-push PR-head propagation retry, -- idempotent behavior when local prep HEAD is already on the PR head, -- post-push SHA verification and `.local/prep.env` generation. - -7. Verify handoff artifacts - -```sh -ls -la .local/prep.md .local/prep.env -``` - -8. Output - -- Summarize resolved findings and gate results. -- Print exactly: `PR is ready for /merge-pr`. - -## Guardrails - -- Do not run `gh pr merge` in this skill. -- Do not delete worktree. diff --git a/.agents/skills/prepare-pr/agents/openai.yaml b/.agents/skills/prepare-pr/agents/openai.yaml deleted file mode 100644 index 290b1b5ab61..00000000000 --- a/.agents/skills/prepare-pr/agents/openai.yaml +++ /dev/null @@ -1,4 +0,0 @@ -interface: - display_name: "Prepare PR" - short_description: "Prepare GitHub PRs for merge" - default_prompt: "Use $prepare-pr to prep a GitHub PR for merge without merging." diff --git a/.agents/skills/review-pr/SKILL.md b/.agents/skills/review-pr/SKILL.md deleted file mode 100644 index f5694ca2c41..00000000000 --- a/.agents/skills/review-pr/SKILL.md +++ /dev/null @@ -1,142 +0,0 @@ ---- -name: review-pr -description: Script-first review-only GitHub pull request analysis. Use for deterministic PR review with structured findings handoff to /prepare-pr. ---- - -# Review PR - -## Overview - -Perform a read-only review and produce both human and machine-readable outputs. - -## Inputs - -- Ask for PR number or URL. -- If missing, always ask. - -## Safety - -- Never push, merge, or modify code intended to keep. -- Work only in `.worktrees/pr-`. -- Wrapper commands are cwd-agnostic; you can run them from repo root or inside the PR worktree. - -## Execution Contract - -1. Run wrapper setup: - -```sh -scripts/pr-review -``` - -2. Use explicit branch mode switches: - -- Main baseline mode: `scripts/pr review-checkout-main ` -- PR-head mode: `scripts/pr review-checkout-pr ` - -3. Before writing review outputs, run branch guard: - -```sh -scripts/pr review-guard -``` - -4. Write both outputs: - -- `.local/review.md` with sections A through J. -- `.local/review.json` with structured findings. - -5. Validate artifacts semantically: - -```sh -scripts/pr review-validate-artifacts -``` - -## Steps - -1. Setup and metadata - -```sh -scripts/pr-review -ls -la .local/pr-meta.json .local/pr-meta.env .local/review-context.env .local/review-mode.env -``` - -2. Existing implementation check on main - -```sh -scripts/pr review-checkout-main -rg -n "" -S src extensions apps || true -git log --oneline --all --grep "" | head -20 -``` - -3. Claim PR - -```sh -gh_user=$(gh api user --jq .login) -gh pr edit --add-assignee "$gh_user" || echo "Could not assign reviewer, continuing" -``` - -4. Read PR description and diff - -```sh -scripts/pr review-checkout-pr -gh pr diff - -source .local/review-context.env -git diff --stat "$MERGE_BASE"..pr- -git diff "$MERGE_BASE"..pr- -``` - -5. Optional local tests - -Use the wrapper for target validation and executed-test verification: - -```sh -scripts/pr review-tests [ ...] -``` - -6. Initialize review artifact templates - -```sh -scripts/pr review-artifacts-init -``` - -7. Produce review outputs - -- Fill `.local/review.md` sections A through J. -- Fill `.local/review.json`. - -Minimum JSON shape: - -```json -{ - "recommendation": "READY FOR /prepare-pr", - "findings": [ - { - "id": "F1", - "severity": "IMPORTANT", - "title": "...", - "area": "path/or/component", - "fix": "Actionable fix" - } - ], - "tests": { - "ran": [], - "gaps": [], - "result": "pass" - }, - "docs": "up_to_date|missing|not_applicable", - "changelog": "required" -} -``` - -8. Guard + validate before final output - -```sh -scripts/pr review-guard -scripts/pr review-validate-artifacts -``` - -## Guardrails - -- Keep review read-only. -- Do not delete worktree. -- Use merge-base scoped diff for local context to avoid stale branch drift. diff --git a/.agents/skills/review-pr/agents/openai.yaml b/.agents/skills/review-pr/agents/openai.yaml deleted file mode 100644 index f6593499507..00000000000 --- a/.agents/skills/review-pr/agents/openai.yaml +++ /dev/null @@ -1,4 +0,0 @@ -interface: - display_name: "Review PR" - short_description: "Review GitHub PRs without merging" - default_prompt: "Use $review-pr to perform a thorough, review-only GitHub PR review." diff --git a/.env.example b/.env.example index 8bc4defd429..41df435b8f9 100644 --- a/.env.example +++ b/.env.example @@ -37,6 +37,16 @@ OPENCLAW_GATEWAY_TOKEN=change-me-to-a-long-random-token # ANTHROPIC_API_KEY=sk-ant-... # GEMINI_API_KEY=... # OPENROUTER_API_KEY=sk-or-... +# OPENCLAW_LIVE_OPENAI_KEY=sk-... +# OPENCLAW_LIVE_ANTHROPIC_KEY=sk-ant-... +# OPENCLAW_LIVE_GEMINI_KEY=... +# OPENAI_API_KEY_1=... +# ANTHROPIC_API_KEY_1=... +# GEMINI_API_KEY_1=... +# GOOGLE_API_KEY=... +# OPENAI_API_KEYS=sk-1,sk-2 +# ANTHROPIC_API_KEYS=sk-ant-1,sk-ant-2 +# GEMINI_API_KEYS=key-1,key-2 # Optional additional providers # ZAI_API_KEY=... diff --git a/.gitattributes b/.gitattributes index 6313b56c578..54fc4c9b14d 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1 +1,3 @@ * text=auto eol=lf +CLAUDE.md -text +src/gateway/server-methods/CLAUDE.md -text diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index 56a343c38d8..927aa7079cf 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -13,7 +13,7 @@ body: attributes: label: Summary description: One-sentence statement of what is broken. - placeholder: After upgrading to 2026.2.13, Telegram thread replies fail with "reply target not found". + placeholder: After upgrading to , behavior regressed from . validations: required: true - type: textarea @@ -48,7 +48,7 @@ body: attributes: label: OpenClaw version description: Exact version/build tested. - placeholder: 2026.2.13 + placeholder: validations: required: true - type: input @@ -83,7 +83,7 @@ body: - Frequency (always/intermittent/edge case) - Consequence (missed messages, failed onboarding, extra cost, etc.) placeholder: | - Affected: Telegram group users on 2026.2.13 + Affected: Telegram group users on Severity: High (blocks replies) Frequency: 100% repro Consequence: Agents cannot respond in threads @@ -92,4 +92,4 @@ body: attributes: label: Additional information description: Add any context that helps triage but does not fit above. - placeholder: Regression started after upgrade from 2026.2.12; temporary workaround is restarting gateway every 30m. + placeholder: Regression started after upgrade from ; temporary workaround is ... diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml index 1b38a9ddf05..4c1b9775597 100644 --- a/.github/ISSUE_TEMPLATE/config.yml +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -2,7 +2,7 @@ blank_issues_enabled: false contact_links: - name: Onboarding url: https://discord.gg/clawd - about: New to OpenClaw? Join Discord for setup guidance from Krill in \#help. + about: "New to OpenClaw? Join Discord for setup guidance in #help." - name: Support url: https://discord.gg/clawd - about: Get help from Krill and the community on Discord in \#help. + about: "Get help from the OpenClaw community on Discord in #help." diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml index 3594b73a2c5..a08b456786e 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.yml +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -21,7 +21,7 @@ body: attributes: label: Problem to solve description: What user pain this solves and why current behavior is insufficient. - placeholder: Teams cannot distinguish agent personas in mixed channels, causing misrouted follow-ups. + placeholder: Agents cannot distinguish persona context in mixed channels, causing misrouted follow-ups. validations: required: true - type: textarea diff --git a/.github/actionlint.yaml b/.github/actionlint.yaml index e660d2a9761..f02fbddb3e8 100644 --- a/.github/actionlint.yaml +++ b/.github/actionlint.yaml @@ -4,8 +4,11 @@ self-hosted-runner: labels: # Blacksmith CI runners - - blacksmith-4vcpu-ubuntu-2404 - - blacksmith-4vcpu-windows-2025 + - blacksmith-8vcpu-ubuntu-2404 + - blacksmith-8vcpu-windows-2025 + - blacksmith-16vcpu-ubuntu-2404 + - blacksmith-16vcpu-windows-2025 + - blacksmith-16vcpu-ubuntu-2404-arm # Ignore patterns for known issues paths: @@ -15,3 +18,5 @@ paths: - "shellcheck reported issue.+" # Ignore intentional if: false for disabled jobs - 'constant expression "false" in condition' + # actionlint's built-in runner label allowlist lags Blacksmith additions. + - 'label "blacksmith-16vcpu-[^"]+" is unknown\.' diff --git a/.github/actions/setup-node-env/action.yml b/.github/actions/setup-node-env/action.yml index 5fa4f6728bc..334cd3c24fb 100644 --- a/.github/actions/setup-node-env/action.yml +++ b/.github/actions/setup-node-env/action.yml @@ -37,7 +37,7 @@ runs: exit 1 - name: Setup Node.js - uses: actions/setup-node@v4 + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 with: node-version: ${{ inputs.node-version }} check-latest: true @@ -52,7 +52,7 @@ runs: if: inputs.install-bun == 'true' uses: oven-sh/setup-bun@v2 with: - bun-version: latest + bun-version: "1.3.9+cf6cdbbba" - name: Runtime versions shell: bash @@ -70,14 +70,29 @@ runs: shell: bash env: CI: "true" + FROZEN_LOCKFILE: ${{ inputs.frozen-lockfile }} run: | + set -euo pipefail export PATH="$NODE_BIN:$PATH" which node node -v pnpm -v - LOCKFILE_FLAG="" - if [ "${{ inputs.frozen-lockfile }}" = "true" ]; then - LOCKFILE_FLAG="--frozen-lockfile" + case "$FROZEN_LOCKFILE" in + true) LOCKFILE_FLAG="--frozen-lockfile" ;; + false) LOCKFILE_FLAG="" ;; + *) + echo "::error::Invalid frozen-lockfile input: '$FROZEN_LOCKFILE' (expected true or false)" + exit 2 + ;; + esac + + install_args=( + install + --ignore-scripts=false + --config.engine-strict=false + --config.enable-pre-post-scripts=true + ) + if [ -n "$LOCKFILE_FLAG" ]; then + install_args+=("$LOCKFILE_FLAG") fi - pnpm install $LOCKFILE_FLAG --ignore-scripts=false --config.engine-strict=false --config.enable-pre-post-scripts=true || \ - pnpm install $LOCKFILE_FLAG --ignore-scripts=false --config.engine-strict=false --config.enable-pre-post-scripts=true + pnpm "${install_args[@]}" || pnpm "${install_args[@]}" diff --git a/.github/actions/setup-pnpm-store-cache/action.yml b/.github/actions/setup-pnpm-store-cache/action.yml index c866393ee43..8e25492ac92 100644 --- a/.github/actions/setup-pnpm-store-cache/action.yml +++ b/.github/actions/setup-pnpm-store-cache/action.yml @@ -14,11 +14,17 @@ runs: steps: - name: Setup pnpm (corepack retry) shell: bash + env: + PNPM_VERSION: ${{ inputs.pnpm-version }} run: | set -euo pipefail + if [[ ! "$PNPM_VERSION" =~ ^[0-9]+(\.[0-9]+){1,2}([.-][0-9A-Za-z.-]+)?$ ]]; then + echo "::error::Invalid pnpm-version input: '$PNPM_VERSION'" + exit 2 + fi corepack enable for attempt in 1 2 3; do - if corepack prepare "pnpm@${{ inputs.pnpm-version }}" --activate; then + if corepack prepare "pnpm@$PNPM_VERSION" --activate; then pnpm -v exit 0 fi diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 829604b4ce3..0a965febb1c 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -111,3 +111,16 @@ updates: - minor - patch open-pull-requests-limit: 5 + + # Docker base images + - package-ecosystem: docker + directory: / + schedule: + interval: weekly + cooldown: + default-days: 7 + groups: + docker-images: + patterns: + - "*" + open-pull-requests-limit: 5 diff --git a/.github/workflows/auto-response.yml b/.github/workflows/auto-response.yml index e3987c500c3..1502456a251 100644 --- a/.github/workflows/auto-response.yml +++ b/.github/workflows/auto-response.yml @@ -13,7 +13,7 @@ jobs: permissions: issues: write pull-requests: write - runs-on: ubuntu-latest + runs-on: blacksmith-16vcpu-ubuntu-2404 steps: - uses: actions/create-github-app-token@d72941d797fd3113feb6b93fd0dec494b13a2547 # v1 id: app-token @@ -48,7 +48,7 @@ jobs: label: "r: third-party-extension", close: true, message: - "This would be better made as a third-party extension with our SDK that you maintain yourself. Docs: https://docs.openclaw.ai/plugin.", + "Please make this as a third-party plugin that you maintain yourself in your own repo. Docs: https://docs.openclaw.ai/plugin. Feel free to open a PR after to add it to our community plugins page: https://docs.openclaw.ai/plugins/community", }, { label: "r: moltbook", @@ -134,7 +134,7 @@ jobs: const invalidLabel = "invalid"; const dirtyLabel = "dirty"; const noisyPrMessage = - "Closing this PR because it looks dirty (too many unrelated commits). Please recreate the PR from a clean branch."; + "Closing this PR because it looks dirty (too many unrelated or unexpected changes). This usually happens when a branch picks up unrelated commits or a merge went sideways. Please recreate the PR from a clean branch."; const pullRequest = context.payload.pull_request; if (pullRequest) { diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5e8a797ce74..f0266c72174 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -13,7 +13,7 @@ jobs: # Detect docs-only changes to skip heavy jobs (test, build, Windows, macOS, Android). # Lint and format always run. Fail-safe: if detection fails, run everything. docs-scope: - runs-on: blacksmith-4vcpu-ubuntu-2404 + runs-on: blacksmith-16vcpu-ubuntu-2404 outputs: docs_only: ${{ steps.check.outputs.docs_only }} docs_changed: ${{ steps.check.outputs.docs_changed }} @@ -33,7 +33,7 @@ jobs: changed-scope: needs: [docs-scope] if: needs.docs-scope.outputs.docs_only != 'true' - runs-on: blacksmith-4vcpu-ubuntu-2404 + runs-on: blacksmith-16vcpu-ubuntu-2404 outputs: run_node: ${{ steps.scope.outputs.run_node }} run_macos: ${{ steps.scope.outputs.run_macos }} @@ -127,7 +127,7 @@ jobs: build-artifacts: needs: [docs-scope, changed-scope, check] if: needs.docs-scope.outputs.docs_only != 'true' && (github.event_name == 'push' || needs.changed-scope.outputs.run_node == 'true') - runs-on: blacksmith-4vcpu-ubuntu-2404 + runs-on: blacksmith-16vcpu-ubuntu-2404 steps: - name: Checkout uses: actions/checkout@v4 @@ -153,7 +153,7 @@ jobs: release-check: needs: [docs-scope, build-artifacts] if: github.event_name == 'push' && needs.docs-scope.outputs.docs_only != 'true' - runs-on: blacksmith-4vcpu-ubuntu-2404 + runs-on: blacksmith-16vcpu-ubuntu-2404 steps: - name: Checkout uses: actions/checkout@v4 @@ -177,7 +177,7 @@ jobs: checks: needs: [docs-scope, changed-scope, check] if: needs.docs-scope.outputs.docs_only != 'true' && (github.event_name == 'push' || needs.changed-scope.outputs.run_node == 'true') - runs-on: blacksmith-4vcpu-ubuntu-2404 + runs-on: blacksmith-16vcpu-ubuntu-2404 strategy: fail-fast: false matrix: @@ -192,20 +192,28 @@ jobs: task: test command: pnpm canvas:a2ui:bundle && bunx vitest run --config vitest.unit.config.ts steps: + - name: Skip bun lane on push + if: github.event_name == 'push' && matrix.runtime == 'bun' + run: echo "Skipping bun test lane on push events." + - name: Checkout + if: github.event_name != 'push' || matrix.runtime != 'bun' uses: actions/checkout@v4 with: submodules: false - name: Setup Node environment + if: matrix.runtime != 'bun' || github.event_name != 'push' uses: ./.github/actions/setup-node-env + with: + install-bun: "${{ matrix.runtime == 'bun' }}" - name: Configure vitest JSON reports - if: matrix.task == 'test' && matrix.runtime == 'node' + if: (github.event_name != 'push' || matrix.runtime != 'bun') && matrix.task == 'test' && matrix.runtime == 'node' run: echo "OPENCLAW_VITEST_REPORT_DIR=$RUNNER_TEMP/vitest-reports" >> "$GITHUB_ENV" - name: Configure Node test resources - if: matrix.task == 'test' && matrix.runtime == 'node' + if: (github.event_name != 'push' || matrix.runtime != 'bun') && matrix.task == 'test' && matrix.runtime == 'node' run: | # `pnpm test` runs `scripts/test-parallel.mjs`, which spawns multiple Node processes. # Default heap limits have been too low on Linux CI (V8 OOM near 4GB). @@ -213,16 +221,17 @@ jobs: echo "OPENCLAW_TEST_MAX_OLD_SPACE_SIZE_MB=6144" >> "$GITHUB_ENV" - name: Run ${{ matrix.task }} (${{ matrix.runtime }}) + if: matrix.runtime != 'bun' || github.event_name != 'push' run: ${{ matrix.command }} - name: Summarize slowest tests - if: matrix.task == 'test' && matrix.runtime == 'node' + if: (github.event_name != 'push' || matrix.runtime != 'bun') && matrix.task == 'test' && matrix.runtime == 'node' run: | node scripts/vitest-slowest.mjs --dir "$OPENCLAW_VITEST_REPORT_DIR" --top 50 --out "$RUNNER_TEMP/vitest-slowest.md" > /dev/null echo "Slowest test summary written to $RUNNER_TEMP/vitest-slowest.md" - name: Upload vitest reports - if: matrix.task == 'test' && matrix.runtime == 'node' + if: (github.event_name != 'push' || matrix.runtime != 'bun') && matrix.task == 'test' && matrix.runtime == 'node' uses: actions/upload-artifact@v4 with: name: vitest-reports-${{ runner.os }}-${{ matrix.runtime }} @@ -235,7 +244,7 @@ jobs: name: "check" needs: [docs-scope] if: needs.docs-scope.outputs.docs_only != 'true' - runs-on: blacksmith-4vcpu-ubuntu-2404 + runs-on: blacksmith-16vcpu-ubuntu-2404 steps: - name: Checkout uses: actions/checkout@v4 @@ -244,15 +253,56 @@ jobs: - name: Setup Node environment uses: ./.github/actions/setup-node-env + with: + install-bun: "false" - name: Check types and lint and oxfmt run: pnpm check + # Report-only dead-code scans. Runs after scope detection and stores machine-readable + # results as artifacts for later triage before we enable hard gates. + # Temporarily disabled in CI while we process initial findings. + deadcode: + name: dead-code report + needs: [docs-scope, changed-scope] + # if: needs.docs-scope.outputs.docs_only != 'true' && (github.event_name == 'push' || needs.changed-scope.outputs.run_node == 'true') + if: false + runs-on: blacksmith-16vcpu-ubuntu-2404 + strategy: + fail-fast: false + matrix: + include: + - tool: knip + command: pnpm deadcode:report:ci:knip + - tool: ts-prune + command: pnpm deadcode:report:ci:ts-prune + - tool: ts-unused-exports + command: pnpm deadcode:report:ci:ts-unused + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + submodules: false + + - name: Setup Node environment + uses: ./.github/actions/setup-node-env + with: + install-bun: "false" + + - name: Run ${{ matrix.tool }} dead-code scan + run: ${{ matrix.command }} + + - name: Upload dead-code results + uses: actions/upload-artifact@v4 + with: + name: dead-code-${{ matrix.tool }}-${{ github.run_id }} + path: .artifacts/deadcode + # Validate docs (format, lint, broken links) only when docs files changed. check-docs: needs: [docs-scope] if: needs.docs-scope.outputs.docs_changed == 'true' - runs-on: blacksmith-4vcpu-ubuntu-2404 + runs-on: blacksmith-16vcpu-ubuntu-2404 steps: - name: Checkout uses: actions/checkout@v4 @@ -261,12 +311,16 @@ jobs: - name: Setup Node environment uses: ./.github/actions/setup-node-env + with: + install-bun: "false" - name: Check docs run: pnpm check:docs - secrets: - runs-on: blacksmith-4vcpu-ubuntu-2404 + skills-python: + needs: [docs-scope, changed-scope] + if: needs.docs-scope.outputs.docs_only != 'true' && (github.event_name == 'push' || needs.changed-scope.outputs.run_node == 'true') + runs-on: blacksmith-16vcpu-ubuntu-2404 steps: - name: Checkout uses: actions/checkout@v4 @@ -278,10 +332,39 @@ jobs: with: python-version: "3.12" - - name: Install detect-secrets + - name: Install Python tooling run: | python -m pip install --upgrade pip - python -m pip install detect-secrets==1.5.0 + python -m pip install pytest ruff pyyaml + + - name: Lint Python skill scripts + run: python -m ruff check skills + + - name: Test skill Python scripts + run: python -m pytest -q skills + + secrets: + runs-on: blacksmith-16vcpu-ubuntu-2404 + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + submodules: false + + - name: Setup Node environment + uses: ./.github/actions/setup-node-env + with: + install-bun: "false" + + - name: Setup Python + uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Install pre-commit + run: | + python -m pip install --upgrade pip + python -m pip install pre-commit detect-secrets==1.5.0 - name: Detect secrets run: | @@ -290,13 +373,37 @@ jobs: exit 1 fi + - name: Detect committed private keys + run: pre-commit run --all-files detect-private-key + + - name: Audit changed GitHub workflows with zizmor + run: | + set -euo pipefail + + if [ "${{ github.event_name }}" = "push" ]; then + BASE="${{ github.event.before }}" + else + BASE="${{ github.event.pull_request.base.sha }}" + fi + + mapfile -t workflow_files < <(git diff --name-only "$BASE" HEAD -- '.github/workflows/*.yml' '.github/workflows/*.yaml') + if [ "${#workflow_files[@]}" -eq 0 ]; then + echo "No workflow changes detected; skipping zizmor." + exit 0 + fi + + pre-commit run zizmor --files "${workflow_files[@]}" + + - name: Audit production dependencies + run: pre-commit run --all-files pnpm-audit-prod + checks-windows: needs: [docs-scope, changed-scope, build-artifacts, check] if: needs.docs-scope.outputs.docs_only != 'true' && (github.event_name == 'push' || needs.changed-scope.outputs.run_node == 'true') - runs-on: blacksmith-4vcpu-windows-2025 + runs-on: blacksmith-16vcpu-windows-2025 env: NODE_OPTIONS: --max-old-space-size=4096 - # Keep total concurrency predictable on the 4 vCPU runner: + # Keep total concurrency predictable on the 16 vCPU runner: # `scripts/test-parallel.mjs` runs some vitest suites in parallel processes. OPENCLAW_TEST_WORKERS: 2 defaults: @@ -355,7 +462,7 @@ jobs: test -s dist/plugin-sdk/index.js - name: Setup Node.js - uses: actions/setup-node@v4 + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 with: node-version: 22.x check-latest: true @@ -366,16 +473,10 @@ jobs: pnpm-version: "10.23.0" cache-key-suffix: "node22" - - name: Setup Bun - uses: oven-sh/setup-bun@v2 - with: - bun-version: latest - - name: Runtime versions run: | node -v npm -v - bun -v pnpm -v - name: Capture node path @@ -653,7 +754,7 @@ jobs: android: needs: [docs-scope, changed-scope, check] if: needs.docs-scope.outputs.docs_only != 'true' && (github.event_name == 'push' || needs.changed-scope.outputs.run_android == 'true') - runs-on: blacksmith-4vcpu-ubuntu-2404 + runs-on: blacksmith-16vcpu-ubuntu-2404 strategy: fail-fast: false matrix: diff --git a/.github/workflows/docker-release.yml b/.github/workflows/docker-release.yml index 05e63005dd5..fc0d97d4091 100644 --- a/.github/workflows/docker-release.yml +++ b/.github/workflows/docker-release.yml @@ -24,13 +24,12 @@ env: jobs: # Build amd64 image build-amd64: - runs-on: ubuntu-latest + runs-on: blacksmith-16vcpu-ubuntu-2404 permissions: packages: write contents: read outputs: image-digest: ${{ steps.build.outputs.digest }} - image-metadata: ${{ steps.meta.outputs.json }} steps: - name: Checkout uses: actions/checkout@v4 @@ -45,18 +44,30 @@ jobs: username: ${{ github.repository_owner }} password: ${{ secrets.GITHUB_TOKEN }} - - name: Extract metadata - id: meta - uses: docker/metadata-action@v5 - with: - images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} - tags: | - type=ref,event=branch - type=semver,pattern={{version}} - type=semver,pattern={{version}},suffix=-amd64 - type=semver,pattern={{version}},suffix=-arm64 - type=ref,event=branch,suffix=-amd64 - type=ref,event=branch,suffix=-arm64 + - name: Resolve image tags (amd64) + id: tags + shell: bash + env: + IMAGE: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + run: | + set -euo pipefail + tags=() + if [[ "${GITHUB_REF}" == "refs/heads/main" ]]; then + tags+=("${IMAGE}:main-amd64") + fi + if [[ "${GITHUB_REF}" == refs/tags/v* ]]; then + version="${GITHUB_REF#refs/tags/v}" + tags+=("${IMAGE}:${version}-amd64") + fi + if [[ ${#tags[@]} -eq 0 ]]; then + echo "::error::No amd64 tags resolved for ref ${GITHUB_REF}" + exit 1 + fi + { + echo "value<> "$GITHUB_OUTPUT" - name: Build and push amd64 image id: build @@ -64,8 +75,7 @@ jobs: with: context: . platforms: linux/amd64 - labels: ${{ steps.meta.outputs.labels }} - tags: ${{ steps.meta.outputs.tags }} + tags: ${{ steps.tags.outputs.value }} cache-from: type=registry,ref=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}-cache:amd64 cache-to: type=registry,ref=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}-cache:amd64,mode=max provenance: false @@ -73,13 +83,12 @@ jobs: # Build arm64 image build-arm64: - runs-on: ubuntu-24.04-arm + runs-on: blacksmith-16vcpu-ubuntu-2404-arm permissions: packages: write contents: read outputs: image-digest: ${{ steps.build.outputs.digest }} - image-metadata: ${{ steps.meta.outputs.json }} steps: - name: Checkout uses: actions/checkout@v4 @@ -94,18 +103,30 @@ jobs: username: ${{ github.repository_owner }} password: ${{ secrets.GITHUB_TOKEN }} - - name: Extract metadata - id: meta - uses: docker/metadata-action@v5 - with: - images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} - tags: | - type=ref,event=branch - type=semver,pattern={{version}} - type=semver,pattern={{version}},suffix=-amd64 - type=semver,pattern={{version}},suffix=-arm64 - type=ref,event=branch,suffix=-amd64 - type=ref,event=branch,suffix=-arm64 + - name: Resolve image tags (arm64) + id: tags + shell: bash + env: + IMAGE: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + run: | + set -euo pipefail + tags=() + if [[ "${GITHUB_REF}" == "refs/heads/main" ]]; then + tags+=("${IMAGE}:main-arm64") + fi + if [[ "${GITHUB_REF}" == refs/tags/v* ]]; then + version="${GITHUB_REF#refs/tags/v}" + tags+=("${IMAGE}:${version}-arm64") + fi + if [[ ${#tags[@]} -eq 0 ]]; then + echo "::error::No arm64 tags resolved for ref ${GITHUB_REF}" + exit 1 + fi + { + echo "value<> "$GITHUB_OUTPUT" - name: Build and push arm64 image id: build @@ -113,8 +134,7 @@ jobs: with: context: . platforms: linux/arm64 - labels: ${{ steps.meta.outputs.labels }} - tags: ${{ steps.meta.outputs.tags }} + tags: ${{ steps.tags.outputs.value }} cache-from: type=registry,ref=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}-cache:arm64 cache-to: type=registry,ref=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}-cache:arm64,mode=max provenance: false @@ -122,12 +142,15 @@ jobs: # Create multi-platform manifest create-manifest: - runs-on: ubuntu-latest + runs-on: blacksmith-16vcpu-ubuntu-2404 permissions: packages: write contents: read needs: [build-amd64, build-arm64] steps: + - name: Checkout + uses: actions/checkout@v4 + - name: Login to GitHub Container Registry uses: docker/login-action@v3 with: @@ -135,19 +158,41 @@ jobs: username: ${{ github.repository_owner }} password: ${{ secrets.GITHUB_TOKEN }} - - name: Extract metadata for manifest - id: meta - uses: docker/metadata-action@v5 - with: - images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} - tags: | - type=ref,event=branch - type=semver,pattern={{version}} + - name: Resolve manifest tags + id: tags + shell: bash + env: + IMAGE: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + run: | + set -euo pipefail + tags=() + if [[ "${GITHUB_REF}" == "refs/heads/main" ]]; then + tags+=("${IMAGE}:main") + fi + if [[ "${GITHUB_REF}" == refs/tags/v* ]]; then + version="${GITHUB_REF#refs/tags/v}" + tags+=("${IMAGE}:${version}") + fi + if [[ ${#tags[@]} -eq 0 ]]; then + echo "::error::No manifest tags resolved for ref ${GITHUB_REF}" + exit 1 + fi + { + echo "value<> "$GITHUB_OUTPUT" - name: Create and push manifest + shell: bash run: | - docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \ + set -euo pipefail + mapfile -t tags <<< "${{ steps.tags.outputs.value }}" + args=() + for tag in "${tags[@]}"; do + [ -z "$tag" ] && continue + args+=("-t" "$tag") + done + docker buildx imagetools create "${args[@]}" \ ${{ needs.build-amd64.outputs.image-digest }} \ ${{ needs.build-arm64.outputs.image-digest }} - env: - DOCKER_METADATA_OUTPUT_JSON: ${{ steps.meta.outputs.json }} diff --git a/.github/workflows/formal-conformance.yml b/.github/workflows/formal-conformance.yml deleted file mode 100644 index 8ba6d7e56b8..00000000000 --- a/.github/workflows/formal-conformance.yml +++ /dev/null @@ -1,139 +0,0 @@ -name: Formal models (informational conformance) - -on: - pull_request: - -concurrency: - group: formal-conformance-${{ github.event.pull_request.number || github.ref_name }} - cancel-in-progress: true - -jobs: - formal_conformance: - runs-on: ubuntu-latest - timeout-minutes: 20 - permissions: - contents: read - pull-requests: write - - steps: - - name: Checkout openclaw (PR) - uses: actions/checkout@v4 - with: - path: openclaw - - - name: Checkout formal models - uses: actions/checkout@v4 - with: - repository: vignesh07/clawdbot-formal-models - ref: main - path: clawdbot-formal-models - - - name: Setup Node - uses: actions/setup-node@v4 - with: - node-version: "22" - - - name: Regenerate extracted constants from openclaw - run: | - set -euo pipefail - cd clawdbot-formal-models - export OPENCLAW_REPO_DIR="${GITHUB_WORKSPACE}/openclaw" - node scripts/extract-tool-groups.mjs - node scripts/check-tool-group-alias.mjs - - # Drift is about extracted artifacts only; compute it before model checking - # to avoid any incidental file touches affecting the result. - - name: Compute drift (generated/*) - id: drift - run: | - set -euo pipefail - cd clawdbot-formal-models - - if git diff --quiet -- generated; then - echo "drift=false" >> "$GITHUB_OUTPUT" - exit 0 - fi - - echo "drift=true" >> "$GITHUB_OUTPUT" - git diff -- generated > "${GITHUB_WORKSPACE}/formal-models-drift.diff" - - - name: Model check (green suite) - run: | - set -euo pipefail - cd clawdbot-formal-models - make \ - precedence groups elevated nodes-policy \ - attacker approvals approvals-token nodes-pipeline \ - gateway-exposure gateway-exposure-v2 gateway-exposure-v2-protected \ - gateway-auth-conformance gateway-auth-tailscale gateway-auth-proxy \ - pairing pairing-cap pairing-idempotency pairing-refresh pairing-refresh-race \ - ingress-gating ingress-idempotency ingress-dedupe-fallback ingress-trace ingress-trace2 \ - routing-isolation routing-precedence routing-identitylinks routing-identity-transitive routing-identity-symmetry routing-identity-channel-override \ - routing-thread-parent discord-pluralkit \ - ingress-retry session-key-stability session-explosion-bound config-normalization \ - queue-drain delivery-route-stability delivery-pipeline retry-termination retry-eventual-success \ - no-cross-stream multi-event-eventual-emission \ - dedupe-collision-fallback crash-restart-dedupe two-worker-dedupe openclaw-session-key-conformance \ - routing-thread-parent-channel-override routing-trirule gateway-auth-proxy-header-spoof \ - group-alias-check - - - name: Model check (negative suite, expected violations) - continue-on-error: true - run: | - set -euo pipefail - cd clawdbot-formal-models - make -k \ - precedence-negative groups-negative elevated-negative nodes-policy-negative \ - attacker-negative attacker-nodes-negative attacker-nodes-allowlist-negative attacker-nodes-allowlist-negative \ - approvals-negative approvals-token-negative nodes-pipeline-negative \ - gateway-exposure-negative gateway-exposure-v2-negative gateway-exposure-v2-protected-negative \ - gateway-exposure-v2-unsafe-custom gateway-exposure-v2-unsafe-tailnet gateway-exposure-v2-unsafe-auto \ - gateway-auth-conformance-negative gateway-auth-tailscale-negative gateway-auth-proxy-negative \ - pairing-negative pairing-cap-negative pairing-idempotency-negative pairing-refresh-negative pairing-refresh-race-negative \ - ingress-gating-negative ingress-idempotency-negative ingress-dedupe-fallback-negative ingress-trace-negative ingress-trace2-negative \ - routing-isolation-negative routing-precedence-negative routing-identitylinks-negative routing-identity-transitive-negative routing-identity-symmetry-negative routing-identity-channel-override-negative \ - routing-thread-parent-negative discord-pluralkit-negative \ - ingress-retry-negative session-key-stability-negative config-normalization-negative \ - queue-drain delivery-route-stability-negative delivery-pipeline-negative retry-termination-negative retry-eventual-success-negative \ - no-cross-stream-negative multi-event-eventual-emission-negative \ - dedupe-collision-fallback-negative crash-restart-dedupe-negative two-worker-dedupe-negative openclaw-session-key-conformance-negative \ - routing-thread-parent-channel-override-negative routing-trirule-negative gateway-auth-proxy-header-spoof-negative - - - name: Upload drift diff artifact - if: steps.drift.outputs.drift == 'true' - uses: actions/upload-artifact@v4 - with: - name: formal-models-conformance-drift - path: formal-models-drift.diff - - - name: Comment on PR (informational) - if: steps.drift.outputs.drift == 'true' - continue-on-error: true - uses: actions/github-script@v7 - with: - script: | - const body = [ - '⚠️ **Formal models conformance drift detected**', - '', - 'The formal models extracted constants (`generated/*`) do not match this openclaw PR.', - '', - 'This check is **informational** (not blocking merges yet).', - 'See the `formal-models-conformance-drift` artifact for the diff.', - '', - 'If this change is intentional, follow up by updating the formal models repo or regenerating the extracted artifacts there.', - ].join('\n'); - - await github.rest.issues.createComment({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: context.payload.pull_request.number, - body, - }); - - - name: Summary - run: | - if [ "${{ steps.drift.outputs.drift }}" = "true" ]; then - echo "Formal conformance drift detected (informational)." - else - echo "Formal conformance: no drift." - fi diff --git a/.github/workflows/install-smoke.yml b/.github/workflows/install-smoke.yml index 45154a5fab4..03e87db82b9 100644 --- a/.github/workflows/install-smoke.yml +++ b/.github/workflows/install-smoke.yml @@ -12,7 +12,7 @@ concurrency: jobs: docs-scope: - runs-on: ubuntu-latest + runs-on: blacksmith-16vcpu-ubuntu-2404 outputs: docs_only: ${{ steps.check.outputs.docs_only }} steps: @@ -28,13 +28,13 @@ jobs: install-smoke: needs: [docs-scope] if: needs.docs-scope.outputs.docs_only != 'true' - runs-on: ubuntu-latest + runs-on: blacksmith-16vcpu-ubuntu-2404 steps: - name: Checkout CLI uses: actions/checkout@v4 - name: Setup Node.js - uses: actions/setup-node@v4 + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 with: node-version: 22.x check-latest: true diff --git a/.github/workflows/labeler.yml b/.github/workflows/labeler.yml index 2bae5a61160..9ac44dfa6b6 100644 --- a/.github/workflows/labeler.yml +++ b/.github/workflows/labeler.yml @@ -23,7 +23,7 @@ jobs: permissions: contents: read pull-requests: write - runs-on: ubuntu-latest + runs-on: blacksmith-16vcpu-ubuntu-2404 steps: - uses: actions/create-github-app-token@d72941d797fd3113feb6b93fd0dec494b13a2547 # v1 id: app-token @@ -200,7 +200,7 @@ jobs: permissions: contents: read pull-requests: write - runs-on: ubuntu-latest + runs-on: blacksmith-16vcpu-ubuntu-2404 steps: - uses: actions/create-github-app-token@d72941d797fd3113feb6b93fd0dec494b13a2547 # v1 id: app-token @@ -440,7 +440,7 @@ jobs: label-issues: permissions: issues: write - runs-on: ubuntu-latest + runs-on: blacksmith-16vcpu-ubuntu-2404 steps: - uses: actions/create-github-app-token@d72941d797fd3113feb6b93fd0dec494b13a2547 # v1 id: app-token diff --git a/.github/workflows/sandbox-common-smoke.yml b/.github/workflows/sandbox-common-smoke.yml index c92a05c3aeb..26c0dcc106f 100644 --- a/.github/workflows/sandbox-common-smoke.yml +++ b/.github/workflows/sandbox-common-smoke.yml @@ -19,7 +19,7 @@ concurrency: jobs: sandbox-common-smoke: - runs-on: ubuntu-latest + runs-on: blacksmith-16vcpu-ubuntu-2404 steps: - name: Checkout uses: actions/checkout@v4 diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml index ccafcf01a18..6248a93dce7 100644 --- a/.github/workflows/stale.yml +++ b/.github/workflows/stale.yml @@ -12,7 +12,7 @@ jobs: permissions: issues: write pull-requests: write - runs-on: ubuntu-latest + runs-on: blacksmith-16vcpu-ubuntu-2404 steps: - uses: actions/create-github-app-token@d72941d797fd3113feb6b93fd0dec494b13a2547 # v1 id: app-token @@ -31,7 +31,7 @@ jobs: stale-pr-label: stale exempt-issue-labels: enhancement,maintainer,pinned,security,no-stale exempt-pr-labels: maintainer,no-stale - operations-per-run: 500 + operations-per-run: 10000 exempt-all-assignees: true remove-stale-when-updated: true stale-issue-message: | diff --git a/.github/workflows/workflow-sanity.yml b/.github/workflows/workflow-sanity.yml index 438a71162da..19668e697ad 100644 --- a/.github/workflows/workflow-sanity.yml +++ b/.github/workflows/workflow-sanity.yml @@ -11,7 +11,7 @@ concurrency: jobs: no-tabs: - runs-on: ubuntu-latest + runs-on: blacksmith-16vcpu-ubuntu-2404 steps: - name: Checkout uses: actions/checkout@v4 @@ -40,3 +40,28 @@ jobs: print(f"- {path}") sys.exit(1) PY + + actionlint: + runs-on: blacksmith-16vcpu-ubuntu-2404 + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Install actionlint + shell: bash + run: | + set -euo pipefail + ACTIONLINT_VERSION="1.7.11" + archive="actionlint_${ACTIONLINT_VERSION}_linux_amd64.tar.gz" + base_url="https://github.com/rhysd/actionlint/releases/download/v${ACTIONLINT_VERSION}" + curl -sSfL -o "${archive}" "${base_url}/${archive}" + curl -sSfL -o checksums.txt "${base_url}/actionlint_${ACTIONLINT_VERSION}_checksums.txt" + grep " ${archive}\$" checksums.txt | sha256sum -c - + tar -xzf "${archive}" actionlint + sudo install -m 0755 actionlint /usr/local/bin/actionlint + + - name: Lint workflows + run: actionlint + + - name: Disallow direct inputs interpolation in composite run blocks + run: python3 scripts/check-composite-action-input-interpolation.py diff --git a/.gitignore b/.gitignore index ea7f13ee132..fca34f7d4ff 100644 --- a/.gitignore +++ b/.gitignore @@ -17,6 +17,11 @@ __pycache__/ ui/src/ui/__screenshots__/ ui/playwright-report/ ui/test-results/ +packages/dashboard-next/.next/ +packages/dashboard-next/out/ + +# Mise configuration files +mise.toml # Android build artifacts apps/android/.gradle/ @@ -36,10 +41,13 @@ bin/docs-list apps/macos/.build-local/ apps/macos/.swiftpm/ apps/shared/MoltbotKit/.swiftpm/ +apps/shared/OpenClawKit/.swiftpm/ Core/ apps/ios/*.xcodeproj/ apps/ios/*.xcworkspace/ apps/ios/.swiftpm/ +apps/ios/.derivedData/ +apps/ios/.local-signing.xcconfig vendor/ apps/ios/Clawdbot.xcodeproj/ apps/ios/Clawdbot.xcodeproj/** @@ -86,3 +94,23 @@ USER.md !.agent/workflows/ /local/ package-lock.json +.claude/settings.local.json +.agents/ +.agents +.agent/ +skills-lock.json + +# Local iOS signing overrides +apps/ios/LocalSigning.xcconfig +# Generated protocol schema (produced via pnpm protocol:gen) +dist/protocol.schema.json +.ant-colony/ + +# Eclipse +**/.project +**/.classpath +**/.settings/ +**/.gradle/ + +# Synthing +**/.stfolder/ diff --git a/.mailmap b/.mailmap new file mode 100644 index 00000000000..9190f88b6e0 --- /dev/null +++ b/.mailmap @@ -0,0 +1,13 @@ +# Canonical contributor identity mappings for cherry-picked commits. +bmendonca3 <208517100+bmendonca3@users.noreply.github.com> +hcl <7755017+hclsys@users.noreply.github.com> +Glucksberg <80581902+Glucksberg@users.noreply.github.com> +JackyWay <53031570+JackyWay@users.noreply.github.com> +Marcus Castro <7562095+mcaxtr@users.noreply.github.com> +Marc Gratch <2238658+mgratch@users.noreply.github.com> +Peter Machona <7957943+chilu18@users.noreply.github.com> +Ben Marvell <92585+easternbloc@users.noreply.github.com> +zerone0x <39543393+zerone0x@users.noreply.github.com> +Marco Di Dionisio <3519682+marcodd23@users.noreply.github.com> +mujiannan <46643837+mujiannan@users.noreply.github.com> +Santhanakrishnan <239082898+bitfoundry-ai@users.noreply.github.com> diff --git a/.npmrc b/.npmrc index f0c783cb6c8..05620061611 100644 --- a/.npmrc +++ b/.npmrc @@ -1 +1 @@ -allow-build-scripts=@whiskeysockets/baileys,sharp,esbuild,protobufjs,fs-ext,node-pty,@lydell/node-pty,@matrix-org/matrix-sdk-crypto-nodejs +# pnpm build-script allowlist lives in package.json -> pnpm.onlyBuiltDependencies. diff --git a/.oxfmtrc.jsonc b/.oxfmtrc.jsonc index f7208b4da3d..0a928d5f9ba 100644 --- a/.oxfmtrc.jsonc +++ b/.oxfmtrc.jsonc @@ -6,14 +6,20 @@ "experimentalSortPackageJson": { "sortScripts": true, }, + "tabWidth": 2, + "useTabs": false, "ignorePatterns": [ "apps/", "assets/", + "CLAUDE.md", + "docker-compose.yml", "dist/", "docs/_layouts/", "node_modules/", "patches/", "pnpm-lock.yaml/", + "src/gateway/server-methods/CLAUDE.md", + "src/auto-reply/reply/export-html/", "Swabble/", "vendor/", ], diff --git a/.oxlintrc.json b/.oxlintrc.json index 4097a58f2d5..687b5bb5eb5 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -11,6 +11,8 @@ "eslint-plugin-unicorn/prefer-array-find": "off", "eslint/no-await-in-loop": "off", "eslint/no-new": "off", + "eslint/no-shadow": "off", + "eslint/no-unmodified-loop-condition": "off", "oxc/no-accumulating-spread": "off", "oxc/no-async-endpoint-handlers": "off", "oxc/no-map-spread": "off", @@ -27,8 +29,9 @@ "extensions/", "node_modules/", "patches/", - "pnpm-lock.yaml/", + "pnpm-lock.yaml", "skills/", + "src/auto-reply/reply/export-html/template.js", "src/canvas-host/a2ui/a2ui.bundle.js", "Swabble/", "vendor/" diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index e946d18c112..30b6363a34d 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -18,6 +18,8 @@ repos: - id: check-added-large-files args: [--maxkb=500] - id: check-merge-conflict + - id: detect-private-key + exclude: '(^|/)(\.secrets\.baseline$|\.detect-secrets\.cfg$|\.pre-commit-config\.yaml$|apps/ios/fastlane/Fastfile$|.*\.test\.ts$)' # Secret detection (same as CI) - repo: https://github.com/Yelp/detect-secrets @@ -45,7 +47,6 @@ repos: - '=== "string"' - --exclude-lines - 'typeof remote\?\.password === "string"' - # Shell script linting - repo: https://github.com/koalaman/shellcheck-precommit rev: v0.11.0 @@ -69,9 +70,34 @@ repos: args: [--persona=regular, --min-severity=medium, --min-confidence=medium] exclude: "^(vendor/|Swabble/)" + # Python checks for skills scripts + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.14.1 + hooks: + - id: ruff + files: "^skills/.*\\.py$" + args: [--config, pyproject.toml] + + - repo: local + hooks: + - id: skills-python-tests + name: skills python tests + entry: pytest -q skills + language: python + additional_dependencies: [pytest>=8, <9] + pass_filenames: false + files: "^skills/.*\\.py$" + # Project checks (same commands as CI) - repo: local hooks: + # pnpm audit --prod --audit-level=high + - id: pnpm-audit-prod + name: pnpm-audit-prod + entry: pnpm audit --prod --audit-level=high + language: system + pass_filenames: false + # oxlint --type-aware src test - id: oxlint name: oxlint diff --git a/.secrets.baseline b/.secrets.baseline index 826d5b4def1..089515fe250 100644 --- a/.secrets.baseline +++ b/.secrets.baseline @@ -146,88 +146,9445 @@ } ], "results": { - ".env.example": [ + ".detect-secrets.cfg": [ { - "type": "Twilio API Key", - "filename": ".env.example", - "hashed_secret": "3c7206eff845bc69cf12d904d0f95f9aec15535e", + "type": "Private Key", + "filename": ".detect-secrets.cfg", + "hashed_secret": "1348b145fa1a555461c1b790a2f66614781091e9", "is_verified": false, - "line_number": 2 + "line_number": 17 + }, + { + "type": "Secret Keyword", + "filename": ".detect-secrets.cfg", + "hashed_secret": "fe88fceb47e040ba1bfafa4ac639366188df2f6d", + "is_verified": false, + "line_number": 19 } ], "appcast.xml": [ { "type": "Base64 High Entropy String", "filename": "appcast.xml", - "hashed_secret": "4e5f0a148d9ef42afeb73b1c77643e2ef2dee0b9", + "hashed_secret": "2bc43713edb8f775582c6314953b7c020d691aba", "is_verified": false, - "line_number": 90 + "line_number": 141 }, { "type": "Base64 High Entropy String", "filename": "appcast.xml", - "hashed_secret": "f1ccdaf78c308ec2cf608818da13f5f1e4809ed1", + "hashed_secret": "2fcd83b35235522978c19dbbab2884a09aa64f35", "is_verified": false, - "line_number": 138 + "line_number": 209 }, { "type": "Base64 High Entropy String", "filename": "appcast.xml", - "hashed_secret": "2691dc9c9ded92ba62a2d8ee589e2d78e2aa0479", + "hashed_secret": "78b65f0952ed8a557e0f67b2364ff67cb6863bc8", "is_verified": false, - "line_number": 212 + "line_number": 310 } ], - "apps/macos/Tests/ClawdbotIPCTests/AnthropicAuthResolverTests.swift": [ + "apps/android/app/src/test/java/ai/openclaw/android/node/AppUpdateHandlerTest.kt": [ + { + "type": "Hex High Entropy String", + "filename": "apps/android/app/src/test/java/ai/openclaw/android/node/AppUpdateHandlerTest.kt", + "hashed_secret": "ee662f2bc691daa48d074542722d8e1b0587673c", + "is_verified": false, + "line_number": 58 + } + ], + "apps/ios/Sources/Gateway/GatewaySettingsStore.swift": [ { "type": "Secret Keyword", - "filename": "apps/macos/Tests/ClawdbotIPCTests/AnthropicAuthResolverTests.swift", + "filename": "apps/ios/Sources/Gateway/GatewaySettingsStore.swift", + "hashed_secret": "5f7c0c35e552780b67fe1c0ee186764354793be3", + "is_verified": false, + "line_number": 28 + } + ], + "apps/ios/Tests/DeepLinkParserTests.swift": [ + { + "type": "Secret Keyword", + "filename": "apps/ios/Tests/DeepLinkParserTests.swift", + "hashed_secret": "1a91d62f7ca67399625a4368a6ab5d4a3baa6073", + "is_verified": false, + "line_number": 89 + } + ], + "apps/macos/Sources/OpenClawProtocol/GatewayModels.swift": [ + { + "type": "Secret Keyword", + "filename": "apps/macos/Sources/OpenClawProtocol/GatewayModels.swift", + "hashed_secret": "7990585255d25249fb1e6eac3d2bd6c37429b2cd", + "is_verified": false, + "line_number": 1492 + } + ], + "apps/macos/Tests/OpenClawIPCTests/AnthropicAuthResolverTests.swift": [ + { + "type": "Secret Keyword", + "filename": "apps/macos/Tests/OpenClawIPCTests/AnthropicAuthResolverTests.swift", "hashed_secret": "e761624445731fcb8b15da94343c6b92e507d190", "is_verified": false, "line_number": 26 }, { "type": "Secret Keyword", - "filename": "apps/macos/Tests/ClawdbotIPCTests/AnthropicAuthResolverTests.swift", + "filename": "apps/macos/Tests/OpenClawIPCTests/AnthropicAuthResolverTests.swift", "hashed_secret": "a23c8630c8a5fbaa21f095e0269c135c20d21689", "is_verified": false, "line_number": 42 } ], - "apps/macos/Tests/ClawdbotIPCTests/GatewayEndpointStoreTests.swift": [ + "apps/macos/Tests/OpenClawIPCTests/GatewayEndpointStoreTests.swift": [ { "type": "Secret Keyword", - "filename": "apps/macos/Tests/ClawdbotIPCTests/GatewayEndpointStoreTests.swift", + "filename": "apps/macos/Tests/OpenClawIPCTests/GatewayEndpointStoreTests.swift", "hashed_secret": "19dad5cecb110281417d1db56b60e1b006d55bb4", "is_verified": false, "line_number": 61 } ], - "apps/macos/Tests/ClawdbotIPCTests/GatewayLaunchAgentManagerTests.swift": [ + "apps/macos/Tests/OpenClawIPCTests/GatewayLaunchAgentManagerTests.swift": [ { "type": "Secret Keyword", - "filename": "apps/macos/Tests/ClawdbotIPCTests/GatewayLaunchAgentManagerTests.swift", + "filename": "apps/macos/Tests/OpenClawIPCTests/GatewayLaunchAgentManagerTests.swift", "hashed_secret": "1a91d62f7ca67399625a4368a6ab5d4a3baa6073", "is_verified": false, "line_number": 13 } ], - "apps/macos/Tests/ClawdbotIPCTests/TailscaleIntegrationSectionTests.swift": [ + "apps/macos/Tests/OpenClawIPCTests/TailscaleIntegrationSectionTests.swift": [ { "type": "Secret Keyword", - "filename": "apps/macos/Tests/ClawdbotIPCTests/TailscaleIntegrationSectionTests.swift", + "filename": "apps/macos/Tests/OpenClawIPCTests/TailscaleIntegrationSectionTests.swift", "hashed_secret": "e5e9fa1ba31ecd1ae84f75caaa474f3a663f05f4", "is_verified": false, "line_number": 27 } ], - "apps/shared/ClawdbotKit/Sources/ClawdbotKit/GatewayChannel.swift": [ + "apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayChannel.swift": [ { "type": "Secret Keyword", - "filename": "apps/shared/ClawdbotKit/Sources/ClawdbotKit/GatewayChannel.swift", + "filename": "apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayChannel.swift", "hashed_secret": "5baa61e4c9b93f3f0682250b6cf8331b7ee68fd8", "is_verified": false, + "line_number": 106 + } + ], + "apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift": [ + { + "type": "Secret Keyword", + "filename": "apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift", + "hashed_secret": "7990585255d25249fb1e6eac3d2bd6c37429b2cd", + "is_verified": false, + "line_number": 1492 + } + ], + "docs/.i18n/zh-CN.tm.jsonl": [ + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "6ba7bb7047f44b28279fbb11350e1a7bf4e7de59", + "is_verified": false, + "line_number": 1 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "e83ec66165edcee8f2b408b5e6bafe4844071f8f", + "is_verified": false, + "line_number": 2 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "8793597fb80169cbcefe08a1b0151138b7ab78bd", + "is_verified": false, + "line_number": 3 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "af6b2a2ef841b637288e2eb2726e20ed9c3974c0", + "is_verified": false, + "line_number": 4 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "db1f9e54942e872f3a7b29aa174c70a3167d76f2", + "is_verified": false, + "line_number": 5 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "f66de1a7ae418bd55115d4fac319824deb0d88cb", + "is_verified": false, + "line_number": 6 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "98510d5b8050a30514bc7fa147af6f66e5e34804", + "is_verified": false, + "line_number": 7 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "b03e1a8bbe1b422cb64d7aea071d94088b6c1768", + "is_verified": false, + "line_number": 8 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "6f72b03efde2d701a7e882dcaed1e935484a8e67", + "is_verified": false, + "line_number": 9 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "57d35c7411cff6f679c4a437d3251c0532fbe3cb", + "is_verified": false, + "line_number": 10 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "fbffe72a354d73fad191eec6605543d3e8e5f549", + "is_verified": false, + "line_number": 11 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "ceb3b4e53c22f7e28ab7006c9e1931bd31d534e1", + "is_verified": false, + "line_number": 12 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "3eb65eb5d24ab5bd58a57bcd1a1894c1d05ad7f6", + "is_verified": false, + "line_number": 13 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "88e065467489c885d4d80d8f582707f3ca6284e6", + "is_verified": false, + "line_number": 14 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "fd9e2dd936c475429f6d461056c5d97d1635de2e", + "is_verified": false, + "line_number": 15 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "b7a629ae866eda49b01fe2eccbf842b52594442a", + "is_verified": false, + "line_number": 16 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "67c615ed823ff022c807fcb65d52bd454a52bc1f", + "is_verified": false, + "line_number": 17 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "121e6974c091fafcc6e493892b7e7ffe3c81e7eb", + "is_verified": false, + "line_number": 18 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "2be720cb8d166c422e71de2c43dbb5832c952df5", + "is_verified": false, + "line_number": 19 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "e44ba9d2b09e8923191b76eb9f58127ad9980cae", + "is_verified": false, + "line_number": 20 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "ff53d507245282f09d082321e8ef511a3e2af5ff", + "is_verified": false, + "line_number": 21 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "7ecbf8a10b1e8bc096b49c27d3b70812778205eb", + "is_verified": false, + "line_number": 22 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "5628e70d1f7717c328418619beb0ae164fb5075c", + "is_verified": false, + "line_number": 23 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "b0b8efbb45c2854a57241d51c2b556838eaebc00", + "is_verified": false, + "line_number": 24 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "686c14971a01fa1737cc2c00790933213b688e52", + "is_verified": false, + "line_number": 25 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "6311a112d1ef120acc3247c79a07721b9dc52f5b", + "is_verified": false, + "line_number": 26 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "0765cbc88514c95526bffd2e5b5144e050969aae", + "is_verified": false, + "line_number": 27 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "8d4d995d95dae479362773b1fe5ff943f735dd97", + "is_verified": false, + "line_number": 28 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "6da60e76ffee6f074c22f89fbfe1969b9b5bbbe2", + "is_verified": false, + "line_number": 29 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "40efc129489cfc37e7f114be79db3843adfd6549", + "is_verified": false, + "line_number": 30 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "976e548e417838885ab177817cf2b04f9c390571", + "is_verified": false, + "line_number": 31 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "26ad87428b833b4d5d569c10ec5bd7cc32019a0a", + "is_verified": false, + "line_number": 32 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "45f8de688074faa92a647dcf9f67b670de68a2b0", + "is_verified": false, + "line_number": 33 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "24d6fb4ef117d39c5f9c45a205faf1c85f356fa0", + "is_verified": false, + "line_number": 34 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "172a6875ed57d321409cb9c27d425b0b41eacb29", + "is_verified": false, + "line_number": 35 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "bf13e4219d558c0deff114eb6b6098dd12d30e90", + "is_verified": false, + "line_number": 36 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "1c91d3756008237ba0540b5831e88763e45a4fa9", + "is_verified": false, + "line_number": 37 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "63f55dcafa051c764eebfc72939788ec777fa3b5", + "is_verified": false, + "line_number": 38 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "2fec58745fb43cefe32e523ca60285baa33825c3", + "is_verified": false, + "line_number": 39 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "7dc4fc41a5c1ba307be067570a0e458f3b139696", + "is_verified": false, + "line_number": 40 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "26e2d413623e29e208ee2e71dd8aa02db3f0daa5", + "is_verified": false, + "line_number": 41 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "816184e85b856e06b4d70967ce713e72b22292e5", + "is_verified": false, + "line_number": 42 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "874b4362c636af8f5b4aebe013ae321ab0b83fd9", + "is_verified": false, + "line_number": 43 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "8e89a4e4945335d905762eb2dc5e8510abc9716d", + "is_verified": false, + "line_number": 44 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "7d4eb519b7fa3bce189b20609de596db82b56fae", + "is_verified": false, + "line_number": 45 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "22f878f965c38ebecdfd6ba0229e118cbfc80b00", + "is_verified": false, + "line_number": 46 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "2b2b5ced0fb09d74ab6fba9f058139ef47ad6bda", + "is_verified": false, + "line_number": 47 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "ff5c4ac7b55661c8bb699005b3ba9e0299b66ec9", + "is_verified": false, + "line_number": 48 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "541344e343f0f02cb1548729b073161d0b44c373", + "is_verified": false, + "line_number": 49 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "886979ee264082f1daebc1a2c95e9376281869fa", + "is_verified": false, + "line_number": 50 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "d1c7b012097938e3b75365359d49aa134768f64f", + "is_verified": false, + "line_number": 51 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "9c6a58787264a4fb0a823f9e20fd2c9abf82b96d", + "is_verified": false, + "line_number": 52 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "79e2c2821ed6a8b47486b4ddea90be8c7d4ad5b8", + "is_verified": false, + "line_number": 53 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "ae8e49c80ed43d16eef9f633c28879b3166318ab", + "is_verified": false, + "line_number": 54 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "f96db0197e1d67eab1197a03c107b07a71cd0ce7", + "is_verified": false, + "line_number": 55 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "cf799fdab5d19a32f25735f5b6a1265b6e30c33d", + "is_verified": false, + "line_number": 56 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "9d2165cc2b208ca555fb00ddaa1768455c89c4d0", + "is_verified": false, + "line_number": 57 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "9139a8402a3454c747b23df0d7c8e957312dd6d2", + "is_verified": false, + "line_number": 58 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "00bb66a6c79ba6cfebbf1018a83af7129a29a479", + "is_verified": false, + "line_number": 59 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "5b43b45627cffb5959d10386ec63025d28dbeec4", + "is_verified": false, + "line_number": 60 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "c99e2f9d7726da2ea48cb07e71a33a757cb12118", + "is_verified": false, + "line_number": 61 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "1880416d744d0693237d330f6ca744b59e7e12b4", + "is_verified": false, + "line_number": 62 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "2ed0dc836758d77d6a96c6b96d054697a59d64f0", + "is_verified": false, + "line_number": 63 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "8f34c522fe85146a367d92efe27488718791707e", + "is_verified": false, + "line_number": 64 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "5bc1ce83e698af25ed3427553c8a3fcf8aaefdc9", + "is_verified": false, + "line_number": 65 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "05e16bf4e66e22a4a83defe89f6e746becf049b8", + "is_verified": false, + "line_number": 66 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "97b2b3d469cde6e5e88ac0089433c772d2d86b0d", + "is_verified": false, + "line_number": 67 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "149e7eb26c3598e6fa620c61de9e7562d7995e01", + "is_verified": false, + "line_number": 68 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "5ec42634100091a94f71a2fd14820cb535df481e", + "is_verified": false, + "line_number": 69 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "8d6ef196daa5e81bda9ac982bcb40a6f07d4f50c", + "is_verified": false, + "line_number": 70 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "2d5c79b7d58642498f734dbe2c1245159a277a1e", + "is_verified": false, + "line_number": 71 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "7efd41240b058195c11e1ea621060bc8c82df8fc", + "is_verified": false, + "line_number": 72 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "47f6371bd5fe1746bcade2fea59cb8d93ff5c4e0", + "is_verified": false, + "line_number": 73 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "c67ce872a65c537d8748b302f45479714a04c420", + "is_verified": false, + "line_number": 74 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "fc32724374d238112dd530743e85af73f1c8eb8e", + "is_verified": false, + "line_number": 75 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "a01d187f1b0f38159c62f32405796de21548be31", + "is_verified": false, + "line_number": 76 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "a39ae2ab785dc2d4aab7856b0a7c6e4e5875b215", + "is_verified": false, + "line_number": 77 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "4ad4b170f1617e562f07cba453b69c8bc53cb5cd", + "is_verified": false, + "line_number": 78 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "b0e551f8b6fbe0147169202fbc141c1a0478dfb2", + "is_verified": false, + "line_number": 79 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "02593ce120c7398316c65894a5fa4be694ea3cee", + "is_verified": false, + "line_number": 80 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "789bc546ba1936b86999373fca6d6a6a4899a787", + "is_verified": false, + "line_number": 81 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "ee29461a81f3e898f4376d270ac84b8567f9b68c", + "is_verified": false, + "line_number": 82 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "235f549d4c65ec31307e0887204c428441d6229f", + "is_verified": false, + "line_number": 83 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "87b2376e9f5457bad56b7fb363c6a5f86d8f119a", + "is_verified": false, + "line_number": 84 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "c3b3424f5845769977ccb309a3c2b70117989e3c", + "is_verified": false, + "line_number": 85 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "88ddc980ca5f609c2806df08e2e1b9b206153817", + "is_verified": false, + "line_number": 86 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "af48a18326858bfcef8e5f3a850fba0f9d462549", + "is_verified": false, + "line_number": 87 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "c22217254346f8d551183caac2f73ec8284953b3", + "is_verified": false, + "line_number": 88 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "2de7388be37ebdde032f5e169940da7c9d38ac8b", + "is_verified": false, + "line_number": 89 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "98facee0b1bf74672bacb855a27972851929dd78", + "is_verified": false, + "line_number": 90 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "0a5cae7f96ade77892c5caa993b6d19cd41232fb", + "is_verified": false, + "line_number": 91 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "fe0da76f124e112f6702f2e9c62514238398ba8d", + "is_verified": false, + "line_number": 92 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "d5ce761d7b87445aa65b1734ad36c5d3d1d71c2a", + "is_verified": false, + "line_number": 93 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "f5b70c708f3034bd837835329603a499207c4fb5", + "is_verified": false, + "line_number": 94 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "50d6381367811dd8a0ad61bf1dd2c3619ece8a44", + "is_verified": false, + "line_number": 95 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "fe061e35aafc5841544633d917f55357813c0906", + "is_verified": false, + "line_number": 96 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "dc8722d30a33248ccc5dd9012fba71eefd3a44ac", + "is_verified": false, + "line_number": 97 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "2fb43da561bbb79d7cf89e5d6c5102c1436f6f49", + "is_verified": false, + "line_number": 98 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "cf61d12e9d98f6ba507bf40285d05f37fe158a01", + "is_verified": false, + "line_number": 99 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "dfeb7563bafd2d89888b8b440dee49d089daeb78", + "is_verified": false, "line_number": 100 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "fea45d453b5b8650cda0b2b9db6b85b60c503d6c", + "is_verified": false, + "line_number": 101 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "bb7538d46b4fde60dc88be303de19d35fe89019d", + "is_verified": false, + "line_number": 102 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "08e0674faf444c6dc671036d900e3decce98d1eb", + "is_verified": false, + "line_number": 103 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "e261897f1d1a99aafec462606b65228331e30583", + "is_verified": false, + "line_number": 104 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "ffe19721c941dfb929b30707c8513e2f0c8c4dc7", + "is_verified": false, + "line_number": 105 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "fe1fc5b0e4ca6aa0189f77a9d78b852201366b81", + "is_verified": false, + "line_number": 106 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "590787fa67e0d75346ed1a3850f98741b6a49506", + "is_verified": false, + "line_number": 107 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "eccb56a947e4d36b8e9d51d0e071caf1a978c6f2", + "is_verified": false, + "line_number": 108 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "c301ee23c9e41d15d5c58c7cd5939e41e7d1eb99", + "is_verified": false, + "line_number": 109 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "9f8607273e42be64e9779e59455706923081cd80", + "is_verified": false, + "line_number": 110 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "72d31fe5a3e5b6e818f5fd3ec97a9ac0042acec7", + "is_verified": false, + "line_number": 111 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "bb9158c9b6e8a0a1007b93b92ec531bdd9ffd32e", + "is_verified": false, + "line_number": 112 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "c2ca44d18bd79c0f1b663d8bc3dfcfb02a7e02df", + "is_verified": false, + "line_number": 113 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "eac2c4cc6263495036a0ef8d8aaf2d8075167249", + "is_verified": false, + "line_number": 114 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "f55341301796552621f367fff6ea9a2bd076df29", + "is_verified": false, + "line_number": 115 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "21967ac89d793aa883840d7a71308514e9e1dc4e", + "is_verified": false, + "line_number": 116 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "679dc9deb86fd7375692381ae784de604a552ae3", + "is_verified": false, + "line_number": 117 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "dd90f8337c050490f6e9b191fb603c9ad402d8c0", + "is_verified": false, + "line_number": 118 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "3c8bfe5a9f458f3884e67768465ac1c17ff80e0f", + "is_verified": false, + "line_number": 119 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "3f01eb8d14a37b6e087592d109baf01e603417eb", + "is_verified": false, + "line_number": 120 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "021709695261ffbc463f12b726d9dd6c27abb6f0", + "is_verified": false, + "line_number": 121 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "a09a21e3684c15de00769686d906f72dd664f663", + "is_verified": false, + "line_number": 122 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "15a62195ff8e8694bfd7045af4391df383b990ed", + "is_verified": false, + "line_number": 123 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "010fa027e45282a3941133bf3403ab98cacc9edd", + "is_verified": false, + "line_number": 124 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "e19fd3f99a05ccf60d1083f5601dea6817b1ac03", + "is_verified": false, + "line_number": 125 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "d17a8e92d9f18e17c7477d375dcac30af8c34ff5", + "is_verified": false, + "line_number": 126 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "c33ae1092a63f763487a4e0d84720b06a2523880", + "is_verified": false, + "line_number": 127 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "9486a607ef0dcb94ce9ac75a85f0a76230defd1d", + "is_verified": false, + "line_number": 128 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "1d850e2d57c74a691b52e3e2526c2767865fb798", + "is_verified": false, + "line_number": 129 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "60a0c030c7e8a5beddd199d1061825b5684ab4ae", + "is_verified": false, + "line_number": 130 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "2986a818d44589ee322b0d05a751b9184b74ebac", + "is_verified": false, + "line_number": 131 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "440aad6aaad76b0dab4c53eb8a9c511d38f5ee1c", + "is_verified": false, + "line_number": 132 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "372c99f2afefff2b07dd4611b07c6830ec1014f3", + "is_verified": false, + "line_number": 133 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "99678a4cbb8d20741f35f04235ee808686a5ee52", + "is_verified": false, + "line_number": 134 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "3486b5c6f177ac543d846a9195d3291a0d3bd724", + "is_verified": false, + "line_number": 135 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "2902179aba6cb39f2c7b774649301a368a39b969", + "is_verified": false, + "line_number": 136 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "4108ee51d5c321b98393b68a262b74d6377cec76", + "is_verified": false, + "line_number": 137 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "8abe8434123396924dc964759bc7823d59b31283", + "is_verified": false, + "line_number": 138 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "a2a8363585b5988aeff2a2c8c878c15445322a52", + "is_verified": false, + "line_number": 139 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "bbbcc1630c23a709000e6da74ca22fe18b78b919", + "is_verified": false, + "line_number": 140 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "be582fadd937879b93b46e404049076080faed08", + "is_verified": false, + "line_number": 141 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "15320eb2e8d97720f682f8dc5105cb86a539a452", + "is_verified": false, + "line_number": 142 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "611278690506b584ecc5d4c88b334dbe7e9b8c54", + "is_verified": false, + "line_number": 143 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "8a08069ce7a3702f245f8c50ac49a529092384be", + "is_verified": false, + "line_number": 144 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "8cf1444399ca01a1bf569233106065b30c103cd2", + "is_verified": false, + "line_number": 145 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "4a5a11832d16a4c2c6914d05397ce3e6f457572f", + "is_verified": false, + "line_number": 146 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "80490973b1980ad3740d42426c7c0f2986cbe462", + "is_verified": false, + "line_number": 147 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "495d2b2d95ba56eded4e4d738b229dd5caaeea67", + "is_verified": false, + "line_number": 148 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "2264d1d1a69546223eb2754465a1b40ce20ab936", + "is_verified": false, + "line_number": 149 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "6e9e9f0b269aacbf7358498c088c226a9296de14", + "is_verified": false, + "line_number": 150 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "1cb9e17cefe3759cb8fd0de893e8a12531c4375b", + "is_verified": false, + "line_number": 151 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "ddc15a0e8c7caca06cf93d15768533595b8ba232", + "is_verified": false, + "line_number": 152 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "7dbafb9953c44da0cc46c003d3dacd14a32a4438", + "is_verified": false, + "line_number": 153 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "be61d29ac11ba55400fcaf405a1b404e269e528e", + "is_verified": false, + "line_number": 154 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "2e65dec5c2802e2bb8102d3cd8d0a7e031a6b130", + "is_verified": false, + "line_number": 155 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "c43e69c82865cf66a55df2d00a9e842df3525669", + "is_verified": false, + "line_number": 156 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "084448bff84b39813fc1efe3ff5840807d7da8f9", + "is_verified": false, + "line_number": 157 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "e175aaf2f1a6929f95138b56d92ae7b84b831ffe", + "is_verified": false, + "line_number": 158 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "9d6deadf9c4eb8ea0240ecca10258afb9b39e0a2", + "is_verified": false, + "line_number": 159 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "4bf318f05592507a55a872cdb1a5739ad4477293", + "is_verified": false, + "line_number": 160 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "b71cc2bafb860b166886bb522c191f45d405cc76", + "is_verified": false, + "line_number": 161 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "a723b7af4e7b4ede705855c03e4d3ac8b17a17a0", + "is_verified": false, + "line_number": 162 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "595c5493c18960b81043b1aaa0ada4a86a493f2b", + "is_verified": false, + "line_number": 163 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "dee9b3f8262451274b6451ead384675a75700188", + "is_verified": false, + "line_number": 164 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "b300397e68cfcee9898e8e00f7395a27f8280070", + "is_verified": false, + "line_number": 165 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "44973e389b0e5b25d51439d6a9b6c9d43fdd6ee0", + "is_verified": false, + "line_number": 166 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "93ebcb14fec5ae9ae41b0bdce7d6aa2971298e47", + "is_verified": false, + "line_number": 167 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "c5b1332b11dd3ba639ce2fdaaa025bad034207e9", + "is_verified": false, + "line_number": 168 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "4927a4f45fa60e6d8deb3d42ca896410d791f3db", + "is_verified": false, + "line_number": 169 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "081e263d2c8f882eb19692648f71ac03a8731c09", + "is_verified": false, + "line_number": 170 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "ef5eba4fd8203b259dd839628ddc0d9a3ed6f97f", + "is_verified": false, + "line_number": 171 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "c90d7323630daddb2824cd0d9e637521237e2454", + "is_verified": false, + "line_number": 172 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "99e13b6a3b2c3c60603df94711c67938be98e776", + "is_verified": false, + "line_number": 173 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "2c55757167c8ecf90790ad052900e790f269619e", + "is_verified": false, + "line_number": 174 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "f3e5c54b01b6e69be585cd9142ed7abe5d4056e5", + "is_verified": false, + "line_number": 175 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "b0dd1c28e143d597218a174dbe0274598c59b9c8", + "is_verified": false, + "line_number": 176 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "9a1fe8341b21243d6116f6b3375877b7fa9b34d7", + "is_verified": false, + "line_number": 177 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "e6b9bc000db030828a117a2d31a0598a84120186", + "is_verified": false, + "line_number": 178 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "8e40eebcfe379882ecbfb761bb470c208826ebf8", + "is_verified": false, + "line_number": 179 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "afd7a7532b580be96e7cc3c0e368a89f31ef621c", + "is_verified": false, + "line_number": 180 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "bfd20c7315b569fab2449be3018de404ed0d6fc3", + "is_verified": false, + "line_number": 181 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "ccba0997cbb3cea20186ca1d3d3b170044e78f27", + "is_verified": false, + "line_number": 182 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "43cd2dcd4adf33ef138634454d93153671a58357", + "is_verified": false, + "line_number": 183 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "7244b34d4c1c0014497a432c580eeea0498b7996", + "is_verified": false, + "line_number": 184 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "ec96512c56ade3837920de713f54fa81e6463a5b", + "is_verified": false, + "line_number": 185 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "f9ab8ac96faef103a825c131a9f6aa18aaf5c496", + "is_verified": false, + "line_number": 186 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "988b02f25fa7b8124ad9d5e3127ec7690bd7f568", + "is_verified": false, + "line_number": 187 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "71d4e0487a5ed7f3f82b2256bed1efb3797c99e2", + "is_verified": false, + "line_number": 188 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "4dad8db6d2449abd1800ac11f64dd362f579a823", + "is_verified": false, + "line_number": 189 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "d079b5fbe50b0b84ad69a0d061b4307a3a0a6688", + "is_verified": false, + "line_number": 190 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "c2672b9214bb9991530f943c1a5a0d05977c0f0a", + "is_verified": false, + "line_number": 191 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "f3a8f4566cd7f256979933da8536f6dafb05d447", + "is_verified": false, + "line_number": 192 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "e3b44891d5e5ec135f1e977ec5fd79c74ca11d9c", + "is_verified": false, + "line_number": 193 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "8542da23c2d0a4b0bcab3939f096b31e3131d85f", + "is_verified": false, + "line_number": 194 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "fb281df2d7a6793a43236092a3fcc1b038db56c9", + "is_verified": false, + "line_number": 195 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "727686c68fa10c5edecbf37cdfec2d44f3a5f669", + "is_verified": false, + "line_number": 196 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "e7957179705dafeab8797bb8f90fcaf5ad0a61ee", + "is_verified": false, + "line_number": 197 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "7424aea64d7c75511030d719e479517e8bef9d25", + "is_verified": false, + "line_number": 198 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "3ad22266e9a3214addc49722b44d9559eb7cbedc", + "is_verified": false, + "line_number": 199 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "8b00c700bf0f6c74820e1ad93d812f961989d69e", + "is_verified": false, + "line_number": 200 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "2eef664e5193da7dde51adccd6d726a988701aaf", + "is_verified": false, + "line_number": 201 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "9186e0986b4b7967aa03cfe311149d508d22e6aa", + "is_verified": false, + "line_number": 202 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "1a639bb9895dc305d6db698183635c1f8b173c5c", + "is_verified": false, + "line_number": 203 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "b5fbec5f1451e2d940c70945a01323eda82984bd", + "is_verified": false, + "line_number": 204 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "ebb046a7ba8464ce615d215edb8b1fd82a1357b6", + "is_verified": false, + "line_number": 205 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "719e3976a5a00a7473cd38f81f712ca8c6e522e1", + "is_verified": false, + "line_number": 206 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "12cde4d54e7136273e8aa76d161b6f143469ef6d", + "is_verified": false, + "line_number": 207 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "e04ec69eef9a4325231986801ebd42d3159ccca7", + "is_verified": false, + "line_number": 208 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "07c8e9accb3cfcc748b91d0369629fa1ee90576f", + "is_verified": false, + "line_number": 209 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "3b00038548a6119fba962ca93f6bd24035d5571e", + "is_verified": false, + "line_number": 210 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "2914f579938a910fb510898044063bec779e5ad5", + "is_verified": false, + "line_number": 211 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "868cf20bb88168a03fa29c7261762c97430ea0fc", + "is_verified": false, + "line_number": 212 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "0475a43ad50f08c4a7012c4a87f15eeee3762ff9", + "is_verified": false, + "line_number": 213 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "5ebe715bd56f0448d0374adae8568a6d86856442", + "is_verified": false, + "line_number": 214 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "9c6dff479fd398382a289dc8f60cabf06fa60a26", + "is_verified": false, + "line_number": 215 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "0102959abc9fee55edba97642bb1bcc546ce07dc", + "is_verified": false, + "line_number": 216 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "45459296596dbed9d7fbf7eab7a9645eb4fa107a", + "is_verified": false, + "line_number": 217 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "5a5a491d064e789e785a8b080d38d9d1cc7d207f", + "is_verified": false, + "line_number": 218 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "f3005c052e76c7e804c10403bdfcd9265a9de2ea", + "is_verified": false, + "line_number": 219 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "73aaaaf5bcab49cc1b1f47b45eae9b31db783a66", + "is_verified": false, + "line_number": 220 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "13aae30474af34fdede678dc5e8c00c075612707", + "is_verified": false, + "line_number": 221 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "336edbc017f4dadc0bf047e0f6d1889679fc3b48", + "is_verified": false, + "line_number": 222 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "7bff3213c39d3873551698ec233998613e6b69dc", + "is_verified": false, + "line_number": 223 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "9f1a6484627a58c233e1ec3f0aeffe4ff2d8a440", + "is_verified": false, + "line_number": 224 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "d7c80e31311e912fb766bb2348b02785c28d878b", + "is_verified": false, + "line_number": 225 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "2c75cc7344d810bb26cb768be82e843af623001a", + "is_verified": false, + "line_number": 226 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "607df6be12ab20f70a64076c372b178d6c10bc00", + "is_verified": false, + "line_number": 227 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "9b7fed64d1f0682953011eb4702467dee8cd1174", + "is_verified": false, + "line_number": 228 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "e982d9359554bc4a5c58d9d8d4387843e6e5cbb4", + "is_verified": false, + "line_number": 229 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "c2f3985aed2da033a083cb330fb006239b2a1c8e", + "is_verified": false, + "line_number": 230 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "23d658cf19e1e76efbfa3498d2c2ed091c60b1f4", + "is_verified": false, + "line_number": 231 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "a58be87cd80825e211c567b3c5397e122f702019", + "is_verified": false, + "line_number": 232 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "f96f43b99c2f249a03a2e57e097c236561a1162c", + "is_verified": false, + "line_number": 233 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "2fc8f0d1c9fadfb9cc384af21c8d3716c99a40f6", + "is_verified": false, + "line_number": 234 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "f229dfc403d5b25f3362e73c4a7dc05233ecd4b6", + "is_verified": false, + "line_number": 235 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "cf79e1dd8ff4c91b3346f5153780ba52438830be", + "is_verified": false, + "line_number": 236 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "20a1e643e857f0f63923b810289ab4b6c848252e", + "is_verified": false, + "line_number": 237 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "9754246ca2c82802cc557d5958175d94ae5c760b", + "is_verified": false, + "line_number": 238 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "ca0abe4a600e610c1bbbb25de89390251811ed1c", + "is_verified": false, + "line_number": 239 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "b9c7402f138d31bea12092e7243ac7050a693146", + "is_verified": false, + "line_number": 240 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "07e9e0d4ea04d51535c0ec78454f32830dcfe8da", + "is_verified": false, + "line_number": 241 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "9872435a00467574f08579e551e3900c65f2b36e", + "is_verified": false, + "line_number": 242 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "eec328050797cfffad3dc2dd6dd16d8ec33675f6", + "is_verified": false, + "line_number": 243 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "b3b084478fcaec50b9f7e39dfef8bda422d48d91", + "is_verified": false, + "line_number": 244 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "2093470fb2ffad170981ec4b030b0292929f3022", + "is_verified": false, + "line_number": 245 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "b920a9ef2ec94e4e4edac20163e006425a391da4", + "is_verified": false, + "line_number": 246 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "06455554c00ce5845d49ebef199c0021b208d5df", + "is_verified": false, + "line_number": 247 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "a077b13877b651822b80de2903f4b6acdbac3433", + "is_verified": false, + "line_number": 248 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "78fd658f1b01b01b25be00348caeced0e3ad0b29", + "is_verified": false, + "line_number": 249 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "79f7d6f792cc4e4ba79e3bf7cd3538fb65e4399a", + "is_verified": false, + "line_number": 250 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "8280b950e62db218766e1087ec5771ec93de3b36", + "is_verified": false, + "line_number": 251 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "11fffafcae5d1e1aacf6f3c3a0235bbed17cacb2", + "is_verified": false, + "line_number": 252 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "f0aebb371b0356a2e803f625a1274299544e0472", + "is_verified": false, + "line_number": 253 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "bce9139737d07f1759822ac6e458eff6c06c1dae", + "is_verified": false, + "line_number": 254 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "a61bed5d464a3dd53f1814dc44da919124e2c72b", + "is_verified": false, + "line_number": 255 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "9c553b7e8c46273c6e1841f82032a11f697cafe1", + "is_verified": false, + "line_number": 256 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "24535adb56bd8d682e42561ded0eaab8a1a18475", + "is_verified": false, + "line_number": 257 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "7f16429d5dba0340ae2ec02921abbe054ad4d9fd", + "is_verified": false, + "line_number": 258 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "61bac3ad8d011d3db96793f70a9fdaf5def37244", + "is_verified": false, + "line_number": 259 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "413654967fff8eae5dd1fece27756c957721d131", + "is_verified": false, + "line_number": 260 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "c42fd06a8e9c5ad8b9b3624c1732347dd992f665", + "is_verified": false, + "line_number": 261 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "53fbf2125f17fd346dba810d394774c191c05241", + "is_verified": false, + "line_number": 262 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "312ebc5348c48d940a08737cc70b257c7ba67358", + "is_verified": false, + "line_number": 263 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "3c072673c95b839b4c75a59ffcb4e7de11df227c", + "is_verified": false, + "line_number": 264 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "67dcac03bb680bd7400daff1125821df29119a57", + "is_verified": false, + "line_number": 265 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "74ceb07916759595af8144a74de06f4622295fab", + "is_verified": false, + "line_number": 266 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "becd47f7a933263c4029eb3298bdf67e64166b72", + "is_verified": false, + "line_number": 267 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "62cbb7af58e6841cb33ae8aa20b188904e88400b", + "is_verified": false, + "line_number": 268 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "1240f6fbe789e15d2488a1f63a38913ace848063", + "is_verified": false, + "line_number": 269 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "b313e2c9b9b7a229486000525bd2bfd909c739c3", + "is_verified": false, + "line_number": 270 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "9ccd84180f08a811fc82fc6c2baa43b92b0c6d4c", + "is_verified": false, + "line_number": 271 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "fec498a62202037efd0ff28ff270b1d65600ee21", + "is_verified": false, + "line_number": 272 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "5e5991defd9bf4c9cd7ad44bfc3499b021f9b306", + "is_verified": false, + "line_number": 273 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "3ac80ba9980be6af93aa361f71cc0b24ebb9a80d", + "is_verified": false, + "line_number": 274 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "3e58a970f8a2580b7929b87623a05bcfd18ff5d0", + "is_verified": false, + "line_number": 275 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "4e95912a938c4a5d793d6147f17b1a4f4564f521", + "is_verified": false, + "line_number": 276 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "b9c19621f11904336bb1c83271b6e66392139adf", + "is_verified": false, + "line_number": 277 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "ea26c6b69a1fbd9d19136131f1a4904190cdc910", + "is_verified": false, + "line_number": 278 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "88806d10d6a88e386d7bffe5ed9d13a01aa30188", + "is_verified": false, + "line_number": 279 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "92c4052a065855d439918461deb8ab1d85b8dec4", + "is_verified": false, + "line_number": 280 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "5a801127b30267b3143bcd1879b09ce966f4e4db", + "is_verified": false, + "line_number": 281 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "03c0a54929a02a84158ffbab6a79ba8a31bbea5e", + "is_verified": false, + "line_number": 282 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "9adc71007b98c2f47eb094b8c771d0a2c81e8584", + "is_verified": false, + "line_number": 283 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "19cc3f05c05fc6ff92f9a56656d3903fb6e05af1", + "is_verified": false, + "line_number": 284 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "901c70145ec0a76f9705743bc180ac505301db81", + "is_verified": false, + "line_number": 285 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "e264698710238eada7824909e03b11a1d5b94d01", + "is_verified": false, + "line_number": 286 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "e74cd3a559f33f9541ef286068dee5338b7c2f5d", + "is_verified": false, + "line_number": 287 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "a0b7170416566ab964d395d0cf138ecd3c65fe2c", + "is_verified": false, + "line_number": 288 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "c9c183b3a85dec6b215a6a18a1f0ce82381c12a6", + "is_verified": false, + "line_number": 289 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "06b739bfeff8deb1f44a03424e08ab08f1280851", + "is_verified": false, + "line_number": 290 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "25dc7c4a6b8bfdcb8bc41e815d05dac7fa905711", + "is_verified": false, + "line_number": 291 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "1b298510f55fd15ee6110b2a9250263dbc9f4fc9", + "is_verified": false, + "line_number": 292 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "6403b53b45d57554b17c4388178cd5250aa7587a", + "is_verified": false, + "line_number": 293 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "f944cf9178e33e14fddf0ac6149cbb69e993d05c", + "is_verified": false, + "line_number": 294 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "61b4fee247e19961be2d760ed745da4e39d8bf4e", + "is_verified": false, + "line_number": 295 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "d25d1f3178dd3a9485d590ce68bd38b3029d0806", + "is_verified": false, + "line_number": 296 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "9fdfeae6046b80e2ae85322799cdc6da4842f991", + "is_verified": false, + "line_number": 297 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "f7143b0c85044b4b76ef20cd58177815daf7407e", + "is_verified": false, + "line_number": 298 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "5e605f0950f7c24e192224fa469889b9c83c80ac", + "is_verified": false, + "line_number": 299 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "329c29edf1fb8e3427b1d79a30e77a700c01ff5c", + "is_verified": false, + "line_number": 300 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "74a03233311d2f477a3dd7ffa81c7343586b1f8e", + "is_verified": false, + "line_number": 301 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "3b1df47dbd920bfaf1de8a7b957d21d552d78a76", + "is_verified": false, + "line_number": 302 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "929a23cdbe2b28de6dac28454d1e7478a4a14fea", + "is_verified": false, + "line_number": 303 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "a6436a4a36cd90e5d03b33f562213dfc3d038455", + "is_verified": false, + "line_number": 304 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "a010833ccd24af9e70339bac73664fb47b6ac727", + "is_verified": false, + "line_number": 305 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "53be5a9c1c894e77c4fcdfbbb3b003405252ed79", + "is_verified": false, + "line_number": 306 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "61b289fe5c2eb0d8b8bc5b1cc5e9855472daabd9", + "is_verified": false, + "line_number": 307 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "773307c58ca81fd42a4734bbc4b3c7eb8bcfd774", + "is_verified": false, + "line_number": 308 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "35f607d2769173d1672e30f60b9276d01b8250d7", + "is_verified": false, + "line_number": 309 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "e602d5d9691c09f57a628600014aaae749d38489", + "is_verified": false, + "line_number": 310 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "625238f7e6c9febfca3878a385daa7b8646a2439", + "is_verified": false, + "line_number": 311 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "e6ba52cd1f2f9a30963834fd94aafc869bf05b82", + "is_verified": false, + "line_number": 312 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "d629b569233f71690b6e6eaed9001e44b88c50bf", + "is_verified": false, + "line_number": 313 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "a001d4059055a1c86b9ec62774d044b54ddb3376", + "is_verified": false, + "line_number": 314 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "bce06d4b0177a2d06399e21e0b26bc99e44d6e9b", + "is_verified": false, + "line_number": 315 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "cb6af31518d65e6dcb92fb01b9f31556c3a70c5e", + "is_verified": false, + "line_number": 316 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "c2a95352f382fdbe53bd8b729a718c38eacfbf73", + "is_verified": false, + "line_number": 317 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "f9b16dccab1e453362789df2fc682f2ba2c9ee2a", + "is_verified": false, + "line_number": 318 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "1bb4e4fd05b7c33cfab0dad062c54a16278d3423", + "is_verified": false, + "line_number": 319 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "9dcc6dc6f20a71fd6880951ceb63262d34de8334", + "is_verified": false, + "line_number": 320 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "666382b579258537d6cf5e7094dbaa0684b78707", + "is_verified": false, + "line_number": 321 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "072c49f046dfdce12c1553a67756e2f5ee4d7e49", + "is_verified": false, + "line_number": 322 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "47b792bdebbbf305d87092f12c0afcd8810e054d", + "is_verified": false, + "line_number": 323 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "41d3b22a387fa43c1491d62310faf50c4ab7956a", + "is_verified": false, + "line_number": 324 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "bcdc3859e08c518f75cfe65b69f3adb9f489400b", + "is_verified": false, + "line_number": 325 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "fc2b22e2d43816acf209af822877aff7e82fa4d0", + "is_verified": false, + "line_number": 326 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "f63542bc2eb9de2caa3bfaeafd53d7bf65485889", + "is_verified": false, + "line_number": 327 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "7ab01f0f438a3d21b529df89fbde67234aa49d89", + "is_verified": false, + "line_number": 328 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "fed608fe9221f0e45c84b68a80a0c065a9a2b7f1", + "is_verified": false, + "line_number": 329 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "7a6394c70b925009c3e708ec195a17ee40cae8f4", + "is_verified": false, + "line_number": 330 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "5d615bd2adf567fe7403c51814ff76c694b1c8d3", + "is_verified": false, + "line_number": 331 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "77f3c695d15ee63db41dabcecce126a246b266e6", + "is_verified": false, + "line_number": 332 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "78138e46003e12617c75a8011fddbe2868ff5650", + "is_verified": false, + "line_number": 333 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "89c905852505ac6168e4132b5ee29241a64b2654", + "is_verified": false, + "line_number": 334 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "3d55f361c5d2bf2c1ec7d2c2551d7bec67b3cc35", + "is_verified": false, + "line_number": 335 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "89f1aec19abc18d22541dc01270e0fee325a878b", + "is_verified": false, + "line_number": 336 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "23ed3413498b5fe9fe2d6d3ae4040a0e2571c9df", + "is_verified": false, + "line_number": 337 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "e7f990c94d57f6880b1e2cf856ab0646636bc46a", + "is_verified": false, + "line_number": 338 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "87dccf8b7123c723b5c35c45533d7471a19c9c22", + "is_verified": false, + "line_number": 339 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "14a222dcf6b592c1178fae0babbb73d809102462", + "is_verified": false, + "line_number": 340 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "161b87029fb1fe5f37573770659140c254b6f26d", + "is_verified": false, + "line_number": 341 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "e01ccf01c8ae560637e1fba1396ec9d27a48943e", + "is_verified": false, + "line_number": 342 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "0d45bd0e0858d416488ca24b5e277430fdbc29a2", + "is_verified": false, + "line_number": 343 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "bd6b3d87fee3f95d7bbe77782404507c7d6d23ba", + "is_verified": false, + "line_number": 344 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "297eface47da40362e6c34af977185a96ecd4503", + "is_verified": false, + "line_number": 345 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "1d908d54bd47e7b762cf149a00428daf8ab41535", + "is_verified": false, + "line_number": 346 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "e0404cb2e3feaba3e7bdc52c798b9bce57f546d3", + "is_verified": false, + "line_number": 347 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "8dc5b0bbc5b3c3f93405daac036e950013ae6e83", + "is_verified": false, + "line_number": 348 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "c914f94ead99fe6e6b262f63f419aba9f1f65cc9", + "is_verified": false, + "line_number": 349 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "5d2559e8fbde4bdf604babb1a00a92f547e9c305", + "is_verified": false, + "line_number": 350 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "b28706495d2c7f4e44a064279570ec409025bce8", + "is_verified": false, + "line_number": 351 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "ce77aa4f51f5ee1a1f56ba0999a3873e07bdec29", + "is_verified": false, + "line_number": 352 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "c828435ec3655b9b44974c212f94811121d3183c", + "is_verified": false, + "line_number": 353 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "0361b85a6a04d362a8704e834cd633a76d7c8531", + "is_verified": false, + "line_number": 354 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "e8b43fe4aa4ece98317775e13e359f784187c9ea", + "is_verified": false, + "line_number": 355 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "ec00a6364212bbc187bc15f3a22ec56eb7d5d201", + "is_verified": false, + "line_number": 356 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "5599c260b57d92c0f8bd7613fa1233ad9f599db3", + "is_verified": false, + "line_number": 357 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "d11065d4dd0b6fd8e29dd99b53bfbe17e1447ab3", + "is_verified": false, + "line_number": 358 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "c8c47349a7991ac9cb1df02c20e18dde2ec48b9c", + "is_verified": false, + "line_number": 359 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "e5302dc80bfbd04a37e52099a936c74b38d022ec", + "is_verified": false, + "line_number": 360 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "4a4e17621d292bddf3604bcc712ed17fdd28aca2", + "is_verified": false, + "line_number": 361 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "a43a1929d714363194cc42b3477dfe9b4c679036", + "is_verified": false, + "line_number": 362 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "645e56a2836118de395a78586b710ac24c6d1b9d", + "is_verified": false, + "line_number": 363 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "c0f20d875c6d2d8e99539de46a245a5a30e757d0", + "is_verified": false, + "line_number": 364 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "fb552bf2f6ea4da1a8d0203ac4c6b4ecb1bbea56", + "is_verified": false, + "line_number": 365 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "53c6b8e08eeb37812e6e40071ac16916c372b60f", + "is_verified": false, + "line_number": 366 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "c64cf6bc4ec02fa8b2bf2f5de1c04f0a0c8ec77d", + "is_verified": false, + "line_number": 367 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "e7dc30b59854ec80d81edc89378c880df83697c4", + "is_verified": false, + "line_number": 368 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "e60404864ae5ddda3612f7ece72537ab2a97abf7", + "is_verified": false, + "line_number": 369 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "a84bea5c674feff72b4542a20373b69d25a47b89", + "is_verified": false, + "line_number": 370 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "47cbc18c75b60b6e0ed4d8b6a56b705a918e814b", + "is_verified": false, + "line_number": 371 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "cd8bc0fe19677ebb0187995618c3fa78d994bbb2", + "is_verified": false, + "line_number": 372 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "887786ac035ae25cc86bd2205542f8a1936e04d2", + "is_verified": false, + "line_number": 373 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "3ef2e1c199d211d5f1805b7116cb0314d7180a5c", + "is_verified": false, + "line_number": 374 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "f89746f236eab3882d16c8ff8668ed874692cde3", + "is_verified": false, + "line_number": 375 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "2b3db4dc1799edfee973978b339357881c73d3ab", + "is_verified": false, + "line_number": 376 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "b7254fda5baf4f83d6081229d10c2734763d58b4", + "is_verified": false, + "line_number": 377 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "9af3e435c37c257b5e652e38a2dfd776ab01726e", + "is_verified": false, + "line_number": 378 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "833be77b754d40e1f889b7eda5c192ae9e3a63fe", + "is_verified": false, + "line_number": 379 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "a153d9446771953d3e571c86725da1572899c284", + "is_verified": false, + "line_number": 380 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "68d2128a64a2b421d62bc4a5afeeb20649efe317", + "is_verified": false, + "line_number": 381 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "92490f06bfafdb12118f5494f08821c771abafff", + "is_verified": false, + "line_number": 382 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "84a479485dd167e8dc97cce221767e68cbe14793", + "is_verified": false, + "line_number": 383 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "ca9c140d7b9b6dbf874d9124b3de861939eb834e", + "is_verified": false, + "line_number": 384 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "d293b3b1e9c7e4b8adde8f2a8d68159c72582f71", + "is_verified": false, + "line_number": 385 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "120db881813bc074d8abb7a52909f1ffc4acf08b", + "is_verified": false, + "line_number": 386 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "6be68465c1bce11d46731c083c86cc39b4ca4b26", + "is_verified": false, + "line_number": 387 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "ec613f94f9c8e0a7c9a412e1405a0d1862888d44", + "is_verified": false, + "line_number": 388 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "53300289cf9589a5e08bfa702e1f3a09d2d088b1", + "is_verified": false, + "line_number": 389 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "aac8dac3f68993b049bcc04acbb83ee491921fa8", + "is_verified": false, + "line_number": 390 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "b309b1a5cda603c764ed884401105a00c1a1b760", + "is_verified": false, + "line_number": 391 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "c1d9acf0ca3757e6861a2c8eab08f6bf39f8f1a3", + "is_verified": false, + "line_number": 392 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "39860c432a27f5bcbcd30b58cdd4b2f8e6daf65f", + "is_verified": false, + "line_number": 393 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "f28f8289110a85b1b99cd2089e9dfa14901a6bbe", + "is_verified": false, + "line_number": 394 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "7c51dd968d2ae5ffad1bc290812c0d6d3f79b28a", + "is_verified": false, + "line_number": 395 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "19e03888ea02a1788b3e7aacdb982a5f29c67816", + "is_verified": false, + "line_number": 396 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "936e0dfc9fa79e90eabe1640e4808232112d6def", + "is_verified": false, + "line_number": 397 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "66b03fc6f79763108c0e0ebced61830ce609d769", + "is_verified": false, + "line_number": 398 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "b4615dacf79e97a732e205acd45e29c655a422cb", + "is_verified": false, + "line_number": 399 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "4e9cab1ac24cee599dc609b69273255207fb9703", + "is_verified": false, + "line_number": 400 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "7c2d628057af1a5f9cdc10e1a94d61fa2f43671c", + "is_verified": false, + "line_number": 401 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "1f76628414c76162638c6cdd002f50d35c0030df", + "is_verified": false, + "line_number": 402 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "656cd81676438907b67dc35f1dcbc7f65fb44eae", + "is_verified": false, + "line_number": 403 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "2b7c94fe6035b5e6d98a65122fd66d9fbc0710f6", + "is_verified": false, + "line_number": 404 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "d55f6f2d0aff7554ed2c85a4f534c421ba83601a", + "is_verified": false, + "line_number": 405 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "742a9e62c813d9b6326e2540f1f9f97dfca8542c", + "is_verified": false, + "line_number": 406 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "8b446fd2f0b22dc0fdfee36b5b370643b669bd2d", + "is_verified": false, + "line_number": 407 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "ce38475ba93df187a8dd9972a02437ffef9e849c", + "is_verified": false, + "line_number": 408 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "e5581573b5114490af9bdc16bad95dca6177f4ba", + "is_verified": false, + "line_number": 409 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "2f005879125b38683f71c8a64bd232cd11591e08", + "is_verified": false, + "line_number": 410 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "7e1581a6326b6fb0d8f18d69631ee8ee2a2b3d50", + "is_verified": false, + "line_number": 411 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "e5814a47cd07ed2435b048b8b97f41be6cd2c9eb", + "is_verified": false, + "line_number": 412 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "72a7b76523b4eda36ffdd63ac1bcd4f52063e387", + "is_verified": false, + "line_number": 413 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "3d2aeb7f6499d336ff54871823348b2bf58e7c89", + "is_verified": false, + "line_number": 414 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "ca1473b861759dfa5fb912c2a7c49316897cafa5", + "is_verified": false, + "line_number": 415 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "5bc665714e4b5b73c47d7e066567db6fde6ff539", + "is_verified": false, + "line_number": 416 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "8f2f91164826d44904bc522f6680822bfd758342", + "is_verified": false, + "line_number": 417 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "c9c956b3f172ca5ed76808abd98502a3499268f1", + "is_verified": false, + "line_number": 418 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "b0c287a3b80addbf5fe7eb56f10dd251368ba491", + "is_verified": false, + "line_number": 419 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "5da8ed9d858656f49131055a4b632defccffd4dd", + "is_verified": false, + "line_number": 420 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "23dd6031c249baabd4b92e8596f896bbc407eb7e", + "is_verified": false, + "line_number": 421 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "c58b01cfd3befe531fdad283418fa7ac558cea5f", + "is_verified": false, + "line_number": 422 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "32a9671da53c8e3572ffd9303171adf6ae95a919", + "is_verified": false, + "line_number": 423 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "60789728174b9ee630b33b2af057e0c6a0180947", + "is_verified": false, + "line_number": 424 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "073252599d795b92b38cbad3ed849f1c5fd5368b", + "is_verified": false, + "line_number": 425 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "761bcb628d3c585abebaa8a64b04ab193f5a559e", + "is_verified": false, + "line_number": 426 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "dd230524f2606a207b426444142d01d518781aef", + "is_verified": false, + "line_number": 427 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "3b459c62a8c9fe3401808103493996348ef70870", + "is_verified": false, + "line_number": 428 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "70dbcfd2a8a038e265a0d3d6379284b679226101", + "is_verified": false, + "line_number": 429 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "29398aafd66a1c4f181e540ec90a2b76dcdfe2cc", + "is_verified": false, + "line_number": 430 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "4698c1c5c6daf3f88ec2768de0693d543e81c8b5", + "is_verified": false, + "line_number": 431 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "cd333285b1ef33582b502f72b4a153a16a4678a9", + "is_verified": false, + "line_number": 432 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "b2c2475773928e727fd3ba3969aaae40ab2b99b2", + "is_verified": false, + "line_number": 433 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "c28676c2076efac73f3d01195ed463c6d7a6f442", + "is_verified": false, + "line_number": 434 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "c520370cf0e7b1bcc405af46775963a7df856b9d", + "is_verified": false, + "line_number": 435 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "fcd376b4fd7ecf2299b1ad018e66732a5e74ee08", + "is_verified": false, + "line_number": 436 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "f9a69a2290885d929addfd83a6c1570dc7c76646", + "is_verified": false, + "line_number": 437 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "5fdb5ce747a93d7048f4fd3a428653520b3efb50", + "is_verified": false, + "line_number": 438 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "4ca9129303ac0d5e4e1b810e7abf90ea11a16833", + "is_verified": false, + "line_number": 439 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "f83fb00877111e23db5ceb8b74255963d17c84e9", + "is_verified": false, + "line_number": 440 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "17e35c47564c0e6fefa2946f24d71618053bcfb7", + "is_verified": false, + "line_number": 441 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "fab7d05454c71ae59bade022116124571421e4c4", + "is_verified": false, + "line_number": 442 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "7820b9feb8912aee44c524eedf37df78b8d90200", + "is_verified": false, + "line_number": 443 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "ea2a0f7323961fd704b1bad39ae54e02c9345d2a", + "is_verified": false, + "line_number": 444 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "353fcf93df94d7081d2bd21eab903cf8e492f614", + "is_verified": false, + "line_number": 445 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "7149d4db2de10af66a4390042173958d5fa1cbde", + "is_verified": false, + "line_number": 446 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "85b4428454e38494e03e227d224ae58a586ab768", + "is_verified": false, + "line_number": 447 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "df83530e6fb8ccd7f380c5dc82bc8c314b82436a", + "is_verified": false, + "line_number": 448 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "106157744da44adeb38c39220b1db267c26deb77", + "is_verified": false, + "line_number": 449 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "c5e67d1eed731314ac68f5e67cb7b7dba68225f5", + "is_verified": false, + "line_number": 450 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "d9737cec69cbdedea1a2d9a70d7961ff76592696", + "is_verified": false, + "line_number": 451 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "7aab6c9118166720f0f0e3a9db46fd59e3ed647d", + "is_verified": false, + "line_number": 452 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "500a58b74d63b4c10c8c098743d63e51a477c9cd", + "is_verified": false, + "line_number": 453 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "69a150ffbef689cc7a14cfc019e9c808b19afd4a", + "is_verified": false, + "line_number": 454 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "49d3801a82b82e48cbcc596af60be9d4b72bbd76", + "is_verified": false, + "line_number": 455 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "5f3e17df79af2812cc6b5dbc211224595f8299a8", + "is_verified": false, + "line_number": 456 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "5f21f46cef784459cbac4d4dc83015d760f37bcf", + "is_verified": false, + "line_number": 457 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "4a91f36506d85a30ddc1a32f9ed41545eeb1320f", + "is_verified": false, + "line_number": 458 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "b99666bc5cc4bf48a44f4f7265633ebc8af6d4b7", + "is_verified": false, + "line_number": 459 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "c061353e73ac0a46b366b0de2325b728e3d75c5b", + "is_verified": false, + "line_number": 460 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "d17d588edde018a01f319f5f235e2d3bcbbe8879", + "is_verified": false, + "line_number": 461 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "63567656706221b839b2545375a8ba06cd8d99ae", + "is_verified": false, + "line_number": 462 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "976e5ce3af12f576a37ce83ccf034fd223616033", + "is_verified": false, + "line_number": 463 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "626b3f10041c9e9a173ca99252424b49e3377345", + "is_verified": false, + "line_number": 464 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "f8ba93d3a155b11bb1f2ef51b2e3c48c2723ef8e", + "is_verified": false, + "line_number": 465 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "8b4879aed0c0368438de972c19849b7835adb762", + "is_verified": false, + "line_number": 466 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "d35dbaf2ea5ec4fc587bed878582bba8599f31c0", + "is_verified": false, + "line_number": 467 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "c09d7037f9b01473f6d2980d71c2f9a1a666411c", + "is_verified": false, + "line_number": 468 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "d53d7f86659a0602cd1eb8068a5ad80a85e16234", + "is_verified": false, + "line_number": 469 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "aa9442f71f2747b5bb2a190454e511a7c62263d8", + "is_verified": false, + "line_number": 470 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "f800b1fed08ed55a8e2a9223fc3939c96f3e11e5", + "is_verified": false, + "line_number": 471 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "e46a4855198ba0f803471fb44a70ae5fbd2dd58f", + "is_verified": false, + "line_number": 472 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "f47b48b6b7c2847fbe206253667d1eda00880758", + "is_verified": false, + "line_number": 473 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "a9d98ab785981fe0f13a721e7fe2094a6e644b5d", + "is_verified": false, + "line_number": 474 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "fe151aabb001edb57e3fed654d3a96e00bc58c81", + "is_verified": false, + "line_number": 475 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "77c40b5a173e170886069d57178c0074dfe71514", + "is_verified": false, + "line_number": 476 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "04e04736dcf54eb8a8ef78638b0b0412cab69e96", + "is_verified": false, + "line_number": 477 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "b13a34e3be842da54436ed8ab8f2a9758b2cc38e", + "is_verified": false, + "line_number": 478 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "3971f1dcb845e4eaedcb04a6505fd69e27b60982", + "is_verified": false, + "line_number": 479 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "1b8ae7b1c309866e28fe66e07927675ce0e24514", + "is_verified": false, + "line_number": 480 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "4c3f6543b234d2db27b1a347b3768028dd60bc77", + "is_verified": false, + "line_number": 481 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "ca4ac68931f7c54308050c1b6ac9657c4ff0d399", + "is_verified": false, + "line_number": 482 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "02cca5fc17dc903feb5088abec3d2262f604402e", + "is_verified": false, + "line_number": 483 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "d864c37f23cab8cff54e9977a41676319c040928", + "is_verified": false, + "line_number": 484 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "e67a5309737b99b0ac9ba746ca33d6682975cea1", + "is_verified": false, + "line_number": 485 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "aef65112b27cc0ecbcfbd3ae95847e9e0fbee0b7", + "is_verified": false, + "line_number": 486 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "40d73861d177d9e22d977dd62b8a111bbf8ee0b7", + "is_verified": false, + "line_number": 487 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "71e44d4a353467958cd9be3a7e6942385e883568", + "is_verified": false, + "line_number": 488 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "e1f00f9205b689ba1d025f88e948f03a4ac77a59", + "is_verified": false, + "line_number": 489 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "6a9f1470e772a7f4176e8c24b7ab0e307847b92b", + "is_verified": false, + "line_number": 490 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "5959a3a8554f9ce7987b60e5e915b9e357af0d99", + "is_verified": false, + "line_number": 491 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "b0a791edf8675bd6a65fc9de9ba5bcb8336d1fc0", + "is_verified": false, + "line_number": 492 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "557bcf89f60a98f72b336e21f56521a4c30a2f0c", + "is_verified": false, + "line_number": 493 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "80e8a78fd29c2ac00817f37e03d9208f8fd59441", + "is_verified": false, + "line_number": 494 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "351dded8c590b80cc8dc498021fccadc972c1d00", + "is_verified": false, + "line_number": 495 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "4f55ad2c0e5a697defde047e6a388c14b3423cda", + "is_verified": false, + "line_number": 496 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "20412c530d4b4c38510d9924cbfb259126c2568c", + "is_verified": false, + "line_number": 497 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "05e66772d14918a72d1b6f45872428a35c424347", + "is_verified": false, + "line_number": 498 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "c61a40f7ae13f5e26ea16a6266491d58e78f6f1f", + "is_verified": false, + "line_number": 499 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "b4d93dd6c2e36056d55ce3844610991eec962277", + "is_verified": false, + "line_number": 500 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "c7088e4ff6e5a3bc44ca3fdf1b06847711f3e95c", + "is_verified": false, + "line_number": 501 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "5e5168774b473fb9fcc31c8f5c1518eb0f9771c1", + "is_verified": false, + "line_number": 502 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "a1f86c50a6626bcab082286bec7f5474e7c8b293", + "is_verified": false, + "line_number": 503 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "a9fac6e3490672c5dccd35d5e6fc1cb7b1b5931b", + "is_verified": false, + "line_number": 504 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "b48c69b346d712e3df1728014956ac0397c659ea", + "is_verified": false, + "line_number": 505 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "8367e351d57fa775f22fc1132dd170c458799542", + "is_verified": false, + "line_number": 506 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "972953c33baa3303c488360576bdd3bae95e79a3", + "is_verified": false, + "line_number": 507 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "2ef2d21dde1d6ef435fbf1b6a049f7e94a2d5588", + "is_verified": false, + "line_number": 508 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "76bf193e8f7b54ab5f0007ee41b768ee1e3ce24d", + "is_verified": false, + "line_number": 509 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "e8e93efe226e4bf62b880c14bdef1507dc67c4fe", + "is_verified": false, + "line_number": 510 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "71cd9e3eb02ec34d305a55df09540b95549f8342", + "is_verified": false, + "line_number": 511 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "34c2c4351cc369f306886089967adc3fd23202b5", + "is_verified": false, + "line_number": 512 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "95a9e6645670ef390609e97a9a94ab1af8ecb5e5", + "is_verified": false, + "line_number": 513 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "7a773ead4f5cbee039dd9c90bcbd2157ff9dfe98", + "is_verified": false, + "line_number": 514 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "c8974d5459c5318a865674227914120b61ee7ca8", + "is_verified": false, + "line_number": 515 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "9aa53dd7b54460ca4058dc1b993c61c85016c3a5", + "is_verified": false, + "line_number": 516 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "5cf42e6632ac13c10b1709348bda0d36d4cc8fe2", + "is_verified": false, + "line_number": 517 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "22368f64933f9d4b20751ed12db25bdb937f4288", + "is_verified": false, + "line_number": 518 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "558145b7f5778e24056c8de59bd9d54190950f14", + "is_verified": false, + "line_number": 519 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "2068d5b68ddc59653056d96e1283951282b22267", + "is_verified": false, + "line_number": 520 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "4d807498a9a96f89bb538a8308d6056a2a303a0d", + "is_verified": false, + "line_number": 521 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "3457741ed34d5ad7b9d04fa9cc677a72e8c47b4d", + "is_verified": false, + "line_number": 522 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "59556e4aa33301c95feb9c58d99d10a080179646", + "is_verified": false, + "line_number": 523 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "2d49954101a3bd1dd5da50b8a1847f00bf4ec16b", + "is_verified": false, + "line_number": 524 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "c2f14cff186baad8445fb7997c3dc863eff10ef6", + "is_verified": false, + "line_number": 525 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "dd317a7973e49de529850041e8c1ce51b0d378df", + "is_verified": false, + "line_number": 526 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "9cbaaf4ff0453e81aaac598e05d8c973991c77b3", + "is_verified": false, + "line_number": 527 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "576dd6a98701c267f16a5e568f8b6a748665713d", + "is_verified": false, + "line_number": 528 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "c5ce7f45e2ddbd43d244e473e165b1400ba86dd9", + "is_verified": false, + "line_number": 529 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "04a10a70b498263467ef1968fabfb90e012fd101", + "is_verified": false, + "line_number": 530 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "482928d9b3b49339bc5f96e54f970e98f84970b7", + "is_verified": false, + "line_number": 531 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "24d25f3a906f38241bd1d3dfa750631cd4b2f91f", + "is_verified": false, + "line_number": 532 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "8cc46e3c020e63d10457e32b2e5d28b5c7ce0960", + "is_verified": false, + "line_number": 533 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "da272306205373082db86bc6bc2577ab85ed9e31", + "is_verified": false, + "line_number": 534 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "b03284305e4d5012e7c3cf243b2942a6dab309cc", + "is_verified": false, + "line_number": 535 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "f7c91578b688a0054f2c1e18082541d6ecc6b865", + "is_verified": false, + "line_number": 536 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "1f009c80b8504a856a276e8d2c66210b59e8bf2e", + "is_verified": false, + "line_number": 537 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "54490e77b2c296149b58ae26c414fea75c6b34ec", + "is_verified": false, + "line_number": 538 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "d5bd68de7769dde988f99eab3781025297a7212d", + "is_verified": false, + "line_number": 539 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "b6161808b7485264957a2f88c822f0929047f39a", + "is_verified": false, + "line_number": 540 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "1ff88fb1bf83bca472ab129466e257c9cc412821", + "is_verified": false, + "line_number": 541 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "002e1405d3a8ea0f2241832ea5480b0bf374c4c6", + "is_verified": false, + "line_number": 542 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "1058c455a959a189a2d87806d15edeff48e32077", + "is_verified": false, + "line_number": 543 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "cbcf1915e42c132c29771ceea1ba465602f4907c", + "is_verified": false, + "line_number": 544 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "23738e07a26a79ab81f4d2f72dc46d89f411e234", + "is_verified": false, + "line_number": 545 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "270492f5701f4895695b3491000112ddc2c1427d", + "is_verified": false, + "line_number": 546 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "88aec41eb1eedc51148e0e36361361a6d2ecc84f", + "is_verified": false, + "line_number": 547 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "7b7d73969b405098122cd3d32d75689cd37ee505", + "is_verified": false, + "line_number": 548 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "79b731de4a4426370b701ad4274d52a3dc1fc6c1", + "is_verified": false, + "line_number": 549 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "5b328e2a87876ae0b6b37b90ef8637e04822a81b", + "is_verified": false, + "line_number": 550 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "8638f4b78c1059177cbfccd236d764224c3cad5c", + "is_verified": false, + "line_number": 551 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "ef285f61357b53010f004c1d4435b6bb9eeaff09", + "is_verified": false, + "line_number": 552 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "ddd64557778a6d44ac631e92ed64691335cf80df", + "is_verified": false, + "line_number": 553 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "de486a7abd16c23dfdf2da477534329520c0c5ec", + "is_verified": false, + "line_number": 554 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "0618c0886736acb309b0ad209de20783b224caa6", + "is_verified": false, + "line_number": 555 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "521ee58b56f589a8f3b116e6ef2e0d31efd4da1d", + "is_verified": false, + "line_number": 556 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "5b916ff5502800f5113b33ba3a8d88671346e3b3", + "is_verified": false, + "line_number": 557 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "7582e85dc9e4a416aa1e2a4ce9e38854f02e8a56", + "is_verified": false, + "line_number": 558 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "b24c1e8ac697a8ff152decc54d028e08dd482e4f", + "is_verified": false, + "line_number": 559 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "923eb19912270d9a7c2614d35594711272bc33c0", + "is_verified": false, + "line_number": 560 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "e0331901bcbebd698248f7ba932083b13144da42", + "is_verified": false, + "line_number": 561 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "f49cc7570d7e3331425d2c1cca13e437c6eb0c86", + "is_verified": false, + "line_number": 562 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "6adbf5db8ff386502f09c1dbb9fa2b37600491a6", + "is_verified": false, + "line_number": 563 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "03060c922cbe09ed17fe632cbf93ed32eb018577", + "is_verified": false, + "line_number": 564 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "71cfee01fe9f254c01da3a00f2b752cf39cbe95d", + "is_verified": false, + "line_number": 565 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "542ef00d5b90d5b9935d54e3c2ebd84c59b7e7ba", + "is_verified": false, + "line_number": 566 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "4073dc551871d96e2b647f18924989272ea88177", + "is_verified": false, + "line_number": 567 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "0a4afe0870fdff9777720cab41c253d7a2a1b318", + "is_verified": false, + "line_number": 568 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "ef7992a75c33f682c8382997f7f93d370996ee7d", + "is_verified": false, + "line_number": 569 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "a265ebf662a7b28aeacc7f61bdb9ba819782fc24", + "is_verified": false, + "line_number": 570 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "2bc27f59373f1a1091eef59a7d9d23c720506614", + "is_verified": false, + "line_number": 571 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "e17be476c0805f05b4445d528ae5b03fa7a13366", + "is_verified": false, + "line_number": 572 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "6b8281ade6ee972b53eb2e5e173068a482250005", + "is_verified": false, + "line_number": 573 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "931c912c0da827ad7895c4e6d901dc2924ef23e4", + "is_verified": false, + "line_number": 574 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "ecf0566d6b6ce6c44f7f8fb56af4a8608e72f5e4", + "is_verified": false, + "line_number": 575 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "947323679dbee5d60736f14258621626565ea1c6", + "is_verified": false, + "line_number": 576 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "05d0d9d4a4e53fa7d7f3f7f8317bec618b1bfe15", + "is_verified": false, + "line_number": 577 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "6b7871d101c02971f1b9f6f95f5a969c36a8483c", + "is_verified": false, + "line_number": 578 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "05441b75c971d39d04a13b168a1b0f2c4aeb2114", + "is_verified": false, + "line_number": 579 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "c9d8088c151b2a7c09676ed3fd9de0fddc490b30", + "is_verified": false, + "line_number": 580 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "07eb4a0a546de02a324550e1e1b66e306bd3f706", + "is_verified": false, + "line_number": 581 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "baa791026849604561c1dd00787a9caa598abae1", + "is_verified": false, + "line_number": 582 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "8d49f6f1c3e27bdfe580816e609cab2c9ca00cc6", + "is_verified": false, + "line_number": 583 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "926d8707e359f80554585f4eca9f90b6021d3327", + "is_verified": false, + "line_number": 584 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "68982f7b9ff005fdd9d27fdf5ef5d37c9c611f58", + "is_verified": false, + "line_number": 585 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "cc95ebd65aeae6dd8e774a1e90798079211554f3", + "is_verified": false, + "line_number": 586 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "a76b151ddad3198ad11b962ff59170a761baf0c6", + "is_verified": false, + "line_number": 587 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "8a59e160326a76b11b5fc26cfa592cfdf158fd49", + "is_verified": false, + "line_number": 588 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "784d839853e3c0966a262a542b36e259aa00e8df", + "is_verified": false, + "line_number": 589 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "fbba9f2d7a916915d9535d71c785ba4491a3b733", + "is_verified": false, + "line_number": 590 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "f290b3c4f8aacf898285d68358fcdffe6baf1e2e", + "is_verified": false, + "line_number": 591 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "14f10baeacada2cc41047108f58b200c6026bca3", + "is_verified": false, + "line_number": 592 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "e583513a87e1f5b242e81fe86427da78faa63ede", + "is_verified": false, + "line_number": 593 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "391f7646f98c7bf123453c90b372ac45f4ea35fc", + "is_verified": false, + "line_number": 594 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "da2e4b9e552f03c36dcf672072f1d6cda917672d", + "is_verified": false, + "line_number": 595 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "9c4a1dc6277cda2374666e447dceb663ac39c62a", + "is_verified": false, + "line_number": 596 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "469b9dfc4d3851edbd0c27f80b4b36c04ec52f5e", + "is_verified": false, + "line_number": 597 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "c09b72b36f9e813bdfcf32f58e070a4fe98f4092", + "is_verified": false, + "line_number": 598 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "6ee9dd6fd0333921cb607f274d3bfc04187bfac5", + "is_verified": false, + "line_number": 599 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "9ccd2b0b5ae426a9c581621270630389e40d08e0", + "is_verified": false, + "line_number": 600 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "881f2e047f571e1ea937638ea2598581e92e4900", + "is_verified": false, + "line_number": 601 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "1e5acdb5b4e970fd7be282ae31e3195d24aa98b9", + "is_verified": false, + "line_number": 602 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "8b1564bd262285220c1f4cc7ba034b14836d3496", + "is_verified": false, + "line_number": 603 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "2f79127d99b576c55a920ce8195d9c871296dd79", + "is_verified": false, + "line_number": 604 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "0aa38b942875102db24b7ce22856fbce4dd8bca5", + "is_verified": false, + "line_number": 605 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "62f537c1449b850f2f3b66c200a85fff4e4ce6c3", + "is_verified": false, + "line_number": 606 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "2f83b93fddaa24f65acbea08be3fc0b2456f3ea5", + "is_verified": false, + "line_number": 607 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "0d3a416a9b47316629342cf32e4535bd5de367bd", + "is_verified": false, + "line_number": 608 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "9d018c03a51c7405ca8de9dafde5fb12bf198544", + "is_verified": false, + "line_number": 609 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "0e20193d744f60ef0bcd425ce45d19c73f5ff504", + "is_verified": false, + "line_number": 610 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "a2ad69c925092acbbffb97ea70f2c87985fccc8e", + "is_verified": false, + "line_number": 611 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "997ad02ee3779b7ffcd11b8e19df0afe052b66f6", + "is_verified": false, + "line_number": 612 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "46bc2f629e8b64d43d23cc3429346583a7319bae", + "is_verified": false, + "line_number": 613 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "10e4c7043154dc91c0a002d88fe23f356370b80b", + "is_verified": false, + "line_number": 614 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "b002194b0535528d6a24fa7502e7f76b935afc8d", + "is_verified": false, + "line_number": 615 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "43728be0f14a9413b4bebd1d22562002cbd07c2d", + "is_verified": false, + "line_number": 616 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "172cb154f89a4168cbbcc48186b6f5a2b113e893", + "is_verified": false, + "line_number": 617 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "1df3a86d99563dd6124a197f28a21f1412fd438b", + "is_verified": false, + "line_number": 618 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "d44276da69dfa1c411354e75dcda7d75ea6d605a", + "is_verified": false, + "line_number": 619 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "39c326b627e45a8ae4192ac750d38cda7fa55d79", + "is_verified": false, + "line_number": 620 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "3c24ec7ee3be457039f1e46a4b437065ba4c4130", + "is_verified": false, + "line_number": 621 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "98b18d68b753e89b1b0c8b4ce575011326b0d2c6", + "is_verified": false, + "line_number": 622 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "95dc0c323f31332cea1b74ce77fe4af9fd0d5c5c", + "is_verified": false, + "line_number": 623 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "cb0763f8b448f29101b230bf3ace6a9fc200be9b", + "is_verified": false, + "line_number": 624 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "f746e396467de57bda19eb1fe555bc43b8773bf2", + "is_verified": false, + "line_number": 625 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "d0878fed2da5ef58888639234936d2df27aa1380", + "is_verified": false, + "line_number": 626 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "3010d3905af38cd8156a527f4d531f34c46c39a7", + "is_verified": false, + "line_number": 627 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "4da40200c07f4e433a8fafc73d0567d024606752", + "is_verified": false, + "line_number": 628 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "5415afc22a2c5f94eabfdadbccbe688b42341335", + "is_verified": false, + "line_number": 629 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "86f3350f28fa5af153e0021bd0f95610f50f0aa6", + "is_verified": false, + "line_number": 630 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "84541393133a5662b9b265024ec3edc3545c3802", + "is_verified": false, + "line_number": 631 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "05830a12efa0b065e55a209e1de1b7721546f2a1", + "is_verified": false, + "line_number": 632 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "9e7dabf3cda36b3ab3b57fefca047d5271cb674e", + "is_verified": false, + "line_number": 633 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "ef05a15dcbe9f43b719bec0f2dc74d6870cab938", + "is_verified": false, + "line_number": 634 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "35c2e8c0d488a1e0e7f4a721cb9fc5af4f91423b", + "is_verified": false, + "line_number": 635 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "e4ad4eb707a0dd2b2ef876c8001f966f51f524d9", + "is_verified": false, + "line_number": 636 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "f99b3161abeffa11c6be076150cccd8221fcd703", + "is_verified": false, + "line_number": 637 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "4b1647cf6264941baa9ba28fb792cd82e06217cd", + "is_verified": false, + "line_number": 638 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "a62b12a0505128c7094f73376a7b32b6896a8602", + "is_verified": false, + "line_number": 639 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "8ac29efbb3b877bfdebdcba31d3528f2cd0809ea", + "is_verified": false, + "line_number": 640 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "1aa7fb76951a195b27333fc8580b44a57e98fa9e", + "is_verified": false, + "line_number": 641 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "3a29474a5fbc845f27b5bafd16ddbb4d7defa2d8", + "is_verified": false, + "line_number": 642 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "b1c3e50ce69aa2cc899da1df5a55338242567ab4", + "is_verified": false, + "line_number": 643 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "841f3550b43d66f5f3138d26990ffbb161a3b827", + "is_verified": false, + "line_number": 644 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "80cfd7fb194ed700b9c0e4970bf4e47cc75257a9", + "is_verified": false, + "line_number": 645 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "bc4508d089cc2186f7bc5bb14ccddeb772a04244", + "is_verified": false, + "line_number": 646 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "01b35bc3e5deb295f2dd6c43f2abae453ed7a20f", + "is_verified": false, + "line_number": 647 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "fa3e9c6424f3bc18eb13d341ed64c132b4f8c929", + "is_verified": false, + "line_number": 648 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "b13663ab4e5621994f9bb7909a69c769c343e542", + "is_verified": false, + "line_number": 649 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "c06f704f3a0cefec9a28623bda60f64f8c038bdd", + "is_verified": false, + "line_number": 650 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "a2eadafda305962f6b553a99abf919d450cc4df2", + "is_verified": false, + "line_number": 651 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "43c8cab46cbb8319ee64234130771cb99a47e034", + "is_verified": false, + "line_number": 652 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "1cc137a3c9d41ba4b30464890ae6a6f08c7ba92d", + "is_verified": false, + "line_number": 653 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "b43d13f2dcc835cd55d4a40733b22d07fd882167", + "is_verified": false, + "line_number": 654 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "78d7945d58ea7aaaf4861131b57b5fd4c308437f", + "is_verified": false, + "line_number": 655 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "6b2f6f1c7b573efc39d8bd013cef20e89e011276", + "is_verified": false, + "line_number": 656 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "d92bdf2e2be4bfe8acb991a3cf2b0f23da624825", + "is_verified": false, + "line_number": 657 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "e8b7c1a13d23facf8589088b2de85f851ad53a82", + "is_verified": false, + "line_number": 658 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "6d3e58158529f32b5ead6e3b94c7ca491ef27ed3", + "is_verified": false, + "line_number": 659 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "800ea2592a27f8b38f0a18253dd49f97b65a3aad", + "is_verified": false, + "line_number": 660 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "0b13798c29f5879b119c807ab7490d35a0342cef", + "is_verified": false, + "line_number": 661 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "0a9a21ca4e9aa08b2b5fbe769bf6afb1deb8da91", + "is_verified": false, + "line_number": 662 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "183877effc366e532c7937f2f62f7f67f299bd36", + "is_verified": false, + "line_number": 663 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "e245782b2f99805ed35dab1350ac78781ae882eb", + "is_verified": false, + "line_number": 664 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "9b619bf6db9561f29c4cc75e26244017cc97d305", + "is_verified": false, + "line_number": 665 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "377469b721f5e247f1ad0fee41cca960c49a1fe9", + "is_verified": false, + "line_number": 666 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "f2cb896b3defe96fd6a885f608e528704b40728c", + "is_verified": false, + "line_number": 667 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "7643925d0ad2652497482352b404604985b0f41e", + "is_verified": false, + "line_number": 668 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "ce5594ef11357e35de0d439687defce446dd0f66", + "is_verified": false, + "line_number": 669 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "65dde318bca6689643335f831444daf0156cc4e5", + "is_verified": false, + "line_number": 670 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "143c3d69803143aa5d40372c0863df82b176b41c", + "is_verified": false, + "line_number": 671 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "c32dcbc4225f3183d5f5a5df78ec5ae9afb38968", + "is_verified": false, + "line_number": 672 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "cfa29e11ebef38d8e08fb599491372f6404e6b6f", + "is_verified": false, + "line_number": 673 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "3d91d5f1054fc768cf87c6b19d005e6d3ccbc2f3", + "is_verified": false, + "line_number": 674 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "2d6bffd0f0c9cc4790eebc50b6a56155c3789663", + "is_verified": false, + "line_number": 675 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "64110bdd2bf084ec47040ce8b25fc13add2318e7", + "is_verified": false, + "line_number": 676 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "7f6bf6522a85f71bf4b93350ec369683759735f9", + "is_verified": false, + "line_number": 677 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "3d53588bd3f314ef6e7bf9806e69872aa2ce1aff", + "is_verified": false, + "line_number": 678 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "d5efc1772557e4bff709c55a59904928b70ffe1c", + "is_verified": false, + "line_number": 679 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "b8e46dd05b23c4127cca0009514527e49b6c400f", + "is_verified": false, + "line_number": 680 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "58d30b123d121316480c37ae6222d755dc9144ca", + "is_verified": false, + "line_number": 681 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "66a2abf99d8a4a38e6d64192d347850840a580bf", + "is_verified": false, + "line_number": 682 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "d434fa5b419700a92dc830da1c3d135e8ad0b3e2", + "is_verified": false, + "line_number": 683 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "ee251356a77d3ec7b7134156818fac73a2972077", + "is_verified": false, + "line_number": 684 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "239cb830c56b6d22115d2905399f8518bd1a5657", + "is_verified": false, + "line_number": 685 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "2e6143570c020503a4e1455ec190038b82bedc19", + "is_verified": false, + "line_number": 686 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "9107d00af85969940a45efb9eccad5e87f8a87f2", + "is_verified": false, + "line_number": 687 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "5a5d1ac75eb4c31c7e9650ac70bdc363a9b612c5", + "is_verified": false, + "line_number": 688 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "05a99938fdc58951b4a6a756c8317050e3f5d665", + "is_verified": false, + "line_number": 689 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "67ccbdebe626ab7af430920c1d0d6ec524bdc4f9", + "is_verified": false, + "line_number": 690 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "71fd81160a50c9d47b12b4522c5c60f2fca72b6a", + "is_verified": false, + "line_number": 691 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "f150f2f043f66a564ed3b3fb2f29c0636fd2921a", + "is_verified": false, + "line_number": 692 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "a1140dfe90f9a5da45451945b56877c45cb36881", + "is_verified": false, + "line_number": 693 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "7533bea169a68e900d67a401cac35a7aade18d92", + "is_verified": false, + "line_number": 694 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "f0dd83a2a8d653ad8b30fefcde5603b98bf1ca66", + "is_verified": false, + "line_number": 695 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "21334df57a3a5c6629c12f451eeb819a2b37b42c", + "is_verified": false, + "line_number": 696 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "99f04da5b8530b3eb79e3740fece370654d3c271", + "is_verified": false, + "line_number": 697 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "c2dfd7c77cafb9193a0e77a45d14ccc1498816fb", + "is_verified": false, + "line_number": 698 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "5351e6405ba12ea193b349e8b2273201bb568404", + "is_verified": false, + "line_number": 699 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "cc215cb1a47a674d2b0c1fb09df87db836ce8505", + "is_verified": false, + "line_number": 700 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "3078af7fa82e149420b97ff56fff9f824387b35b", + "is_verified": false, + "line_number": 701 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "ac0e1537926b5bbd543ad3e731959a0bad451c73", + "is_verified": false, + "line_number": 702 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "a6da4e82d314f4ca0bf7262a78875b0b6edc30aa", + "is_verified": false, + "line_number": 703 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "e08c74c3fbf412c2d4f330b0414f1275679cb818", + "is_verified": false, + "line_number": 704 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "7bf9ae1b766cb0b9a5aa335a0103518d7be00daf", + "is_verified": false, + "line_number": 705 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "ec844560c5f208fa8723c1700f6e86b8e7ffed04", + "is_verified": false, + "line_number": 706 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "6c133b025f53327eb652d2a1ca576dfe58eef1b4", + "is_verified": false, + "line_number": 707 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "3dc21b9f6f63b73a241d900e379a3c7094341f8b", + "is_verified": false, + "line_number": 708 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "1a012b2bf61ee9874d5af73df474051c0d235ecf", + "is_verified": false, + "line_number": 709 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "b0ebf0b521ec6e6e696f9be2fe4e1845876d57ab", + "is_verified": false, + "line_number": 710 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "f0a5d3ac0705186e25effb02649df87361b8c67e", + "is_verified": false, + "line_number": 711 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "385ecb845a1d5d43766d568b466d1dd237a81980", + "is_verified": false, + "line_number": 712 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "18d0416b8ea44ce305b214380de978cef27e8603", + "is_verified": false, + "line_number": 713 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "89dca45aa9146b8a31236fd77001c02769dceb60", + "is_verified": false, + "line_number": 714 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "30acd4c1f4a878883c654846b8f3c5a6ab807285", + "is_verified": false, + "line_number": 715 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "7d3229ff5e754c72a8b2072d3d7a5e00749ece9b", + "is_verified": false, + "line_number": 716 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "e6da9d65dc0cfb42b86ae8f9b7c1d5fe79b4a763", + "is_verified": false, + "line_number": 717 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "9c85908a1bfd5f2a7337f812c68f2ce8dfbfd65e", + "is_verified": false, + "line_number": 718 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "4000341e5c04854eeca9fe7537dfddfdbb7c785a", + "is_verified": false, + "line_number": 719 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "ef23e2969a46edf410fab2c69d1b29b2a65f57f9", + "is_verified": false, + "line_number": 720 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "4902863163e24fa9f172e61808385de2b9ee3099", + "is_verified": false, + "line_number": 721 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "31efc8d3bba9c8f66b3f54bc146443732ac15c2c", + "is_verified": false, + "line_number": 722 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "263deaf83b359554fc9dafca8e6622ece44cf75d", + "is_verified": false, + "line_number": 723 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "ead7409fe5b86813e3609f7fe6e13b8fc4b0b9d6", + "is_verified": false, + "line_number": 724 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "7b0d884d6cdc64a613cf3e887395d875ff738c3e", + "is_verified": false, + "line_number": 725 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "fa0a0a999cb067eee81673f3d2de8bfd96a0d14c", + "is_verified": false, + "line_number": 726 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "0db684d862dfc8427e8f66adb62f33fcdc9f3de8", + "is_verified": false, + "line_number": 727 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "8794a8121832fd31b1871d2c5d4b00af07779b0c", + "is_verified": false, + "line_number": 728 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "d6070805e7a6c25dbe13a540cbc0f16a89055e7e", + "is_verified": false, + "line_number": 729 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "56b3e8e6d14b9b459bf055900784e8aa31c306c2", + "is_verified": false, + "line_number": 730 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "a4d6976637c19991da48707bf35b3cf2ded4c2fb", + "is_verified": false, + "line_number": 731 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "f714e448a86a46baf2128d81014e554874f0d4f6", + "is_verified": false, + "line_number": 732 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "2b03a5eb51085de41df415881ef1d425f20f9e05", + "is_verified": false, + "line_number": 733 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "99fa7285e15d91ac3047b95ddb475d339c7afc7b", + "is_verified": false, + "line_number": 734 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "4a9880aa478dba526c2d311ae17578711d0f9426", + "is_verified": false, + "line_number": 735 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "0cd512ccf176189c7bf36765b520d8ec2ddeade0", + "is_verified": false, + "line_number": 736 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "2eb8822459b9db479752d12f62dec094ab68fc55", + "is_verified": false, + "line_number": 737 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "1aab694ebb334a12ccd22baa0044a3b058db67f9", + "is_verified": false, + "line_number": 738 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "ce29f8616e1c62e54a8f0b39b829d9bd7df5721c", + "is_verified": false, + "line_number": 739 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "c099a1c5f639e647bda5961d9c51cc158790ff3e", + "is_verified": false, + "line_number": 740 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "78dc2b71e3614e4e802c4f578a66132ea1ae0be8", + "is_verified": false, + "line_number": 741 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "0befb6d3255080ce4d051a531fc1fedb33801389", + "is_verified": false, + "line_number": 742 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "087447f269677e0947da157a5bc0bb535c6c7759", + "is_verified": false, + "line_number": 743 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "8911e3aef563e1481305a379a083f7616d57cd08", + "is_verified": false, + "line_number": 744 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "2846a4bb4af2826a787fb0d8a0e7342c404a1cd1", + "is_verified": false, + "line_number": 745 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "3364317b783250007fcee5bcddf07b2006752ad3", + "is_verified": false, + "line_number": 746 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "e1a4444540434bc0ba51a8b5e6540e82d4b17f4f", + "is_verified": false, + "line_number": 747 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "f453d1221dfbe308b5c71029f5cc2fba020f2c6a", + "is_verified": false, + "line_number": 748 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "3e4231678403aa61b0f4f6719081016d579fa3e4", + "is_verified": false, + "line_number": 749 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "a64b90a0dd1a214d6c65a4078437eab4ada65a32", + "is_verified": false, + "line_number": 750 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "0433fe0f97f7a354a3ed06d6a8a77c2f1983f947", + "is_verified": false, + "line_number": 751 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "a21195a2dde808b7cff35695396ecf7699125a53", + "is_verified": false, + "line_number": 752 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "6547a05519f26198981f500b703d36443958ad14", + "is_verified": false, + "line_number": 753 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "fbb8441f5e8e9b911cc42a025c856470784d89d1", + "is_verified": false, + "line_number": 754 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "6378293ead806f554612c82fddf04ea8fb1ab2cc", + "is_verified": false, + "line_number": 755 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "3272309f5c986a45cd892d943c5bd5af5165ad70", + "is_verified": false, + "line_number": 756 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "1c79d15ecac42472241726cbae8d19bb820f478b", + "is_verified": false, + "line_number": 757 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "a868da324435f3b1f32bc12bbd3171e9d62fcdca", + "is_verified": false, + "line_number": 758 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "c56de5d2c763355c7a508dec8c7318e0c985dfec", + "is_verified": false, + "line_number": 759 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "258e19436174463d0e1b8066eb8adfbf79f78b32", + "is_verified": false, + "line_number": 760 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "112d96e04bf661b672adc373f32126696e9c06fe", + "is_verified": false, + "line_number": 761 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "bdeaea4ca3484db9e8b0769382e1ba65b62362b3", + "is_verified": false, + "line_number": 762 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "fff367064d95bace4262a1b712aa5b6fb2a821d6", + "is_verified": false, + "line_number": 763 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "e16dcae490d17a842f5acd262ca51eae385fb6af", + "is_verified": false, + "line_number": 764 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "bad941c81722b152629cebce1794a7fd01b85ebc", + "is_verified": false, + "line_number": 765 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "65e6aaaad1727c35328c05dd79fb718d5b1f01ce", + "is_verified": false, + "line_number": 766 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "b7ea9b9d7d8c84eeeb12423e69f8d4f228e37add", + "is_verified": false, + "line_number": 767 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "42bea72c021eedb1af58f249bdae3a2e948c03fa", + "is_verified": false, + "line_number": 768 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "1ddcb2cad21af53ad5dd2483478f91f3c884cea0", + "is_verified": false, + "line_number": 769 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "e72ad6e31d1a19d6b69a1a316486290cb2c61eab", + "is_verified": false, + "line_number": 770 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "8ca884c8fb24ecd61300231b81d1d575611cda07", + "is_verified": false, + "line_number": 771 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "5754688edbb69be88b9c0ea821cc97eada724c14", + "is_verified": false, + "line_number": 772 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "a267e65960056589647f075496fd3a6067618928", + "is_verified": false, + "line_number": 773 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "ad3424f420bf25442aa9df96533852d29eac12a9", + "is_verified": false, + "line_number": 774 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "8a5a26db2b7bda6268a9250808256e08d2a62262", + "is_verified": false, + "line_number": 775 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "ff90aa934268bd629b33708b7db9a10b5f0bf822", + "is_verified": false, + "line_number": 776 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "9294697fb9b36decacc26c3c33c3d186fc128f82", + "is_verified": false, + "line_number": 777 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "8dfc552d4f52ed53ccb13c958117ceba6c8038d8", + "is_verified": false, + "line_number": 778 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "49c6467fa09d3052faaa1a369ebd226234db892d", + "is_verified": false, + "line_number": 779 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "f2a450ffba5b1fdb7f016e4add7035ef6ba2df77", + "is_verified": false, + "line_number": 780 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "79a4f5a8804b9a94b5c4801700f08a2cdef54662", + "is_verified": false, + "line_number": 781 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "1baf161ffff392357bbfb8e38d95c8c2f79ef6a2", + "is_verified": false, + "line_number": 782 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "840365ccbf5f23b939e8ee15571bdb838a862cb3", + "is_verified": false, + "line_number": 783 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "0e50db71a57f0d0016b2abeaf299294c3bb4fedb", + "is_verified": false, + "line_number": 784 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "b108976e96b8ce856b59b4f73cc6caa2555310cf", + "is_verified": false, + "line_number": 785 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "474f1a83c946ec223093d46f5010ff081f433765", + "is_verified": false, + "line_number": 786 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "3740691aa3a788e71b7b74806dbcae3009b4f7fb", + "is_verified": false, + "line_number": 787 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "c11bddda98ea121b857aabafbcdf75307a18bc45", + "is_verified": false, + "line_number": 788 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "3445e70b7f8f3d381c21f6ed88c28c0db545662e", + "is_verified": false, + "line_number": 789 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "c368482da3144e79d4f4f8063bdcfc85b1318ca1", + "is_verified": false, + "line_number": 790 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "470e734260c3e67dd19fca5ef32dbc6ce863dcbc", + "is_verified": false, + "line_number": 791 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "0dc9bbedd1b90674d2d0c81563b1b59e82f901b6", + "is_verified": false, + "line_number": 792 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "49bbe143a0a5d2d81eaa04b0ae5f02b89b2e60ce", + "is_verified": false, + "line_number": 793 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "9e009fcc53e8ae16ac2cd1c31945812a8b3cb1f8", + "is_verified": false, + "line_number": 794 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "fda8ab7b8d8d0e3d995648f21cb97fb6a4371008", + "is_verified": false, + "line_number": 795 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "15ca6383ad968b3f606e5600e0ee5765cc61a223", + "is_verified": false, + "line_number": 796 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "c901600adaae1fae9b24fe869cc11364e07651c1", + "is_verified": false, + "line_number": 797 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "2a6968448cc0520a44b0fc8eac395ef9047a0ba9", + "is_verified": false, + "line_number": 798 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "e58e1397cdedc8cedfc10472af62b0e24b7d90bd", + "is_verified": false, + "line_number": 799 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "3f1a00fc8f814e6e5bfbb1b38a44318af25c0149", + "is_verified": false, + "line_number": 800 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "23887318ac83e9f3953825ada42ec746364c362a", + "is_verified": false, + "line_number": 801 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "c5ebf6b1cd6af76112bb20fb2ef8482bd95088fe", + "is_verified": false, + "line_number": 802 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "7f2b7465a347061ef449ed6410a3fccb7805775a", + "is_verified": false, + "line_number": 803 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "35c7486eb3aab3d324e34c9f2e4149c0833e7368", + "is_verified": false, + "line_number": 804 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "6bafab58fdb0248c4e31eb58b8b99d326a5fec77", + "is_verified": false, + "line_number": 805 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "b5b8f84bebc143026521dd3dec400fc319c8f07f", + "is_verified": false, + "line_number": 806 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "dc663ea73f635724beef79b22fe7c40bf812907f", + "is_verified": false, + "line_number": 807 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "a5f5ebcab108b702af3122c9dec85e4aed492ba1", + "is_verified": false, + "line_number": 808 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "24826ebb519bed6f61af4c6dc3008fea3ca87c62", + "is_verified": false, + "line_number": 809 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "f5e2d1ee2fc9d16703269c4942a406effa9208ae", + "is_verified": false, + "line_number": 810 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "f28e36af3d92643a5ca738f66b0f9b0f0906a02a", + "is_verified": false, + "line_number": 811 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "19c8b107d6fdc4b807d831334b433ba0f051ee3d", + "is_verified": false, + "line_number": 812 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "fd640c778ecdae75e71f490588436bad8551dc0c", + "is_verified": false, + "line_number": 813 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "b93f3e5a8f7937290e368015ec63b9faa148a091", + "is_verified": false, + "line_number": 814 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "b665cd0e94b8b690e5edb8446039bc20bd4edf8f", + "is_verified": false, + "line_number": 815 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "e3482306ec339930b1f4d60e13c4006b9ac9949d", + "is_verified": false, + "line_number": 816 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "a2c8590320283074b40e9c0f05af26ac1671580f", + "is_verified": false, + "line_number": 817 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "e30ee01ef2baf677c7592e2a339d1d4c5f3b3053", + "is_verified": false, + "line_number": 818 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "b8495b9cd806dbee2e7679dc94c9ca6b675107af", + "is_verified": false, + "line_number": 819 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "b175eb842c0cb4c4d2b816c80b2cfea2b81eca04", + "is_verified": false, + "line_number": 820 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "7cca142d68498553dd9cd10129b64f8f6b1d130d", + "is_verified": false, + "line_number": 821 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "62709b572d8c7952674f5ca8c807aa12346d8219", + "is_verified": false, + "line_number": 822 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "260d9d5da81fc235a36890dc1df9b0b93e620051", + "is_verified": false, + "line_number": 823 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "f45c83b63c8fb4ee062a5649950ed25963f72269", + "is_verified": false, + "line_number": 824 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "94ab5caccdc141879f89dff48b17d633cce7c6ae", + "is_verified": false, + "line_number": 825 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "8a67f56357e2ab075ec362aa17de81e09829dd1e", + "is_verified": false, + "line_number": 826 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "e47ea7fc498253e920531b2f9440df22b65b4bfb", + "is_verified": false, + "line_number": 827 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "608bda7f1c9bbb04cbcd94fbef60907b34e5107c", + "is_verified": false, + "line_number": 828 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "0ef4f672781b0c8008104b4833da99758a37c2d5", + "is_verified": false, + "line_number": 829 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "b84c442c7f733ee0416ab3e451b3acd4fe708d11", + "is_verified": false, + "line_number": 830 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "af40c42cfab503d271744c98fa2d912f75fe1192", + "is_verified": false, + "line_number": 831 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "088fb0ba102fd16911bc92ecad1e96d6b9d7c6e1", + "is_verified": false, + "line_number": 832 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "0205ce524bdf9689abb764ade3daff0a75a9680b", + "is_verified": false, + "line_number": 833 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "ffb06eac178944f7cd519dffee1bce92b7b39de0", + "is_verified": false, + "line_number": 834 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "1f4fec8780ce70e3b189b9ef478d52cb508ab225", + "is_verified": false, + "line_number": 835 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "2084a2c1c5c015caab2036e77747bc1bc8da1b5b", + "is_verified": false, + "line_number": 836 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "6d61e0dc6e9e3786a038ce41b2645ffa55ad34dd", + "is_verified": false, + "line_number": 837 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "c2eedfdfb494f1da2837db4fe02a349f6b83e34b", + "is_verified": false, + "line_number": 838 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "cb90f645f60eb596ccd816c2c9cad6df1da2f7af", + "is_verified": false, + "line_number": 839 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "3714fb2f7dd6cc5392456fa413a7a6ba3cceca16", + "is_verified": false, + "line_number": 840 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "a2b9353093261900009e92216ad07fb712d3aeef", + "is_verified": false, + "line_number": 841 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "38abeae07fcc9d78f57c915f7ec1ef448928c8d7", + "is_verified": false, + "line_number": 842 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "4aab4807666815ca001aecb2c98150fa4e998a4e", + "is_verified": false, + "line_number": 843 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "a3c2b5f078ce6bd677972296a39a9b6f476ad8fb", + "is_verified": false, + "line_number": 844 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "76cb76a7b46fbebf5a3d38b4f7507f5f6f966bbb", + "is_verified": false, + "line_number": 845 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "6216237ea7f4271573ad9257b04f29624b32d067", + "is_verified": false, + "line_number": 846 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "c46a24ae59ed9570cd0eaaf744cbdac682131822", + "is_verified": false, + "line_number": 847 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "c7f4bfd365cfeda78938b48c174e84c476e0b121", + "is_verified": false, + "line_number": 848 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "95306491cf2bf602d32f153877fa3668188e89e5", + "is_verified": false, + "line_number": 849 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "0a86977039aca715fef41f075a006d08913e2f9e", + "is_verified": false, + "line_number": 850 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "98ab4de33fb607da8c4bd3e6dcde7fc48be461cb", + "is_verified": false, + "line_number": 851 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "c8a681b8468ceb7be04c81c9531fc1b76a73a979", + "is_verified": false, + "line_number": 852 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "c1f2b4dc85c69f47bab7f0c95934abeb21241dfe", + "is_verified": false, + "line_number": 853 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "d2c65d95022c1689e545f27bdb9125abfa65014a", + "is_verified": false, + "line_number": 854 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "5334888b103ace2ac1628b453dfba0374aa21563", + "is_verified": false, + "line_number": 855 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "db870d53e2dbee8610b39a18017bf2e95d9b6a1d", + "is_verified": false, + "line_number": 856 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "a874dd47f5e9d721212644df27395f9d0455bc7b", + "is_verified": false, + "line_number": 857 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "24304e79b441e1689f7db990cf1380e8ea172237", + "is_verified": false, + "line_number": 858 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "ed52cda8715ae3d4b24fdea5e451cf0610003eb6", + "is_verified": false, + "line_number": 859 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "8b5757852d0c36e7217daf8504004e6c85212d7a", + "is_verified": false, + "line_number": 860 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "85d089a4858f5681d1828bc1d67eb3f19bbeba6f", + "is_verified": false, + "line_number": 861 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "80dbb757c0b7fb948816886168d397b09b317e0b", + "is_verified": false, + "line_number": 862 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "a45b519f89630194e67ed91782425b2095083fcb", + "is_verified": false, + "line_number": 863 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "297a0f9e38f85884d7d6beb518b33f8f35349004", + "is_verified": false, + "line_number": 864 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "2200c973aaaaa2f1201604176787152091904d25", + "is_verified": false, + "line_number": 865 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "07d4fef177f006578f4d37289137d90727a5fa86", + "is_verified": false, + "line_number": 866 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "d68f0a891f53a354bff2a9002ce0e3c60236d0fa", + "is_verified": false, + "line_number": 867 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "d101c2cdae39ce8adcf30a777effd4be14b07713", + "is_verified": false, + "line_number": 868 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "7e5670956a5ca012cbfe2ec89841595ada7ffc4a", + "is_verified": false, + "line_number": 869 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "d58782068176eeb0987b1850ec9b1e54764c5947", + "is_verified": false, + "line_number": 870 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "d779f72f04dbb76344f4c264d19bba7242e25e90", + "is_verified": false, + "line_number": 871 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "99c57a64facfebfb9e41dfae591af95633715986", + "is_verified": false, + "line_number": 872 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "a7a97bb3f0508c2ed46ad81ed8cc53ff7469edc5", + "is_verified": false, + "line_number": 873 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "c8b289fb0554107bbd07c43f462a87e7b929a529", + "is_verified": false, + "line_number": 874 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "3c092d1639246d4ce9167319e729dc39d1bb3793", + "is_verified": false, + "line_number": 875 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "c34cc18e2fb77269d8f33529c23d4ae2a55b873e", + "is_verified": false, + "line_number": 876 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "57562f3034b2895272567bccdb4476ff4ffb387f", + "is_verified": false, + "line_number": 877 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "e75aa06fcf9eb16ce4f765009f73bff5998b4d82", + "is_verified": false, + "line_number": 878 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "561dd2c1798724b1f7730df97cf07b16f27db369", + "is_verified": false, + "line_number": 879 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "548d01127e6414ebc307a1da07e1814eb28d9c43", + "is_verified": false, + "line_number": 880 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "d356fdfdeab6a77435a395a60e99e988f3c7e85e", + "is_verified": false, + "line_number": 881 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "7d850865aadf5851746b420805c2d1a859af11fe", + "is_verified": false, + "line_number": 882 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "a2221c705b602dee5ab23632133b47700d5a1dd2", + "is_verified": false, + "line_number": 883 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "0d4e54941ee10299f1064634fffb86e4b7bfd005", + "is_verified": false, + "line_number": 884 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "589f88e962e41fc2e6691090dc335a20c7520348", + "is_verified": false, + "line_number": 885 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "0d9ea7340e4afb03c7564f911883428d4d0e5e01", + "is_verified": false, + "line_number": 886 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "86525dece15cc1ed811c029ebae7ce496af598aa", + "is_verified": false, + "line_number": 887 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "f3add200e410ee751ec2e65f4c00d5fe546a2b46", + "is_verified": false, + "line_number": 888 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "89588ee266a0fee04980b989461d344c91f917cf", + "is_verified": false, + "line_number": 889 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "c02f12006740778cceb3e14d10eef033650f0905", + "is_verified": false, + "line_number": 890 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "16d1c52b661852a0a2d801d14e5153cd2245854a", + "is_verified": false, + "line_number": 891 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "bd48b759e75395bd491df6811d82ada954b1a8f8", + "is_verified": false, + "line_number": 892 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "f9d8d2bcc1f978b39c12409b8bd5c35e1fd3caef", + "is_verified": false, + "line_number": 893 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "bd7006183d8fc08da5a29edc7dce2833b7d67c29", + "is_verified": false, + "line_number": 894 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "b4f7d597cf8d0e4a8bdd47b462ffaf7f753906f6", + "is_verified": false, + "line_number": 895 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "10d3f4cb2e16143374e3db5c6828184d97cef711", + "is_verified": false, + "line_number": 896 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "6045891b6aed86c8d19a6aecd12b2df1a32e3921", + "is_verified": false, + "line_number": 897 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "f09ecd7a19945614bd73b5be04331b691d2bc030", + "is_verified": false, + "line_number": 898 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "f0cf1445d72e773713d17ed9ecbf6f805206cc80", + "is_verified": false, + "line_number": 899 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "34cba93b5c522de558e25672a78a5d75028a02fc", + "is_verified": false, + "line_number": 900 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "b08833d65be532022a038652bffe2445f840479f", + "is_verified": false, + "line_number": 901 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "ed24a43ca6ed9df8d933b25418889701bdf1492d", + "is_verified": false, + "line_number": 902 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "f081d33d1093e834b3fe9e678720c07c7dfbaef7", + "is_verified": false, + "line_number": 903 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "fbd0b56627efce28202a4ebc927ed09fb338cf24", + "is_verified": false, + "line_number": 904 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "8f79ecdca6ff2d1240ab55db0395f3babd8e0cd7", + "is_verified": false, + "line_number": 905 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "0d42925b4018649775d5543b6e5ccd1096eea954", + "is_verified": false, + "line_number": 906 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "5564f26e8a7f58c2e525d04261557b54ccb3eeae", + "is_verified": false, + "line_number": 907 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "7e61f7e6fbbccc54b49c5932dfee56e4d05d8bb6", + "is_verified": false, + "line_number": 908 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "d28c82f5235be5773d7b556004493d197863e47e", + "is_verified": false, + "line_number": 909 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "ead7a2d8ba1098da1203103338f6077d384ec789", + "is_verified": false, + "line_number": 910 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "57b73b00541a671b1c0f9b49b1a5b9b6d43e386f", + "is_verified": false, + "line_number": 911 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "00d3ba478bd4e0005ba325c0fa3bbb80969a4072", + "is_verified": false, + "line_number": 912 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "63497e9fab38614d05946c0b9dd1338983132696", + "is_verified": false, + "line_number": 913 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "bf7915a186cac89cbf27b479b4318af45d334f3e", + "is_verified": false, + "line_number": 914 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "9e5791210452015df2676f6a7706415ad7c8149e", + "is_verified": false, + "line_number": 915 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "149a819c93748d871763fdd157fbf2c93fcff33d", + "is_verified": false, + "line_number": 916 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "5c0e33a6cdc2bcfa911e665929ae524093e8d4a8", + "is_verified": false, + "line_number": 917 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "0a04734c82ec76181682c537a590934fbe46fe44", + "is_verified": false, + "line_number": 918 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "fb96412139d649dc332fc596841dc2d7543a09d3", + "is_verified": false, + "line_number": 919 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "c48b721469472686b78de0db8d34ccfbe5113804", + "is_verified": false, + "line_number": 920 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "7c832e5288c3cd8f714e3b57d31c7fe05ad0b98b", + "is_verified": false, + "line_number": 921 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "58383e090cd1cdfdbd494f46d533d7be96c3d16f", + "is_verified": false, + "line_number": 922 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "964063ef09c1114c0b89c4a8bdc6fb9a5238b75b", + "is_verified": false, + "line_number": 923 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "0f70be8ee00fb5491a86ff2b185e193bed8147d2", + "is_verified": false, + "line_number": 924 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "eade9c861e70446d1a4057306ea14bcbb105515a", + "is_verified": false, + "line_number": 925 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "645a4a4787c20dbf7d23af52b6b66e963a79701d", + "is_verified": false, + "line_number": 926 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "952b79bc3f47f661ffd882f2cac342d761c7ee89", + "is_verified": false, + "line_number": 927 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "325ae8750d58cb76ba5b471c776b575c6dd8f7de", + "is_verified": false, + "line_number": 928 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "c848e0ebbd67aadd99f498bf457fe74377e2dee9", + "is_verified": false, + "line_number": 929 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "938a394aacb5f28860f2a21dc11c2143dfda6609", + "is_verified": false, + "line_number": 930 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "6f7cc320c863e5e4d854df9f1d9343408b316152", + "is_verified": false, + "line_number": 931 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "bca601976f824d572c9829820d04ef78f0aa89f2", + "is_verified": false, + "line_number": 932 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "8f436a87f64990bcc5bba342e4614ba240cb4001", + "is_verified": false, + "line_number": 933 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "3c41d19e585a5d6932fbedfe9a9970b2be5be662", + "is_verified": false, + "line_number": 934 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "11c444922d1367a8d844b4f265dd34234145b4e1", + "is_verified": false, + "line_number": 935 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "4b5b8766a87bdfe9e72b205635cf3202579c294e", + "is_verified": false, + "line_number": 936 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "a8c32045952ca987aa668c54161b8313d4e27d06", + "is_verified": false, + "line_number": 937 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "7280d2d3abaeaa0b8c09b30184cfa8e9d96f16f9", + "is_verified": false, + "line_number": 938 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "d353aeb68a062440b13bc25906bc19450808c33f", + "is_verified": false, + "line_number": 939 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "c06ff020b6c003435cd543d7c094df946d5cee8a", + "is_verified": false, + "line_number": 940 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "6c846e552b2bae1eb5fb1ee603bd35dbcf43f8e1", + "is_verified": false, + "line_number": 941 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "9526db9835d636a82d4c7843dcb4b1a97f0cd41a", + "is_verified": false, + "line_number": 942 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "c0d1d341758862cd2d243425d7e0e638ccde2be9", + "is_verified": false, + "line_number": 943 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "168f03ae12ec1b265302c9be39275b3ff886f0ba", + "is_verified": false, + "line_number": 944 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "d4431e65831239ecb46c60b109b3cdf3d90413e4", + "is_verified": false, + "line_number": 945 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "6065a318efbc35fa8bfa8179ea00d139aa8ac5f8", + "is_verified": false, + "line_number": 946 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "ca8eb4ab2a13fd9c8009f64e9a57a9698da2af08", + "is_verified": false, + "line_number": 947 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "076d36e09e412d1baffcfe20e235b32e766d9d37", + "is_verified": false, + "line_number": 948 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "8a96b1bb17e8fc8048721963a8944f194e0d6383", + "is_verified": false, + "line_number": 949 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "036334bc532f791df9f17a922a6b282468e3a32d", + "is_verified": false, + "line_number": 950 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "2e9e4798ee11ce742834d80c2103c846b8a7daa8", + "is_verified": false, + "line_number": 951 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "b34309d4e552ffa204cbf7632dd06376f7cfe925", + "is_verified": false, + "line_number": 952 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "eb323c2dabc2fe8fe9d73e355e24554f45a097ef", + "is_verified": false, + "line_number": 953 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "eeb750c5480e76e5b075a1cc415007182d5a84a5", + "is_verified": false, + "line_number": 954 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "baa82df8fe62f21e4a9bd056515d279b5f4bf296", + "is_verified": false, + "line_number": 955 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "7ed197e47d75c92a2bb9fa469ce2584338ae7978", + "is_verified": false, + "line_number": 956 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "eacb84eb412e97afee8329c534ea5822025d2f34", + "is_verified": false, + "line_number": 957 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "1a7e7d49835c298874d24cf9434a7c249f71811c", + "is_verified": false, + "line_number": 958 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "71124a16113f0bfca8f71090445ea96115e92c3b", + "is_verified": false, + "line_number": 959 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "eb6fed65dc17090a731ba790be1c1e913ed43696", + "is_verified": false, + "line_number": 960 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "ff488edfba52bda0a9d4ef548f4e848e1bc407c1", + "is_verified": false, + "line_number": 961 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "d58ebcc9017888fd12d9eee6a1dbb7a1e5d8bf72", + "is_verified": false, + "line_number": 962 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "4db9b98c3dc42567e08ac91e4658c7774eacfddd", + "is_verified": false, + "line_number": 963 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "e91ea43a53d83fb4b47e5769b7db51e4f1c0a333", + "is_verified": false, + "line_number": 964 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "b8768444a059004aa7d50c73da0c7665e774c8b7", + "is_verified": false, + "line_number": 965 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "52af7be744b7e8e3c9d75db11b3de31693313573", + "is_verified": false, + "line_number": 966 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "169a53ab3aa86b11c6a4fb5064b2cab7b64d260d", + "is_verified": false, + "line_number": 967 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "6c29925cd018548844c1b174a4fad45f39ca4d3b", + "is_verified": false, + "line_number": 968 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "793d9bb0e0d7f5e031e367587ecb877881cdd56b", + "is_verified": false, + "line_number": 969 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "709969f024af92b318a5dc3a0315a66c2a024820", + "is_verified": false, + "line_number": 970 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "6c66657d4bd785b7c16df241260cd51f8d7e7702", + "is_verified": false, + "line_number": 971 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "54330bf419e7174ab210ac03a0b26bdbb50832e3", + "is_verified": false, + "line_number": 972 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "02bbbfc42d316c59297fe15109e17447512bc76c", + "is_verified": false, + "line_number": 973 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "446f08aead8d20df9ee177b4ee290303cbbfc348", + "is_verified": false, + "line_number": 974 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "9b47bd9a70c30307c89348cf7044e66b8eeb604b", + "is_verified": false, + "line_number": 975 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "16799c910c44755b0c3ffa38c27e420439938bb8", + "is_verified": false, + "line_number": 976 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "cfba338d2d1c6c8ee47fd7297eae9e346ef33d2c", + "is_verified": false, + "line_number": 977 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "42f730799ccc5f4e3f522abf901ce4a7872f4353", + "is_verified": false, + "line_number": 978 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "5669611e63657e7b6d5f10aee1fe08837577dc99", + "is_verified": false, + "line_number": 979 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "8b8a1180371e560308a4b3bcbf7d135e4fdce66e", + "is_verified": false, + "line_number": 980 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "b5b25fad7a60d76bb8612fe1fe7f4114134b7fe1", + "is_verified": false, + "line_number": 981 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "7268358632fc15cc97395c23ac937631427a06da", + "is_verified": false, + "line_number": 982 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "77b14302acab126de73e1960951b4d8862f8996b", + "is_verified": false, + "line_number": 983 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "a9f98d55aa73cddda74d878887f9cf7c91ed9622", + "is_verified": false, + "line_number": 984 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "7c0abf324bb40af2772baa72ec9eb002674b972d", + "is_verified": false, + "line_number": 985 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "ecd7751d16ed66ffbccbc3bc0cdc6767e85c9737", + "is_verified": false, + "line_number": 986 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "1829e0ea8aa97dd1c07f83877af61079a0420f0a", + "is_verified": false, + "line_number": 987 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "246e88cdb42b377333a3fb259ca89b8f2927c9f6", + "is_verified": false, + "line_number": 988 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "70c184cc1ba36cc336edff03d3180e16a7b6a8c8", + "is_verified": false, + "line_number": 989 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "f3e0f3c62ed74ee4c701d70dbfbf5825e9b153e3", + "is_verified": false, + "line_number": 990 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "fceabb5893c16c83a2f75e44a2c969cb6bff4c70", + "is_verified": false, + "line_number": 991 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "dd14309feb249e827dba5ced8ac68b654e7db8cf", + "is_verified": false, + "line_number": 992 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "9f675a535ed79052f233c3b6f844eb96368d2d4f", + "is_verified": false, + "line_number": 993 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "0e0d26feae012efa3585e895b6fa672005c3434e", + "is_verified": false, + "line_number": 994 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "42a18905f6b1ba2fa6cda2c3b08b43059503926d", + "is_verified": false, + "line_number": 995 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "960330eaa639a3374f20fb3bb1d33c3cb926f9cc", + "is_verified": false, + "line_number": 996 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "c676ae0d67843480085f4544a475ccec95b1c942", + "is_verified": false, + "line_number": 997 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "05a62b604c1187eb336526d03642a7c46e6727c3", + "is_verified": false, + "line_number": 998 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "cde1211319f593ead3f23c0fac4f0ab48866f5da", + "is_verified": false, + "line_number": 999 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "7d12d1e4865212b188c6aefd69096d4f6df8d113", + "is_verified": false, + "line_number": 1000 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "58c2087994575f810e6fb07f476718ac01436189", + "is_verified": false, + "line_number": 1001 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "b9b320c5cd52c63f2c7d8df9f7eb8d7ae97ea0c9", + "is_verified": false, + "line_number": 1002 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "94ade2ea50c865df9827f975b66b0ed87f6196b3", + "is_verified": false, + "line_number": 1003 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "399c06fffa9278491e56e25312b94398408888b6", + "is_verified": false, + "line_number": 1004 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "f20cde564b4b5821671912b7c6a87f2955fa42e8", + "is_verified": false, + "line_number": 1005 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "6f320defd3068726e899c9764628473dfd3552bf", + "is_verified": false, + "line_number": 1006 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "2e1374c55dbeb0c445b7cebbcf13b2258776c08b", + "is_verified": false, + "line_number": 1007 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "60d220a965d81b4d93238d90e5f9f6a8cfe4ee1a", + "is_verified": false, + "line_number": 1008 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "b6b4a1a8971608d6c5f4612efb7b811612fab847", + "is_verified": false, + "line_number": 1009 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "54d103be76f6e12ddfb2d277d367ce2e78d41c5b", + "is_verified": false, + "line_number": 1010 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "65de6ec76c0fb7685c47bc8c136b9f8e35187a14", + "is_verified": false, + "line_number": 1011 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "3e507308114a34a5709c1796bc43132539ecc410", + "is_verified": false, + "line_number": 1012 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "6b2d7139a0eb9228a3ee9cce0808e1f8a8790e82", + "is_verified": false, + "line_number": 1013 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "7a6e781d3ddf14c6314ee3329b8fec94fb15c29c", + "is_verified": false, + "line_number": 1014 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "fee4d49183e2b79df72990acf34d147d86b65df3", + "is_verified": false, + "line_number": 1015 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "6f0633cbd3640e2b979a8a1516c9bd394da76fe5", + "is_verified": false, + "line_number": 1016 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "711980892808cca786860a2790796417f526d762", + "is_verified": false, + "line_number": 1017 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "25756983273f8f4a48bb032b07c85104e4fc98cd", + "is_verified": false, + "line_number": 1018 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "5726a0328e5579f407bbf03fc3caa06062205ca8", + "is_verified": false, + "line_number": 1019 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "e8c6a788cf042a2a2ea8989b33826f1d6423eb29", + "is_verified": false, + "line_number": 1020 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "755577452cdccb63d3e7f1d3176316fe5ef084c8", + "is_verified": false, + "line_number": 1021 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "0ec16170fcd97d28c0f5fa919e3c635358935c04", + "is_verified": false, + "line_number": 1022 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "0f91ef272eab7567d0f2db99dffc6dbaae2cc084", + "is_verified": false, + "line_number": 1023 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "35e6dad6c44367b5bb860ff5afeb54c8c92cef58", + "is_verified": false, + "line_number": 1024 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "73dcdb9d800fe9776667edb8cde8312a0a768ada", + "is_verified": false, + "line_number": 1025 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "b56ea4486eded8635f63a8622a012fb3ee81a3bb", + "is_verified": false, + "line_number": 1026 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "b0f4a8c4f6255ea5f66fdb118eba5eeb0829307d", + "is_verified": false, + "line_number": 1027 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "88d9c65e3ce55ba286c8faf8cb105ea6ac39a19b", + "is_verified": false, + "line_number": 1028 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "adc51f3f9a4c42b861f0da4fcc29392bafe2d98e", + "is_verified": false, + "line_number": 1029 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "96b4ea6fc588c3413700405f4d169504240aa637", + "is_verified": false, + "line_number": 1030 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "f119079e796b8f2b9d29804daa90877f525cee3a", + "is_verified": false, + "line_number": 1031 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "fbf43f6ca18c68df0a478acd09bb465453c9358b", + "is_verified": false, + "line_number": 1032 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "d437b203233fd78ffc8630e42a0655f58d2e9f4e", + "is_verified": false, + "line_number": 1033 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "6b7f8512ed9b6046476383c6515fc080c63ca508", + "is_verified": false, + "line_number": 1034 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "d9f3006796ec72e11dba105176761e360fcf2a3d", + "is_verified": false, + "line_number": 1035 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "ad59895b47e8ab566d17c2ef7121c98d469e0559", + "is_verified": false, + "line_number": 1036 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "132f531444b23991fdf797454d8f949e5426ff45", + "is_verified": false, + "line_number": 1037 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "406f3373f38a62e52e8caa4458dfaa68eca20780", + "is_verified": false, + "line_number": 1038 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "ce605737729ff998492c8760553bd54393097aac", + "is_verified": false, + "line_number": 1039 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "fc42bf79fd0d8179e9f4f9f0190faad588388004", + "is_verified": false, + "line_number": 1040 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "efc0f56dded17fa0c00b58a820fbe74a1e368b63", + "is_verified": false, + "line_number": 1041 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "9d450e49c3cbcffcfb559a51d6ab4531f2a645bf", + "is_verified": false, + "line_number": 1042 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "8437e864bc114188554fd79b98cfd43f4c588df7", + "is_verified": false, + "line_number": 1043 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "de462d8851d3dc92579a62f39fadecf6b9d6bc22", + "is_verified": false, + "line_number": 1044 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "508fdca9918030fb0b8a8739ba791f611b793112", + "is_verified": false, + "line_number": 1045 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "4933bc7d4edeb7116d71e7f1947e5d6ed29760ec", + "is_verified": false, + "line_number": 1046 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "4a8bfde12d39966ecc92cc667695767bbdf7366b", + "is_verified": false, + "line_number": 1047 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "3dbc1c47b263483e20fa69941a4274cc19f85bc2", + "is_verified": false, + "line_number": 1048 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "d1287d92f048a817c6bb27b0993a87aa9560996b", + "is_verified": false, + "line_number": 1049 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "10cb9bc401ea5975fd15188a2b9cc592e513647a", + "is_verified": false, + "line_number": 1050 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "f18de35aa597b41bb9d73890f35c8f7704c72ea1", + "is_verified": false, + "line_number": 1051 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "dfe7e4f70a85c9d4d9e5e43b38e6c4afb6af9858", + "is_verified": false, + "line_number": 1052 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "d39edd8dd598dfb8918b748d29c25259509675dd", + "is_verified": false, + "line_number": 1053 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "5d2721a37cabecbb784a5e45ff9d869e7c90d7f5", + "is_verified": false, + "line_number": 1054 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "60d52adbbee54411db221581b7d93960b772f691", + "is_verified": false, + "line_number": 1055 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "af1320e386741990cf1c7201101f2ae194fc72ca", + "is_verified": false, + "line_number": 1056 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "4bbc199707b0d38feb6244d4069391cf4af4b8bb", + "is_verified": false, + "line_number": 1057 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "22023f99a0e352116a61bf566f8af2ab60b5d9c1", + "is_verified": false, + "line_number": 1058 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "3f664164c66bb49689d9931436c3d4f57f316eb6", + "is_verified": false, + "line_number": 1059 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "9a4a988167abb6a3816d472d4be97cd105a69baf", + "is_verified": false, + "line_number": 1060 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "7edf4402503eaf501e23c31ef1306392d5ecacd0", + "is_verified": false, + "line_number": 1061 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "508b4ed03f5a2f09fb22e2641580065ee4c8a372", + "is_verified": false, + "line_number": 1062 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "b02f44c26e7091096fa6fcafb832b62869af42a2", + "is_verified": false, + "line_number": 1063 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "0f9174e85538561b056727e432773bb69e128278", + "is_verified": false, + "line_number": 1064 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "cabc1f10dc737ef7e110172b814966cdad11b159", + "is_verified": false, + "line_number": 1065 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "ee5288a3e32b3b55b342ef18051c78ffff012231", + "is_verified": false, + "line_number": 1066 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "0a25e259c157bcc1a99d7e001e52b35d0a4ae2b8", + "is_verified": false, + "line_number": 1067 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "3c7bdd0b20d6f7c299da33dbb32d99105489f1c4", + "is_verified": false, + "line_number": 1068 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "19b40ca81ef322c1c0028ad1a005654faa9cfe93", + "is_verified": false, + "line_number": 1069 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "fc4ff73da4fb03231a38728acf285f405b1b3ce5", + "is_verified": false, + "line_number": 1070 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "c4e603285dc95917f8836283bebce03ff4bc11ba", + "is_verified": false, + "line_number": 1071 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "e9e498abd308db923d58b1c35ad83467e58a60b3", + "is_verified": false, + "line_number": 1072 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "954161d814c5c2ccf3ce8c3609ebb4157c08b6f7", + "is_verified": false, + "line_number": 1073 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "9bcf9c2a4de2db297ac881231955ad39f19a9df1", + "is_verified": false, + "line_number": 1074 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "8eafb590298e1d35ed72d88625bd344a427ccc8b", + "is_verified": false, + "line_number": 1075 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "32a3705a4ce42eecec3c45b0bb0a2c36142b6d08", + "is_verified": false, + "line_number": 1076 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "5e8a991485e2080c429eab8a5049b3c3bf7c0ba8", + "is_verified": false, + "line_number": 1077 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "d9fbae4d79a44395e6eca487062df13d46954053", + "is_verified": false, + "line_number": 1078 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "f62a4f64d930b746fbefdad6c48b0d2a2dc07130", + "is_verified": false, + "line_number": 1079 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "f7af30387bf7c4ac2cc0b48eef09f350ec43dae8", + "is_verified": false, + "line_number": 1080 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "afb00100d9ca02672c09acc78c7e13b56b049f63", + "is_verified": false, + "line_number": 1081 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "428e0f17cb680f5fc2b3cdc648ef8739b0fc1d87", + "is_verified": false, + "line_number": 1082 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "a7846f258d908bca9bdf9120db6b9b370a4143bd", + "is_verified": false, + "line_number": 1083 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "38c581282a5c2d07745c008443cdc545acbf5aca", + "is_verified": false, + "line_number": 1084 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "63f97716fc1f282d6718710c230006611b86be04", + "is_verified": false, + "line_number": 1085 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "57600ce03478249d79dd13c009f7f64b7ae6211c", + "is_verified": false, + "line_number": 1086 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "8e96ee931397b82b3f2c330bcfb3cfea3093d5a7", + "is_verified": false, + "line_number": 1087 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "c85653058313f125a2438e1cf446cb90bbedd8ed", + "is_verified": false, + "line_number": 1088 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "1a54794f5e3a4dd2036cfd120e294e6401f6d227", + "is_verified": false, + "line_number": 1089 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "60f2b36dcf992c96fe61ea001441417f314064ff", + "is_verified": false, + "line_number": 1090 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "939ca981ece9656aebd5b02d02ed33deadb8923b", + "is_verified": false, + "line_number": 1091 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "c28c0ae6268f5e6e813f9fe3b119e211473071e6", + "is_verified": false, + "line_number": 1092 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "fa66a89cdd91b75a640282d832886514fe6456a1", + "is_verified": false, + "line_number": 1093 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "e464c2a1ba37ae51b0f7ff8b3fba06a8ed7108dc", + "is_verified": false, + "line_number": 1094 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "8fb023d4933c56bfeb403311ffc3752d2fbc975e", + "is_verified": false, + "line_number": 1095 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "8f066fc1693da2a9cfa30bc540bb35f884c62a30", + "is_verified": false, + "line_number": 1096 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "63a7db4c42e5b728324ad5d2c92e6514ab23364a", + "is_verified": false, + "line_number": 1097 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "d4b9ba68b048c4c52c65e192dd281c1c203463c0", + "is_verified": false, + "line_number": 1098 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "33e4d896c6a8b4d14cb836f616f03eaafa43018b", + "is_verified": false, + "line_number": 1099 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "1a5b72368ecddce420d879781be813c19475c1be", + "is_verified": false, + "line_number": 1100 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "0106004ab89b24991e5e01849276a2ed348d1194", + "is_verified": false, + "line_number": 1101 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "54ede800e24d999c54ce14b80d8c56f834d1a570", + "is_verified": false, + "line_number": 1102 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "ff58b7f59920c5d3484985e53a686b91d7b183cd", + "is_verified": false, + "line_number": 1103 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "255ac9b7f9fa6a2376b2fc2219ff38f80dc8c655", + "is_verified": false, + "line_number": 1104 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "b0b7694dff36d2e9337b1012073d9ab41aec18c6", + "is_verified": false, + "line_number": 1105 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "3d675b3354c15f5088cf1581fc9fa052360c8ecf", + "is_verified": false, + "line_number": 1106 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "6e11485ed9e411128ab20a54b6d52e4e879e289f", + "is_verified": false, + "line_number": 1107 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "200a78aa828ba2d7cca00e420a85bef9dde6c841", + "is_verified": false, + "line_number": 1108 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "936a30deb66f624c112527914bbe2f09fb1c2ea2", + "is_verified": false, + "line_number": 1109 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "430e0786d83a62119d1ed6bdc8b87efbf7afbc9d", + "is_verified": false, + "line_number": 1110 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "f3fd7614d07e21dc15fa385fc2042847610f8259", + "is_verified": false, + "line_number": 1111 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "dddf43eddf77d768ace4901fc5d506ae2c85ec2d", + "is_verified": false, + "line_number": 1112 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "ae367707142233fce304a364467337f943952845", + "is_verified": false, + "line_number": 1113 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "6b16b9ea707df813fc90c54d7a531cf0f6b754d0", + "is_verified": false, + "line_number": 1114 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "cd1dc83b5bd180fb9f5e72361ff34526b2227197", + "is_verified": false, + "line_number": 1115 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "2f4400f3ba736cab5d0bf75f249c030724c8d0b7", + "is_verified": false, + "line_number": 1116 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "43d51f653e0a59b1f5988c8b6732b71dc2492bde", + "is_verified": false, + "line_number": 1117 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "32336fe7d0a6638edadafcef1f7355ff5a5043d1", + "is_verified": false, + "line_number": 1118 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "4915df89c72bb9de93ba1cf88de251db9ebb05ec", + "is_verified": false, + "line_number": 1119 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "3f1343a17f1e3d24a58df03d29a1330994239874", + "is_verified": false, + "line_number": 1120 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "a240e2ccfb08d02d3d54ce913d120af2b4a68a19", + "is_verified": false, + "line_number": 1121 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "ac1f2ad12e871b6e5818be4e7f23f90f0b655c65", + "is_verified": false, + "line_number": 1122 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "3b792af94a90899b8cfb1cc44605d4de5c0eab7a", + "is_verified": false, + "line_number": 1123 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "d6d3294546ce3a4df35269a80497b35d3d97851c", + "is_verified": false, + "line_number": 1124 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "04992ccff77891f14f3dca8bb59cc30534ae31f3", + "is_verified": false, + "line_number": 1125 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "bbb54a9a3169f76822f3c8de4c5c33c12138a8ed", + "is_verified": false, + "line_number": 1126 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "64419f894e06d7b0ab1236d60034a5410006f422", + "is_verified": false, + "line_number": 1127 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "f58a6063b0ce4ccf2630215d7ab442eb3a6cc154", + "is_verified": false, + "line_number": 1128 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "80fa5cbedc3d970f28652338cbd1da179a4b24f5", + "is_verified": false, + "line_number": 1129 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "904d8f8daa11159afe547828d6da112ec785fc9e", + "is_verified": false, + "line_number": 1130 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "62e23442e30718968242cf6397ceaf835e2b6758", + "is_verified": false, + "line_number": 1131 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "8ce675cce57b21a3cf664029ff539107da67583b", + "is_verified": false, + "line_number": 1132 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "64098f0a9449c43a8f071d2052c6066940e75ee8", + "is_verified": false, + "line_number": 1133 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "876250d35eaa0e8f788304e6f47bfb9ecf4aa1f4", + "is_verified": false, + "line_number": 1134 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "7aac80369e7b76f53ae0de0d94dfbaa21a130d32", + "is_verified": false, + "line_number": 1135 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "65df2537b97ebdb84c0dc6afa37f140811294e57", + "is_verified": false, + "line_number": 1136 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "f6ed524b021390fe734f26cac66fcf1e6a6c455e", + "is_verified": false, + "line_number": 1137 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "8fdc365a4e50f09aa482d72bba1974df3b6c9859", + "is_verified": false, + "line_number": 1138 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "36890040b0afedd15fdd9eb87459a4165fcbe2a3", + "is_verified": false, + "line_number": 1139 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "9df5cbdfba97fabe10d94f771bcd7ca889c87b2d", + "is_verified": false, + "line_number": 1140 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "de65594f00e0098e7ab3312414faf191bbc3e3c1", + "is_verified": false, + "line_number": 1141 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "37247ab05766ecc1ac7fae19a77b31f7116cce38", + "is_verified": false, + "line_number": 1142 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "13d8923244df4b3025c5d2dd405a22a757628f8d", + "is_verified": false, + "line_number": 1143 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "9eef15e4a145e31f7c74235731b69dba5207b237", + "is_verified": false, + "line_number": 1144 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "746b63eabaddeed7ab5dbe3b1fe4e41f89e9f21e", + "is_verified": false, + "line_number": 1145 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "f9512226d4044bb241d77988dac046b05effb4f3", + "is_verified": false, + "line_number": 1146 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "de168aa5d99ff80498b7552c850db5d42cb425f9", + "is_verified": false, + "line_number": 1147 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "2367ab77f144da2b2349cdbfdc4500d429754353", + "is_verified": false, + "line_number": 1148 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "d6a619ebb4b2766bce83fa5bfb6118a9d8ba3212", + "is_verified": false, + "line_number": 1149 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "35fe8489533c677b657cfee61474bab7f268a495", + "is_verified": false, + "line_number": 1150 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "e58be566894c228cb922e434d34416a473f0dc28", + "is_verified": false, + "line_number": 1151 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "18f33c6db138875913acb6ad887ed80ca3dc317f", + "is_verified": false, + "line_number": 1152 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "1e8a66cfa6671b1771e5874f29bfd96e47b4ad76", + "is_verified": false, + "line_number": 1153 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "284301d7ef66a6721a4b76a02c274419de91a437", + "is_verified": false, + "line_number": 1154 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "6694d586f66b50c0162e1cff4b1f133e2c8a9423", + "is_verified": false, + "line_number": 1155 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "c712802905f08891cac2e68e6d8f5f6d85e4cf60", + "is_verified": false, + "line_number": 1156 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "cd5f0c85968b392a77596cb5143de81f6f109bcd", + "is_verified": false, + "line_number": 1157 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "e158eb64d577c9904690ff67584f2b0090792139", + "is_verified": false, + "line_number": 1158 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "62cef2983d23c372ffd1175683e2cf0489a0a93c", + "is_verified": false, + "line_number": 1159 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "0039a393f63d3b522516a90354354b6477765b06", + "is_verified": false, + "line_number": 1160 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "5c91012c71d492f7e5bc5607f71e1d3337562f9b", + "is_verified": false, + "line_number": 1161 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "83fd266255474e467fcc3f1ca61b0371bf6933eb", + "is_verified": false, + "line_number": 1162 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "44dc9bc4f3a32681036d3328bf2e2c298c94c5b3", + "is_verified": false, + "line_number": 1163 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "c077db4aab559fcc23cecde6c8dce6f58a86c7ba", + "is_verified": false, + "line_number": 1164 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "f2e728ed22184e3a7bf3b34308c53815d811687d", + "is_verified": false, + "line_number": 1165 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "9d653c4cd2f63ba627e1f7eb557b793e7eb50f3a", + "is_verified": false, + "line_number": 1166 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "33e0029ea6c1f2989bf2b5b86f6c4acc03fd7b10", + "is_verified": false, + "line_number": 1167 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "139c8a653e6827e2b29b75c31d27eba181977579", + "is_verified": false, + "line_number": 1168 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "e34424070b48aeaee9eeeb88a1a928d2ce1f5517", + "is_verified": false, + "line_number": 1169 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "c4db39ccd7c06e68ada50b294aa53f947559a99a", + "is_verified": false, + "line_number": 1170 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "0636d970e79e781a5159068c6fe7f0411698b596", + "is_verified": false, + "line_number": 1171 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "0bc38af13c57dafb7f18b33b86e5bcbe1292bc2e", + "is_verified": false, + "line_number": 1172 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "02d9eabf8b61d1e62425eac9c7b39385e602ddad", + "is_verified": false, + "line_number": 1173 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "3ba33420b436dd34da6f45fdbdbb26a87c99e811", + "is_verified": false, + "line_number": 1174 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "2965a6a5b73c3edfdc11d9a979bb085546d63d1f", + "is_verified": false, + "line_number": 1175 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "8b15da0afbed8313d1daec67d4bca7958949484d", + "is_verified": false, + "line_number": 1176 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "4bf0c8b08ddcb81f5ac2457580003197ff4782dd", + "is_verified": false, + "line_number": 1177 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "9e3822884cf25511703c4fbfce1ddacc0d19d021", + "is_verified": false, + "line_number": 1178 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "26fd6e63721168b064c7825415fda7da4c17cd36", + "is_verified": false, + "line_number": 1179 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "82db110822969249eff39d4b7e6830ee919c4b8e", + "is_verified": false, + "line_number": 1180 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "e81523785f6e5efeb372a665059ab959c7911c37", + "is_verified": false, + "line_number": 1181 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "4c8056fa1e16e63e4da13f329a0f0ba8c3d875eb", + "is_verified": false, + "line_number": 1182 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "63a9faac8e9440b425905da27052de51aa69b937", + "is_verified": false, + "line_number": 1183 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "e0ad9315e82b5f80b7b02ce12ba3e686c9a637a5", + "is_verified": false, + "line_number": 1184 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "176ca3d77737c23c86a524235e4281df3a64a573", + "is_verified": false, + "line_number": 1185 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "e8b4a7abb0c1178809eb5f5703ed43d558083a2d", + "is_verified": false, + "line_number": 1186 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "7e0ad9ba810350bcd8da9180615fd964827c14ef", + "is_verified": false, + "line_number": 1187 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "39c3357766171faf88e70eea0dccb00239f273c5", + "is_verified": false, + "line_number": 1188 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "d17aa49aceeaf925527404fa57a4e17668de8596", + "is_verified": false, + "line_number": 1189 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "2a6b75b5576df53c3219112e7daff1dc142702d1", + "is_verified": false, + "line_number": 1190 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "b75fa52e7d8ecfb8e7e9ff3dc2c37b73abcf7e2c", + "is_verified": false, + "line_number": 1191 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "c551bfc4af7eb1fd5daa4f05fd58a2d4d65b85fe", + "is_verified": false, + "line_number": 1192 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "a8d858cd02dcd5038dc3e76ac76b2da91f8dbccd", + "is_verified": false, + "line_number": 1193 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "1bf631baf29fc48072c20ebfdd321964066f9f08", + "is_verified": false, + "line_number": 1194 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "c6eb53905cd7e0253f4e69f34295cb6a50f58e08", + "is_verified": false, + "line_number": 1195 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "7bbb8b2539588d170a6c26e9f61ae0800f9d8f2d", + "is_verified": false, + "line_number": 1196 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "26caefb3dca46d7afafdcf0010c67b9e9fccc92b", + "is_verified": false, + "line_number": 1197 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "2cb19ac1427a96db3d380729bf039e5349ef63be", + "is_verified": false, + "line_number": 1198 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "9e2aa480ce341383cbca0c207198d483e20322bd", + "is_verified": false, + "line_number": 1199 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "be742ba9f651b96a51823045433f3a1948d7eced", + "is_verified": false, + "line_number": 1200 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "317bd6bc5bcc732a1db7e57d0371aa9257f8df00", + "is_verified": false, + "line_number": 1201 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "7c80c0ebf44179e49cf0e5a3d0408cc76aee83de", + "is_verified": false, + "line_number": 1202 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "7858b77e2046951eadc43758c07104d777668eb7", + "is_verified": false, + "line_number": 1203 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "85a09b9fd03c47f1b036cf44c4909bc73ddd6cad", + "is_verified": false, + "line_number": 1204 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "1718e46e064b47cec903bad3b0e9d6ef1da2f11b", + "is_verified": false, + "line_number": 1205 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "0c1ee8a96d538ba8b4fa8b05db03563fd7ef8973", + "is_verified": false, + "line_number": 1206 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "2017b3f2be44d213be17940140c168a5fba7561d", + "is_verified": false, + "line_number": 1207 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "b083a5002d8fe4f2a66696aa0814e03ffa6d1837", + "is_verified": false, + "line_number": 1208 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "ff42555f72300b656e47db4ed191f5df0ac07560", + "is_verified": false, + "line_number": 1209 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "2ef2cf7195a65a890efa0632dd212ef8220aa1c6", + "is_verified": false, + "line_number": 1210 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "69cb36505922753131885b4a08c707f81ac66a47", + "is_verified": false, + "line_number": 1211 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "069b86c3a9114bd673eef998e22656df1fcaddd8", + "is_verified": false, + "line_number": 1212 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "70c8686a1be4b67a602a59a873ddbede2cd4da7e", + "is_verified": false, + "line_number": 1213 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "523d5a3e6d4fbf64c23594663c7e4687ae9c2be3", + "is_verified": false, + "line_number": 1214 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "16e86f176fd3cd4f7a58f0ffb8dc5791f3f95a86", + "is_verified": false, + "line_number": 1215 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "ed84afa53dc05329a7991f5bf5cd2cae1fd77ffc", + "is_verified": false, + "line_number": 1216 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "f1289b7119566377ed28ab9dd62af0fd09ed9fe2", + "is_verified": false, + "line_number": 1217 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "a4f904b0556d1681ef00ea1813f2f94e28b797eb", + "is_verified": false, + "line_number": 1218 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "0949c112813b58b0da6912740cf8bcbb85226c34", + "is_verified": false, + "line_number": 1219 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "1bbf17622cda5702d35e14ba66df075a7bb57913", + "is_verified": false, + "line_number": 1220 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "8e3a03cec08874a64bccc6d6d425f0afe79533a1", + "is_verified": false, + "line_number": 1221 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "1aafc9018c54c7198cf74db22feb0319707898b6", + "is_verified": false, + "line_number": 1222 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "e7b49f254a6e2de711e659bd28ad158691e30fce", + "is_verified": false, + "line_number": 1223 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "fbc11861a047faba2041e2b6c715d8ca60803c8e", + "is_verified": false, + "line_number": 1224 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "44c990c1ce572f1e8f1ab851427e3a42ce71242a", + "is_verified": false, + "line_number": 1225 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "4d3640532de6af408ed943d63ed3e3c2689e9c5f", + "is_verified": false, + "line_number": 1226 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "a523fffc0ede19e1deeda09652de2b7a018cf8b4", + "is_verified": false, + "line_number": 1227 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "4a995d1758da7e7154ba4acbec5b5b403742b7e1", + "is_verified": false, + "line_number": 1228 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "de4be8856b30e21fc713dc10f8988539feea7023", + "is_verified": false, + "line_number": 1229 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "fb1c0866f73c66412d08391f3ce4878af73aa639", + "is_verified": false, + "line_number": 1230 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "a702fefff9cdbe1f95ab8827ddec5ba8efc30892", + "is_verified": false, + "line_number": 1231 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "724b47ffa7a9db1bbaf712b3d9d2b76898db0ea5", + "is_verified": false, + "line_number": 1232 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "e0f16906358b6b058b6d986929a05521b6901f68", + "is_verified": false, + "line_number": 1233 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "4332f528fff4a967c90c89db64aa58e23393bfed", + "is_verified": false, + "line_number": 1234 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "451a10712041218c61b0cc3787311943dab42dc6", + "is_verified": false, + "line_number": 1235 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "6a1be9deb76862f934fd8a9197069f4609ef70b5", + "is_verified": false, + "line_number": 1236 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "2b1256a86a2fb02c20dc58e47774d30baed60f62", + "is_verified": false, + "line_number": 1237 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "74d000f3ede09a41df362d509537a2ac5f1fa07b", + "is_verified": false, + "line_number": 1238 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "43f8293d7eda52b663063cd56e5a3e394f193642", + "is_verified": false, + "line_number": 1239 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "51352b84bafc3573024540c543cc95922a764ef0", + "is_verified": false, + "line_number": 1240 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "0ece3e42bfed9840f907fa700d5d29f0087985db", + "is_verified": false, + "line_number": 1241 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "3b91d6d99ae8c482392adc042654bd076573cd8a", + "is_verified": false, + "line_number": 1242 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "ab529305822e1642ed7c7d3acd9ba80dabc55108", + "is_verified": false, + "line_number": 1243 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "3cf4744d88fd85b0fcb0fbf0425c5b50eae93b3e", + "is_verified": false, + "line_number": 1244 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "228fe53a555785f979a20a0159c96ef7d8d057c7", + "is_verified": false, + "line_number": 1245 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "8d21215aa0a8f29d068ff316fc09ea6ae9e766c7", + "is_verified": false, + "line_number": 1246 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "d63d3d63396c5e88f1fd8cdab9116331080cd2e2", + "is_verified": false, + "line_number": 1247 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "d4fe6d5f06c2860ed38ebb02079bb2ebfcbfb093", + "is_verified": false, + "line_number": 1248 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "5e1d352485a30350ac108f66da7ac3ce62b1ea4f", + "is_verified": false, + "line_number": 1249 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "c682e7af6638379e4edf52c36995c3454ea1b149", + "is_verified": false, + "line_number": 1250 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "bb193ef1c9bcbc39ed64689f474af29719df489e", + "is_verified": false, + "line_number": 1251 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "01c34073e2e61552f4fd0ba64139be0ccabcdb8a", + "is_verified": false, + "line_number": 1252 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "cc47b8620102a6216f098eb7f9ea841c3c2a5f22", + "is_verified": false, + "line_number": 1253 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "8f070c859fe84c5502e45b84a274308bbc0a7744", + "is_verified": false, + "line_number": 1254 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "5f3061dc64135be12c1eaef23ab8e02f1826f24d", + "is_verified": false, + "line_number": 1255 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "9238be5963618c3501e919ebd4c13992a4bea3b4", + "is_verified": false, + "line_number": 1256 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "68c1365f209fa103e65c4da375b42d5656575940", + "is_verified": false, + "line_number": 1257 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "384be6402a8d31d62cb35fefaec77b06c8211f59", + "is_verified": false, + "line_number": 1258 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "360329c0a8cb6053168e61758688b85104fc86ff", + "is_verified": false, + "line_number": 1259 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "7cd87f59db950306302a74b81e8f926df1577397", + "is_verified": false, + "line_number": 1260 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "553b2380d863621a9e4ab7c7a97fdec425ebab25", + "is_verified": false, + "line_number": 1261 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "43562265e7cf90c28221c2b7dbfcafa8f62843dc", + "is_verified": false, + "line_number": 1262 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "ed7e495370ef7882b13866c332dff00ef7c361a6", + "is_verified": false, + "line_number": 1263 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "7123453c9f62fc6c33951aa2595f1714b23d583a", + "is_verified": false, + "line_number": 1264 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "e941c0eb1694570c999ca3fe548f76f6daaca83c", + "is_verified": false, + "line_number": 1265 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "85018e48b287ca7323192ff38ebe9411e61b38e2", + "is_verified": false, + "line_number": 1266 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "814d7edca30e0262ab0b07c6baf47d20738c823b", + "is_verified": false, + "line_number": 1267 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "5dca59fe14f949e763116aef3968af2662926895", + "is_verified": false, + "line_number": 1268 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "ee86abd29ecfab79519c1efc033546d2c477477f", + "is_verified": false, + "line_number": 1269 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "5878ed0ebded462f8d2461fe18061aa18d1000fd", + "is_verified": false, + "line_number": 1270 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "4dd683cc3993e43d00b1b5f9e4e57895bb56e8e5", + "is_verified": false, + "line_number": 1271 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "a8a20da925fd5126d24df7d8baf68ac1fa23a184", + "is_verified": false, + "line_number": 1272 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "137f68b2d3f03ddd81ed8602ff19218c71df55fb", + "is_verified": false, + "line_number": 1273 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "b32f2f31a868ddf0e3f013465c72527f62057e44", + "is_verified": false, + "line_number": 1274 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "f5425542a9e9183a33dd16d559c92182f35f44a8", + "is_verified": false, + "line_number": 1275 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "77e8234c8ff852ec820384cd8f9284cde00e34a9", + "is_verified": false, + "line_number": 1276 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "be6e0ac8ab7d8ac8d7f7a4fc86b123392c09374e", + "is_verified": false, + "line_number": 1277 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "3063130919857912b6373c6182853095d60ca18b", + "is_verified": false, + "line_number": 1278 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "607c9f8efafb2de11157fefd103f9f1cda4f347b", + "is_verified": false, + "line_number": 1279 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "2c301b0126a15e8150d92a84d8a49ab1eb9b4282", + "is_verified": false, + "line_number": 1280 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "84737ddb75ed5806c645ba66e122402be971389a", + "is_verified": false, + "line_number": 1281 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "5a9adaee2ecb6e99992aa263eda966061c9acac0", + "is_verified": false, + "line_number": 1282 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "0c09b49e14a5a35d3f26420994f8b786035166e6", + "is_verified": false, + "line_number": 1283 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "0ef06e9fe84d92197ae053067b3f3d5051070690", + "is_verified": false, + "line_number": 1284 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "b249743c201079e983e03d0afeb3c140342fc9d0", + "is_verified": false, + "line_number": 1285 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "82d624e2d36bf5346e60dd14806ff782bb2a4334", + "is_verified": false, + "line_number": 1286 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "88850db69d81a7ece67fb1d9b286c2d951b70819", + "is_verified": false, + "line_number": 1287 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "e49afb46bf458312000f8f9660ae81ff47bdc199", + "is_verified": false, + "line_number": 1288 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "1cbdad16e84903fc3b9b6388a089a067dea2a3d2", + "is_verified": false, + "line_number": 1289 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "82feda736f248ac86d376891de516d9d1824a27c", + "is_verified": false, + "line_number": 1290 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "4a71f468c1364aff801b9120b1f5d529078048e9", + "is_verified": false, + "line_number": 1291 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "f091998ff0fee46909f88aa7fd4f3cc73a3d3c9a", + "is_verified": false, + "line_number": 1292 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "29eaffa6f6f8a37758a5f7b32907b3dc5b691896", + "is_verified": false, + "line_number": 1293 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "44f681b1a58ce0c6df53676cc0808013e97ea9f4", + "is_verified": false, + "line_number": 1294 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "962dfd74b7253ac6cd612a6e748f2e95efb79f51", + "is_verified": false, + "line_number": 1295 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "c86ef7132a2306cf87224e55cb204e6d2e8e7828", + "is_verified": false, + "line_number": 1296 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "c4eb42c72ecfdf7810202a43d54548f7d2bff62d", + "is_verified": false, + "line_number": 1297 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "19383a628b845b1cbb1c0444832b0afbe8ab5064", + "is_verified": false, + "line_number": 1298 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "b34bf28a1f7465a72772787a147d434d923c8d1b", + "is_verified": false, + "line_number": 1299 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "288ba78781c2ed007a423cb65cb1bf2306c3fd95", + "is_verified": false, + "line_number": 1300 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "f3ceb3cc25a1228a6c53b4e215d7568d36e757a6", + "is_verified": false, + "line_number": 1301 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "87996bb1e32b4a0ecc22ac1d13cea8e0190b350b", + "is_verified": false, + "line_number": 1302 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "790704b8f93fe5aca8ac2ecfcb68f1584dad2647", + "is_verified": false, + "line_number": 1303 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "86223a1c42e86aae0a1ed4fa7d40eb2d059c4dd5", + "is_verified": false, + "line_number": 1304 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "1673e79621b9dddf3b29a9b1ddf8d2ec0aad4bdc", + "is_verified": false, + "line_number": 1305 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "35b29b6e62d70ae4822318a19d0a46658eddd34f", + "is_verified": false, + "line_number": 1306 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "b3cb65216294e3c0b3981e2db721954bafc3b23a", + "is_verified": false, + "line_number": 1307 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "5dbca02e62ce0d208d12a1da12ba317344d8c6cc", + "is_verified": false, + "line_number": 1308 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "83acef9b2863c05447dea16c378025f007bc8c34", + "is_verified": false, + "line_number": 1309 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "524ef34b587ca7240673b9607b4314f3f37cd2a8", + "is_verified": false, + "line_number": 1310 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "c76948814be7ef0455d6d9ff65aeae688b7bec24", + "is_verified": false, + "line_number": 1311 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "5604fd630dabf095466a6c854750348059dbb1aa", + "is_verified": false, + "line_number": 1312 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "0b5772a512bb087fa1d6e34a062c7eec75f6e744", + "is_verified": false, + "line_number": 1313 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "7d3fa248843c7c76c909ee18b0dd773bbb5741e7", + "is_verified": false, + "line_number": 1314 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "d7d16ac0dbd0bb5e98c6cb1d8508ff0132bbcbb0", + "is_verified": false, + "line_number": 1315 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "d33b30cdcf982839a7cb6ae4e04b74deb2bd8f28", + "is_verified": false, + "line_number": 1316 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "281ca8a981dae1cebcb05b90cde4c895f3c59525", + "is_verified": false, + "line_number": 1317 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "712b5a91ad8f25eaaae3afccd7b41c6215102f70", + "is_verified": false, + "line_number": 1318 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "5fbc83376379b2201ae51f28039f87cb1ca14649", + "is_verified": false, + "line_number": 1319 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "d7497697fc350ef28cc0682526233a7846bfbf7f", + "is_verified": false, + "line_number": 1320 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "3f3d0b8308dfa23ce4c75abcfdd3840cab33de8b", + "is_verified": false, + "line_number": 1321 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "71a4936bbf172bf22c55b532a505a2c33f04ef2a", + "is_verified": false, + "line_number": 1322 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "f76a3c0087070143222761d33c9496d10ec5645a", + "is_verified": false, + "line_number": 1323 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "b8e837e18bc28489da6d38ac38370bd4a7757770", + "is_verified": false, + "line_number": 1324 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "af90bf5453dacd36dd205811a40eda42d5496cb5", + "is_verified": false, + "line_number": 1325 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "1fd5a47605b1192ee40beb9203beaafe8e53e13c", + "is_verified": false, + "line_number": 1326 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "c286938c2542589cd0fbed6acb6326d3c9efeb77", + "is_verified": false, + "line_number": 1327 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "73cfd5a17466838726c63386a3e5cccdf722a9d8", + "is_verified": false, + "line_number": 1328 + }, + { + "type": "Hex High Entropy String", + "filename": "docs/.i18n/zh-CN.tm.jsonl", + "hashed_secret": "8bb0680522ae015a5b71c1e7d24ec4641960c322", + "is_verified": false, + "line_number": 1329 } ], "docs/brave-search.md": [ @@ -236,7 +9593,7 @@ "filename": "docs/brave-search.md", "hashed_secret": "491d458f895b9213facb2ee9375b1b044eaea3ac", "is_verified": false, - "line_number": 26 + "line_number": 27 } ], "docs/channels/bluebubbles.md": [ @@ -245,7 +9602,41 @@ "filename": "docs/channels/bluebubbles.md", "hashed_secret": "555da20df20d4172e00f1b73d7c3943802055270", "is_verified": false, - "line_number": 32 + "line_number": 37 + } + ], + "docs/channels/feishu.md": [ + { + "type": "Secret Keyword", + "filename": "docs/channels/feishu.md", + "hashed_secret": "b60d121b438a380c343d5ec3c2037564b82ffef3", + "is_verified": false, + "line_number": 187 + }, + { + "type": "Secret Keyword", + "filename": "docs/channels/feishu.md", + "hashed_secret": "186154712b2d5f6791d85b9a0987b98fa231779c", + "is_verified": false, + "line_number": 435 + } + ], + "docs/channels/irc.md": [ + { + "type": "Secret Keyword", + "filename": "docs/channels/irc.md", + "hashed_secret": "d54831b8e4b461d85e32ea82156d2fb5ce5cb624", + "is_verified": false, + "line_number": 191 + } + ], + "docs/channels/line.md": [ + { + "type": "Secret Keyword", + "filename": "docs/channels/line.md", + "hashed_secret": "83661b43df128631f891767fbfc5b049af3dce86", + "is_verified": false, + "line_number": 61 } ], "docs/channels/matrix.md": [ @@ -254,7 +9645,7 @@ "filename": "docs/channels/matrix.md", "hashed_secret": "45d676e7c6ab44cf4b8fa366ef2d8fccd3e6d6e6", "is_verified": false, - "line_number": 58 + "line_number": 60 } ], "docs/channels/nextcloud-talk.md": [ @@ -263,7 +9654,7 @@ "filename": "docs/channels/nextcloud-talk.md", "hashed_secret": "76ed0a056aa77060de25754586440cff390791d0", "is_verified": false, - "line_number": 47 + "line_number": 56 } ], "docs/channels/nostr.md": [ @@ -272,7 +9663,7 @@ "filename": "docs/channels/nostr.md", "hashed_secret": "edeb23e25a619c434d22bb7f1c3ca4841166b4e8", "is_verified": false, - "line_number": 65 + "line_number": 67 } ], "docs/channels/slack.md": [ @@ -281,7 +9672,23 @@ "filename": "docs/channels/slack.md", "hashed_secret": "3f4800fb7c1fb79a9a48bfd562d90bc6b2e2b718", "is_verified": false, - "line_number": 141 + "line_number": 104 + } + ], + "docs/channels/twitch.md": [ + { + "type": "Secret Keyword", + "filename": "docs/channels/twitch.md", + "hashed_secret": "0d1ba0da3e84e54f29846c93c43182eede365858", + "is_verified": false, + "line_number": 138 + }, + { + "type": "Secret Keyword", + "filename": "docs/channels/twitch.md", + "hashed_secret": "7cb4c5b8b81e266d08d4f106799af98d748bceb9", + "is_verified": false, + "line_number": 324 } ], "docs/concepts/memory.md": [ @@ -290,21 +9697,21 @@ "filename": "docs/concepts/memory.md", "hashed_secret": "39d711243bfcee9fec8299b204e1aa9c3430fa12", "is_verified": false, - "line_number": 108 + "line_number": 281 }, { "type": "Secret Keyword", "filename": "docs/concepts/memory.md", "hashed_secret": "1a8abbf465c52363ab4c9c6ad945b8e857cbea55", "is_verified": false, - "line_number": 131 + "line_number": 305 }, { "type": "Secret Keyword", "filename": "docs/concepts/memory.md", "hashed_secret": "b9f640d6095b9f6b5a65983f7b76dbbb254e0044", "is_verified": false, - "line_number": 373 + "line_number": 706 } ], "docs/concepts/model-providers.md": [ @@ -313,30 +9720,21 @@ "filename": "docs/concepts/model-providers.md", "hashed_secret": "ec3810e10fb78db55ce38b9c18d1c3eb1db739e0", "is_verified": false, - "line_number": 168 + "line_number": 178 + }, + { + "type": "Secret Keyword", + "filename": "docs/concepts/model-providers.md", + "hashed_secret": "6a4a6c8f2406f4f0843a0a1aae6a320f92f9d6ae", + "is_verified": false, + "line_number": 274 }, { "type": "Secret Keyword", "filename": "docs/concepts/model-providers.md", "hashed_secret": "ef83ad68b9b66e008727b7c417c6a8f618b5177e", "is_verified": false, - "line_number": 255 - } - ], - "docs/environment.md": [ - { - "type": "Secret Keyword", - "filename": "docs/environment.md", - "hashed_secret": "a219d7693c25cd2d93313512e200ff3eb374d281", - "is_verified": false, - "line_number": 29 - }, - { - "type": "Secret Keyword", - "filename": "docs/environment.md", - "hashed_secret": "b6f56e5e92078ed7c078c46fbfeedcbe5719bc25", - "is_verified": false, - "line_number": 31 + "line_number": 305 } ], "docs/gateway/configuration-examples.md": [ @@ -345,35 +9743,107 @@ "filename": "docs/gateway/configuration-examples.md", "hashed_secret": "a219d7693c25cd2d93313512e200ff3eb374d281", "is_verified": false, - "line_number": 53 + "line_number": 57 }, { "type": "Secret Keyword", "filename": "docs/gateway/configuration-examples.md", "hashed_secret": "b6f56e5e92078ed7c078c46fbfeedcbe5719bc25", "is_verified": false, - "line_number": 55 + "line_number": 59 }, { "type": "Secret Keyword", "filename": "docs/gateway/configuration-examples.md", "hashed_secret": "22af290a1a3d5e941193a41a3d3a9e4ca8da5e27", "is_verified": false, - "line_number": 319 + "line_number": 332 }, { "type": "Secret Keyword", "filename": "docs/gateway/configuration-examples.md", "hashed_secret": "c1e6ee547fd492df1441ac492e8bb294974712bd", "is_verified": false, - "line_number": 414 + "line_number": 431 }, { "type": "Secret Keyword", "filename": "docs/gateway/configuration-examples.md", "hashed_secret": "16c249e04e2be318050cb883c40137361c0c7209", "is_verified": false, - "line_number": 548 + "line_number": 596 + } + ], + "docs/gateway/configuration-reference.md": [ + { + "type": "Secret Keyword", + "filename": "docs/gateway/configuration-reference.md", + "hashed_secret": "e5e9fa1ba31ecd1ae84f75caaa474f3a663f05f4", + "is_verified": false, + "line_number": 149 + }, + { + "type": "Secret Keyword", + "filename": "docs/gateway/configuration-reference.md", + "hashed_secret": "1188d5a8ed7edcff5144a9472af960243eacf12e", + "is_verified": false, + "line_number": 1267 + }, + { + "type": "Secret Keyword", + "filename": "docs/gateway/configuration-reference.md", + "hashed_secret": "bde4db9b4c3be4049adc3b9a69851d7c35119770", + "is_verified": false, + "line_number": 1283 + }, + { + "type": "Secret Keyword", + "filename": "docs/gateway/configuration-reference.md", + "hashed_secret": "7f8aaf142ce0552c260f2e546dda43ddd7c9aef3", + "is_verified": false, + "line_number": 1461 + }, + { + "type": "Secret Keyword", + "filename": "docs/gateway/configuration-reference.md", + "hashed_secret": "22af290a1a3d5e941193a41a3d3a9e4ca8da5e27", + "is_verified": false, + "line_number": 1603 + }, + { + "type": "Secret Keyword", + "filename": "docs/gateway/configuration-reference.md", + "hashed_secret": "ec3810e10fb78db55ce38b9c18d1c3eb1db739e0", + "is_verified": false, + "line_number": 1631 + }, + { + "type": "Secret Keyword", + "filename": "docs/gateway/configuration-reference.md", + "hashed_secret": "c1e6ee547fd492df1441ac492e8bb294974712bd", + "is_verified": false, + "line_number": 1862 + }, + { + "type": "Secret Keyword", + "filename": "docs/gateway/configuration-reference.md", + "hashed_secret": "45d676e7c6ab44cf4b8fa366ef2d8fccd3e6d6e6", + "is_verified": false, + "line_number": 1966 + }, + { + "type": "Secret Keyword", + "filename": "docs/gateway/configuration-reference.md", + "hashed_secret": "a219d7693c25cd2d93313512e200ff3eb374d281", + "is_verified": false, + "line_number": 2202 + }, + { + "type": "Secret Keyword", + "filename": "docs/gateway/configuration-reference.md", + "hashed_secret": "b6f56e5e92078ed7c078c46fbfeedcbe5719bc25", + "is_verified": false, + "line_number": 2204 } ], "docs/gateway/configuration.md": [ @@ -382,63 +9852,14 @@ "filename": "docs/gateway/configuration.md", "hashed_secret": "a219d7693c25cd2d93313512e200ff3eb374d281", "is_verified": false, - "line_number": 272 + "line_number": 434 }, { "type": "Secret Keyword", "filename": "docs/gateway/configuration.md", "hashed_secret": "b6f56e5e92078ed7c078c46fbfeedcbe5719bc25", "is_verified": false, - "line_number": 274 - }, - { - "type": "Secret Keyword", - "filename": "docs/gateway/configuration.md", - "hashed_secret": "e5e9fa1ba31ecd1ae84f75caaa474f3a663f05f4", - "is_verified": false, - "line_number": 1029 - }, - { - "type": "Secret Keyword", - "filename": "docs/gateway/configuration.md", - "hashed_secret": "1188d5a8ed7edcff5144a9472af960243eacf12e", - "is_verified": false, - "line_number": 1470 - }, - { - "type": "Secret Keyword", - "filename": "docs/gateway/configuration.md", - "hashed_secret": "bde4db9b4c3be4049adc3b9a69851d7c35119770", - "is_verified": false, - "line_number": 1486 - }, - { - "type": "Secret Keyword", - "filename": "docs/gateway/configuration.md", - "hashed_secret": "22af290a1a3d5e941193a41a3d3a9e4ca8da5e27", - "is_verified": false, - "line_number": 2268 - }, - { - "type": "Secret Keyword", - "filename": "docs/gateway/configuration.md", - "hashed_secret": "ec3810e10fb78db55ce38b9c18d1c3eb1db739e0", - "is_verified": false, - "line_number": 2344 - }, - { - "type": "Secret Keyword", - "filename": "docs/gateway/configuration.md", - "hashed_secret": "c1e6ee547fd492df1441ac492e8bb294974712bd", - "is_verified": false, - "line_number": 2658 - }, - { - "type": "Secret Keyword", - "filename": "docs/gateway/configuration.md", - "hashed_secret": "45d676e7c6ab44cf4b8fa366ef2d8fccd3e6d6e6", - "is_verified": false, - "line_number": 2844 + "line_number": 435 } ], "docs/gateway/local-models.md": [ @@ -447,14 +9868,14 @@ "filename": "docs/gateway/local-models.md", "hashed_secret": "16c249e04e2be318050cb883c40137361c0c7209", "is_verified": false, - "line_number": 32 + "line_number": 34 }, { "type": "Secret Keyword", "filename": "docs/gateway/local-models.md", "hashed_secret": "49fd535e63175a827aab3eff9ac58a9e82460ac9", "is_verified": false, - "line_number": 121 + "line_number": 124 } ], "docs/gateway/tailscale.md": [ @@ -463,7 +9884,23 @@ "filename": "docs/gateway/tailscale.md", "hashed_secret": "9cb0dc5383312aa15b9dc6745645bde18ff5ade9", "is_verified": false, - "line_number": 75 + "line_number": 81 + } + ], + "docs/help/environment.md": [ + { + "type": "Secret Keyword", + "filename": "docs/help/environment.md", + "hashed_secret": "a219d7693c25cd2d93313512e200ff3eb374d281", + "is_verified": false, + "line_number": 31 + }, + { + "type": "Secret Keyword", + "filename": "docs/help/environment.md", + "hashed_secret": "b6f56e5e92078ed7c078c46fbfeedcbe5719bc25", + "is_verified": false, + "line_number": 33 } ], "docs/help/faq.md": [ @@ -472,35 +9909,44 @@ "filename": "docs/help/faq.md", "hashed_secret": "491d458f895b9213facb2ee9375b1b044eaea3ac", "is_verified": false, - "line_number": 925 + "line_number": 1412 }, { "type": "Secret Keyword", "filename": "docs/help/faq.md", "hashed_secret": "a219d7693c25cd2d93313512e200ff3eb374d281", "is_verified": false, - "line_number": 1113 + "line_number": 1689 }, { "type": "Secret Keyword", "filename": "docs/help/faq.md", "hashed_secret": "b6f56e5e92078ed7c078c46fbfeedcbe5719bc25", "is_verified": false, - "line_number": 1114 + "line_number": 1690 }, { "type": "Secret Keyword", "filename": "docs/help/faq.md", "hashed_secret": "ec3810e10fb78db55ce38b9c18d1c3eb1db739e0", "is_verified": false, - "line_number": 1439 + "line_number": 2118 }, { "type": "Secret Keyword", "filename": "docs/help/faq.md", "hashed_secret": "45d676e7c6ab44cf4b8fa366ef2d8fccd3e6d6e6", "is_verified": false, - "line_number": 1715 + "line_number": 2398 + } + ], + "docs/install/macos-vm.md": [ + { + "type": "Secret Keyword", + "filename": "docs/install/macos-vm.md", + "hashed_secret": "8dd3bcd07c9ee927e6921c98b4dc6e94e2cc10a9", + "is_verified": false, + "line_number": 217 } ], "docs/nodes/talk.md": [ @@ -509,7 +9955,7 @@ "filename": "docs/nodes/talk.md", "hashed_secret": "1188d5a8ed7edcff5144a9472af960243eacf12e", "is_verified": false, - "line_number": 50 + "line_number": 58 } ], "docs/perplexity.md": [ @@ -518,7 +9964,16 @@ "filename": "docs/perplexity.md", "hashed_secret": "6b26c117c66a0c030e239eef595c1e18865132a8", "is_verified": false, - "line_number": 35 + "line_number": 36 + } + ], + "docs/plugins/voice-call.md": [ + { + "type": "Secret Keyword", + "filename": "docs/plugins/voice-call.md", + "hashed_secret": "cb46980ce5532f18440dff4bbbe097896a8c08c8", + "is_verified": false, + "line_number": 239 } ], "docs/providers/anthropic.md": [ @@ -527,7 +9982,16 @@ "filename": "docs/providers/anthropic.md", "hashed_secret": "c7a8c334eef5d1749fface7d42c66f9ae5e8cf36", "is_verified": false, - "line_number": 32 + "line_number": 33 + } + ], + "docs/providers/claude-max-api-proxy.md": [ + { + "type": "Secret Keyword", + "filename": "docs/providers/claude-max-api-proxy.md", + "hashed_secret": "b5c2827eb65bf13b87130e7e3c424ba9ff07cd67", + "is_verified": false, + "line_number": 80 } ], "docs/providers/glm.md": [ @@ -536,7 +10000,23 @@ "filename": "docs/providers/glm.md", "hashed_secret": "ec3810e10fb78db55ce38b9c18d1c3eb1db739e0", "is_verified": false, - "line_number": 22 + "line_number": 24 + } + ], + "docs/providers/litellm.md": [ + { + "type": "Secret Keyword", + "filename": "docs/providers/litellm.md", + "hashed_secret": "b907cadbe5a060ca6c6b78fee4c1953f34c64c32", + "is_verified": false, + "line_number": 40 + }, + { + "type": "Secret Keyword", + "filename": "docs/providers/litellm.md", + "hashed_secret": "651702a4fa521c0c493a3171cfba79c3c49eeaec", + "is_verified": false, + "line_number": 52 } ], "docs/providers/minimax.md": [ @@ -545,14 +10025,14 @@ "filename": "docs/providers/minimax.md", "hashed_secret": "ec3810e10fb78db55ce38b9c18d1c3eb1db739e0", "is_verified": false, - "line_number": 49 + "line_number": 71 }, { "type": "Secret Keyword", "filename": "docs/providers/minimax.md", "hashed_secret": "16c249e04e2be318050cb883c40137361c0c7209", "is_verified": false, - "line_number": 118 + "line_number": 140 } ], "docs/providers/moonshot.md": [ @@ -561,7 +10041,25 @@ "filename": "docs/providers/moonshot.md", "hashed_secret": "ec3810e10fb78db55ce38b9c18d1c3eb1db739e0", "is_verified": false, - "line_number": 39 + "line_number": 43 + } + ], + "docs/providers/nvidia.md": [ + { + "type": "Secret Keyword", + "filename": "docs/providers/nvidia.md", + "hashed_secret": "2083c49ad8d63838a4d18f1de0c419f06eb464db", + "is_verified": false, + "line_number": 18 + } + ], + "docs/providers/ollama.md": [ + { + "type": "Secret Keyword", + "filename": "docs/providers/ollama.md", + "hashed_secret": "e774aaeac31c6272107ba89080295e277050fa7c", + "is_verified": false, + "line_number": 33 } ], "docs/providers/openai.md": [ @@ -579,7 +10077,7 @@ "filename": "docs/providers/opencode.md", "hashed_secret": "ec3810e10fb78db55ce38b9c18d1c3eb1db739e0", "is_verified": false, - "line_number": 25 + "line_number": 27 } ], "docs/providers/openrouter.md": [ @@ -588,7 +10086,7 @@ "filename": "docs/providers/openrouter.md", "hashed_secret": "a219d7693c25cd2d93313512e200ff3eb374d281", "is_verified": false, - "line_number": 22 + "line_number": 24 } ], "docs/providers/synthetic.md": [ @@ -597,7 +10095,48 @@ "filename": "docs/providers/synthetic.md", "hashed_secret": "ec3810e10fb78db55ce38b9c18d1c3eb1db739e0", "is_verified": false, - "line_number": 31 + "line_number": 33 + } + ], + "docs/providers/venice.md": [ + { + "type": "Secret Keyword", + "filename": "docs/providers/venice.md", + "hashed_secret": "0b1b9301d9cd541620de4e3865d4a8f54f42fa89", + "is_verified": false, + "line_number": 55 + }, + { + "type": "Secret Keyword", + "filename": "docs/providers/venice.md", + "hashed_secret": "c179fe46776696372a90218532dc0d67267f2f04", + "is_verified": false, + "line_number": 236 + } + ], + "docs/providers/vllm.md": [ + { + "type": "Secret Keyword", + "filename": "docs/providers/vllm.md", + "hashed_secret": "6a4a6c8f2406f4f0843a0a1aae6a320f92f9d6ae", + "is_verified": false, + "line_number": 26 + } + ], + "docs/providers/xiaomi.md": [ + { + "type": "Secret Keyword", + "filename": "docs/providers/xiaomi.md", + "hashed_secret": "6d9c68c603e465077bdd49c62347fe54717f83a3", + "is_verified": false, + "line_number": 34 + }, + { + "type": "Secret Keyword", + "filename": "docs/providers/xiaomi.md", + "hashed_secret": "2369ac9988d706e53899168280d126c81c33bcd2", + "is_verified": false, + "line_number": 42 } ], "docs/providers/zai.md": [ @@ -606,7 +10145,7 @@ "filename": "docs/providers/zai.md", "hashed_secret": "ec3810e10fb78db55ce38b9c18d1c3eb1db739e0", "is_verified": false, - "line_number": 25 + "line_number": 27 } ], "docs/tools/browser.md": [ @@ -615,7 +10154,7 @@ "filename": "docs/tools/browser.md", "hashed_secret": "9d4e1e23bd5b727046a9e3b4b7db57bd8d6ee684", "is_verified": false, - "line_number": 163 + "line_number": 140 } ], "docs/tools/firecrawl.md": [ @@ -624,7 +10163,7 @@ "filename": "docs/tools/firecrawl.md", "hashed_secret": "674397e2c0c2faaa85961c708d2a96a7cc7af217", "is_verified": false, - "line_number": 28 + "line_number": 29 } ], "docs/tools/skills-config.md": [ @@ -633,7 +10172,7 @@ "filename": "docs/tools/skills-config.md", "hashed_secret": "c1e6ee547fd492df1441ac492e8bb294974712bd", "is_verified": false, - "line_number": 30 + "line_number": 29 } ], "docs/tools/skills.md": [ @@ -642,7 +10181,7 @@ "filename": "docs/tools/skills.md", "hashed_secret": "c1e6ee547fd492df1441ac492e8bb294974712bd", "is_verified": false, - "line_number": 160 + "line_number": 198 } ], "docs/tools/web.md": [ @@ -651,28 +10190,28 @@ "filename": "docs/tools/web.md", "hashed_secret": "6b26c117c66a0c030e239eef595c1e18865132a8", "is_verified": false, - "line_number": 61 + "line_number": 62 }, { "type": "Secret Keyword", "filename": "docs/tools/web.md", "hashed_secret": "96c682c88ed551f22fe76d206c2dfb7df9221ad9", "is_verified": false, - "line_number": 112 + "line_number": 113 }, { "type": "Secret Keyword", "filename": "docs/tools/web.md", "hashed_secret": "491d458f895b9213facb2ee9375b1b044eaea3ac", "is_verified": false, - "line_number": 160 + "line_number": 161 }, { "type": "Secret Keyword", "filename": "docs/tools/web.md", "hashed_secret": "674397e2c0c2faaa85961c708d2a96a7cc7af217", "is_verified": false, - "line_number": 223 + "line_number": 235 } ], "docs/tts.md": [ @@ -681,14 +10220,584 @@ "filename": "docs/tts.md", "hashed_secret": "bde4db9b4c3be4049adc3b9a69851d7c35119770", "is_verified": false, - "line_number": 72 + "line_number": 95 }, { "type": "Secret Keyword", "filename": "docs/tts.md", "hashed_secret": "1188d5a8ed7edcff5144a9472af960243eacf12e", "is_verified": false, - "line_number": 77 + "line_number": 100 + } + ], + "docs/zh-CN/brave-search.md": [ + { + "type": "Secret Keyword", + "filename": "docs/zh-CN/brave-search.md", + "hashed_secret": "491d458f895b9213facb2ee9375b1b044eaea3ac", + "is_verified": false, + "line_number": 34 + } + ], + "docs/zh-CN/channels/bluebubbles.md": [ + { + "type": "Secret Keyword", + "filename": "docs/zh-CN/channels/bluebubbles.md", + "hashed_secret": "555da20df20d4172e00f1b73d7c3943802055270", + "is_verified": false, + "line_number": 43 + } + ], + "docs/zh-CN/channels/feishu.md": [ + { + "type": "Secret Keyword", + "filename": "docs/zh-CN/channels/feishu.md", + "hashed_secret": "b60d121b438a380c343d5ec3c2037564b82ffef3", + "is_verified": false, + "line_number": 195 + }, + { + "type": "Secret Keyword", + "filename": "docs/zh-CN/channels/feishu.md", + "hashed_secret": "186154712b2d5f6791d85b9a0987b98fa231779c", + "is_verified": false, + "line_number": 445 + } + ], + "docs/zh-CN/channels/line.md": [ + { + "type": "Secret Keyword", + "filename": "docs/zh-CN/channels/line.md", + "hashed_secret": "83661b43df128631f891767fbfc5b049af3dce86", + "is_verified": false, + "line_number": 62 + } + ], + "docs/zh-CN/channels/matrix.md": [ + { + "type": "Secret Keyword", + "filename": "docs/zh-CN/channels/matrix.md", + "hashed_secret": "45d676e7c6ab44cf4b8fa366ef2d8fccd3e6d6e6", + "is_verified": false, + "line_number": 62 + } + ], + "docs/zh-CN/channels/nextcloud-talk.md": [ + { + "type": "Secret Keyword", + "filename": "docs/zh-CN/channels/nextcloud-talk.md", + "hashed_secret": "76ed0a056aa77060de25754586440cff390791d0", + "is_verified": false, + "line_number": 61 + } + ], + "docs/zh-CN/channels/nostr.md": [ + { + "type": "Secret Keyword", + "filename": "docs/zh-CN/channels/nostr.md", + "hashed_secret": "edeb23e25a619c434d22bb7f1c3ca4841166b4e8", + "is_verified": false, + "line_number": 74 + } + ], + "docs/zh-CN/channels/slack.md": [ + { + "type": "Secret Keyword", + "filename": "docs/zh-CN/channels/slack.md", + "hashed_secret": "3f4800fb7c1fb79a9a48bfd562d90bc6b2e2b718", + "is_verified": false, + "line_number": 153 + } + ], + "docs/zh-CN/channels/twitch.md": [ + { + "type": "Secret Keyword", + "filename": "docs/zh-CN/channels/twitch.md", + "hashed_secret": "0d1ba0da3e84e54f29846c93c43182eede365858", + "is_verified": false, + "line_number": 145 + }, + { + "type": "Secret Keyword", + "filename": "docs/zh-CN/channels/twitch.md", + "hashed_secret": "7cb4c5b8b81e266d08d4f106799af98d748bceb9", + "is_verified": false, + "line_number": 330 + } + ], + "docs/zh-CN/concepts/memory.md": [ + { + "type": "Secret Keyword", + "filename": "docs/zh-CN/concepts/memory.md", + "hashed_secret": "39d711243bfcee9fec8299b204e1aa9c3430fa12", + "is_verified": false, + "line_number": 127 + }, + { + "type": "Secret Keyword", + "filename": "docs/zh-CN/concepts/memory.md", + "hashed_secret": "1a8abbf465c52363ab4c9c6ad945b8e857cbea55", + "is_verified": false, + "line_number": 150 + }, + { + "type": "Secret Keyword", + "filename": "docs/zh-CN/concepts/memory.md", + "hashed_secret": "b9f640d6095b9f6b5a65983f7b76dbbb254e0044", + "is_verified": false, + "line_number": 398 + } + ], + "docs/zh-CN/concepts/model-providers.md": [ + { + "type": "Secret Keyword", + "filename": "docs/zh-CN/concepts/model-providers.md", + "hashed_secret": "ec3810e10fb78db55ce38b9c18d1c3eb1db739e0", + "is_verified": false, + "line_number": 181 + }, + { + "type": "Secret Keyword", + "filename": "docs/zh-CN/concepts/model-providers.md", + "hashed_secret": "ef83ad68b9b66e008727b7c417c6a8f618b5177e", + "is_verified": false, + "line_number": 282 + } + ], + "docs/zh-CN/gateway/configuration-examples.md": [ + { + "type": "Secret Keyword", + "filename": "docs/zh-CN/gateway/configuration-examples.md", + "hashed_secret": "a219d7693c25cd2d93313512e200ff3eb374d281", + "is_verified": false, + "line_number": 64 + }, + { + "type": "Secret Keyword", + "filename": "docs/zh-CN/gateway/configuration-examples.md", + "hashed_secret": "b6f56e5e92078ed7c078c46fbfeedcbe5719bc25", + "is_verified": false, + "line_number": 66 + }, + { + "type": "Secret Keyword", + "filename": "docs/zh-CN/gateway/configuration-examples.md", + "hashed_secret": "22af290a1a3d5e941193a41a3d3a9e4ca8da5e27", + "is_verified": false, + "line_number": 329 + }, + { + "type": "Secret Keyword", + "filename": "docs/zh-CN/gateway/configuration-examples.md", + "hashed_secret": "c1e6ee547fd492df1441ac492e8bb294974712bd", + "is_verified": false, + "line_number": 424 + }, + { + "type": "Secret Keyword", + "filename": "docs/zh-CN/gateway/configuration-examples.md", + "hashed_secret": "16c249e04e2be318050cb883c40137361c0c7209", + "is_verified": false, + "line_number": 563 + } + ], + "docs/zh-CN/gateway/configuration.md": [ + { + "type": "Secret Keyword", + "filename": "docs/zh-CN/gateway/configuration.md", + "hashed_secret": "a219d7693c25cd2d93313512e200ff3eb374d281", + "is_verified": false, + "line_number": 289 + }, + { + "type": "Secret Keyword", + "filename": "docs/zh-CN/gateway/configuration.md", + "hashed_secret": "b6f56e5e92078ed7c078c46fbfeedcbe5719bc25", + "is_verified": false, + "line_number": 291 + }, + { + "type": "Secret Keyword", + "filename": "docs/zh-CN/gateway/configuration.md", + "hashed_secret": "e5e9fa1ba31ecd1ae84f75caaa474f3a663f05f4", + "is_verified": false, + "line_number": 1092 + }, + { + "type": "Secret Keyword", + "filename": "docs/zh-CN/gateway/configuration.md", + "hashed_secret": "1188d5a8ed7edcff5144a9472af960243eacf12e", + "is_verified": false, + "line_number": 1570 + }, + { + "type": "Secret Keyword", + "filename": "docs/zh-CN/gateway/configuration.md", + "hashed_secret": "bde4db9b4c3be4049adc3b9a69851d7c35119770", + "is_verified": false, + "line_number": 1586 + }, + { + "type": "Secret Keyword", + "filename": "docs/zh-CN/gateway/configuration.md", + "hashed_secret": "22af290a1a3d5e941193a41a3d3a9e4ca8da5e27", + "is_verified": false, + "line_number": 2398 + }, + { + "type": "Secret Keyword", + "filename": "docs/zh-CN/gateway/configuration.md", + "hashed_secret": "ec3810e10fb78db55ce38b9c18d1c3eb1db739e0", + "is_verified": false, + "line_number": 2476 + }, + { + "type": "Secret Keyword", + "filename": "docs/zh-CN/gateway/configuration.md", + "hashed_secret": "c1e6ee547fd492df1441ac492e8bb294974712bd", + "is_verified": false, + "line_number": 2768 + }, + { + "type": "Secret Keyword", + "filename": "docs/zh-CN/gateway/configuration.md", + "hashed_secret": "45d676e7c6ab44cf4b8fa366ef2d8fccd3e6d6e6", + "is_verified": false, + "line_number": 2967 + } + ], + "docs/zh-CN/gateway/local-models.md": [ + { + "type": "Secret Keyword", + "filename": "docs/zh-CN/gateway/local-models.md", + "hashed_secret": "16c249e04e2be318050cb883c40137361c0c7209", + "is_verified": false, + "line_number": 41 + }, + { + "type": "Secret Keyword", + "filename": "docs/zh-CN/gateway/local-models.md", + "hashed_secret": "49fd535e63175a827aab3eff9ac58a9e82460ac9", + "is_verified": false, + "line_number": 131 + } + ], + "docs/zh-CN/gateway/tailscale.md": [ + { + "type": "Secret Keyword", + "filename": "docs/zh-CN/gateway/tailscale.md", + "hashed_secret": "9cb0dc5383312aa15b9dc6745645bde18ff5ade9", + "is_verified": false, + "line_number": 80 + } + ], + "docs/zh-CN/help/environment.md": [ + { + "type": "Secret Keyword", + "filename": "docs/zh-CN/help/environment.md", + "hashed_secret": "a219d7693c25cd2d93313512e200ff3eb374d281", + "is_verified": false, + "line_number": 38 + }, + { + "type": "Secret Keyword", + "filename": "docs/zh-CN/help/environment.md", + "hashed_secret": "b6f56e5e92078ed7c078c46fbfeedcbe5719bc25", + "is_verified": false, + "line_number": 40 + } + ], + "docs/zh-CN/help/faq.md": [ + { + "type": "Secret Keyword", + "filename": "docs/zh-CN/help/faq.md", + "hashed_secret": "491d458f895b9213facb2ee9375b1b044eaea3ac", + "is_verified": false, + "line_number": 1277 + }, + { + "type": "Secret Keyword", + "filename": "docs/zh-CN/help/faq.md", + "hashed_secret": "a219d7693c25cd2d93313512e200ff3eb374d281", + "is_verified": false, + "line_number": 1524 + }, + { + "type": "Secret Keyword", + "filename": "docs/zh-CN/help/faq.md", + "hashed_secret": "b6f56e5e92078ed7c078c46fbfeedcbe5719bc25", + "is_verified": false, + "line_number": 1525 + }, + { + "type": "Secret Keyword", + "filename": "docs/zh-CN/help/faq.md", + "hashed_secret": "ec3810e10fb78db55ce38b9c18d1c3eb1db739e0", + "is_verified": false, + "line_number": 1916 + }, + { + "type": "Secret Keyword", + "filename": "docs/zh-CN/help/faq.md", + "hashed_secret": "45d676e7c6ab44cf4b8fa366ef2d8fccd3e6d6e6", + "is_verified": false, + "line_number": 2191 + } + ], + "docs/zh-CN/install/macos-vm.md": [ + { + "type": "Secret Keyword", + "filename": "docs/zh-CN/install/macos-vm.md", + "hashed_secret": "8dd3bcd07c9ee927e6921c98b4dc6e94e2cc10a9", + "is_verified": false, + "line_number": 224 + } + ], + "docs/zh-CN/nodes/talk.md": [ + { + "type": "Secret Keyword", + "filename": "docs/zh-CN/nodes/talk.md", + "hashed_secret": "1188d5a8ed7edcff5144a9472af960243eacf12e", + "is_verified": false, + "line_number": 65 + } + ], + "docs/zh-CN/perplexity.md": [ + { + "type": "Secret Keyword", + "filename": "docs/zh-CN/perplexity.md", + "hashed_secret": "6b26c117c66a0c030e239eef595c1e18865132a8", + "is_verified": false, + "line_number": 42 + } + ], + "docs/zh-CN/plugins/voice-call.md": [ + { + "type": "Secret Keyword", + "filename": "docs/zh-CN/plugins/voice-call.md", + "hashed_secret": "cb46980ce5532f18440dff4bbbe097896a8c08c8", + "is_verified": false, + "line_number": 167 + } + ], + "docs/zh-CN/providers/anthropic.md": [ + { + "type": "Secret Keyword", + "filename": "docs/zh-CN/providers/anthropic.md", + "hashed_secret": "c7a8c334eef5d1749fface7d42c66f9ae5e8cf36", + "is_verified": false, + "line_number": 40 + } + ], + "docs/zh-CN/providers/claude-max-api-proxy.md": [ + { + "type": "Secret Keyword", + "filename": "docs/zh-CN/providers/claude-max-api-proxy.md", + "hashed_secret": "b5c2827eb65bf13b87130e7e3c424ba9ff07cd67", + "is_verified": false, + "line_number": 87 + } + ], + "docs/zh-CN/providers/glm.md": [ + { + "type": "Secret Keyword", + "filename": "docs/zh-CN/providers/glm.md", + "hashed_secret": "ec3810e10fb78db55ce38b9c18d1c3eb1db739e0", + "is_verified": false, + "line_number": 30 + } + ], + "docs/zh-CN/providers/minimax.md": [ + { + "type": "Secret Keyword", + "filename": "docs/zh-CN/providers/minimax.md", + "hashed_secret": "ec3810e10fb78db55ce38b9c18d1c3eb1db739e0", + "is_verified": false, + "line_number": 72 + }, + { + "type": "Secret Keyword", + "filename": "docs/zh-CN/providers/minimax.md", + "hashed_secret": "16c249e04e2be318050cb883c40137361c0c7209", + "is_verified": false, + "line_number": 140 + } + ], + "docs/zh-CN/providers/moonshot.md": [ + { + "type": "Secret Keyword", + "filename": "docs/zh-CN/providers/moonshot.md", + "hashed_secret": "ec3810e10fb78db55ce38b9c18d1c3eb1db739e0", + "is_verified": false, + "line_number": 47 + } + ], + "docs/zh-CN/providers/ollama.md": [ + { + "type": "Secret Keyword", + "filename": "docs/zh-CN/providers/ollama.md", + "hashed_secret": "e774aaeac31c6272107ba89080295e277050fa7c", + "is_verified": false, + "line_number": 38 + } + ], + "docs/zh-CN/providers/openai.md": [ + { + "type": "Secret Keyword", + "filename": "docs/zh-CN/providers/openai.md", + "hashed_secret": "ec3810e10fb78db55ce38b9c18d1c3eb1db739e0", + "is_verified": false, + "line_number": 37 + } + ], + "docs/zh-CN/providers/opencode.md": [ + { + "type": "Secret Keyword", + "filename": "docs/zh-CN/providers/opencode.md", + "hashed_secret": "ec3810e10fb78db55ce38b9c18d1c3eb1db739e0", + "is_verified": false, + "line_number": 32 + } + ], + "docs/zh-CN/providers/openrouter.md": [ + { + "type": "Secret Keyword", + "filename": "docs/zh-CN/providers/openrouter.md", + "hashed_secret": "a219d7693c25cd2d93313512e200ff3eb374d281", + "is_verified": false, + "line_number": 30 + } + ], + "docs/zh-CN/providers/synthetic.md": [ + { + "type": "Secret Keyword", + "filename": "docs/zh-CN/providers/synthetic.md", + "hashed_secret": "ec3810e10fb78db55ce38b9c18d1c3eb1db739e0", + "is_verified": false, + "line_number": 39 + } + ], + "docs/zh-CN/providers/venice.md": [ + { + "type": "Secret Keyword", + "filename": "docs/zh-CN/providers/venice.md", + "hashed_secret": "0b1b9301d9cd541620de4e3865d4a8f54f42fa89", + "is_verified": false, + "line_number": 62 + }, + { + "type": "Secret Keyword", + "filename": "docs/zh-CN/providers/venice.md", + "hashed_secret": "c179fe46776696372a90218532dc0d67267f2f04", + "is_verified": false, + "line_number": 243 + } + ], + "docs/zh-CN/providers/xiaomi.md": [ + { + "type": "Secret Keyword", + "filename": "docs/zh-CN/providers/xiaomi.md", + "hashed_secret": "6d9c68c603e465077bdd49c62347fe54717f83a3", + "is_verified": false, + "line_number": 38 + }, + { + "type": "Secret Keyword", + "filename": "docs/zh-CN/providers/xiaomi.md", + "hashed_secret": "2369ac9988d706e53899168280d126c81c33bcd2", + "is_verified": false, + "line_number": 46 + } + ], + "docs/zh-CN/providers/zai.md": [ + { + "type": "Secret Keyword", + "filename": "docs/zh-CN/providers/zai.md", + "hashed_secret": "ec3810e10fb78db55ce38b9c18d1c3eb1db739e0", + "is_verified": false, + "line_number": 32 + } + ], + "docs/zh-CN/tools/browser.md": [ + { + "type": "Basic Auth Credentials", + "filename": "docs/zh-CN/tools/browser.md", + "hashed_secret": "9d4e1e23bd5b727046a9e3b4b7db57bd8d6ee684", + "is_verified": false, + "line_number": 137 + } + ], + "docs/zh-CN/tools/firecrawl.md": [ + { + "type": "Secret Keyword", + "filename": "docs/zh-CN/tools/firecrawl.md", + "hashed_secret": "674397e2c0c2faaa85961c708d2a96a7cc7af217", + "is_verified": false, + "line_number": 36 + } + ], + "docs/zh-CN/tools/skills-config.md": [ + { + "type": "Secret Keyword", + "filename": "docs/zh-CN/tools/skills-config.md", + "hashed_secret": "c1e6ee547fd492df1441ac492e8bb294974712bd", + "is_verified": false, + "line_number": 36 + } + ], + "docs/zh-CN/tools/skills.md": [ + { + "type": "Secret Keyword", + "filename": "docs/zh-CN/tools/skills.md", + "hashed_secret": "c1e6ee547fd492df1441ac492e8bb294974712bd", + "is_verified": false, + "line_number": 183 + } + ], + "docs/zh-CN/tools/web.md": [ + { + "type": "Secret Keyword", + "filename": "docs/zh-CN/tools/web.md", + "hashed_secret": "6b26c117c66a0c030e239eef595c1e18865132a8", + "is_verified": false, + "line_number": 67 + }, + { + "type": "Secret Keyword", + "filename": "docs/zh-CN/tools/web.md", + "hashed_secret": "96c682c88ed551f22fe76d206c2dfb7df9221ad9", + "is_verified": false, + "line_number": 112 + }, + { + "type": "Secret Keyword", + "filename": "docs/zh-CN/tools/web.md", + "hashed_secret": "491d458f895b9213facb2ee9375b1b044eaea3ac", + "is_verified": false, + "line_number": 159 + }, + { + "type": "Secret Keyword", + "filename": "docs/zh-CN/tools/web.md", + "hashed_secret": "674397e2c0c2faaa85961c708d2a96a7cc7af217", + "is_verified": false, + "line_number": 229 + } + ], + "docs/zh-CN/tts.md": [ + { + "type": "Secret Keyword", + "filename": "docs/zh-CN/tts.md", + "hashed_secret": "bde4db9b4c3be4049adc3b9a69851d7c35119770", + "is_verified": false, + "line_number": 89 + }, + { + "type": "Secret Keyword", + "filename": "docs/zh-CN/tts.md", + "hashed_secret": "1188d5a8ed7edcff5144a9472af960243eacf12e", + "is_verified": false, + "line_number": 94 } ], "extensions/bluebubbles/src/actions.test.ts": [ @@ -697,7 +10806,7 @@ "filename": "extensions/bluebubbles/src/actions.test.ts", "hashed_secret": "789cbe0407840b1c2041cb33452ff60f19bf58cc", "is_verified": false, - "line_number": 73 + "line_number": 86 } ], "extensions/bluebubbles/src/attachments.test.ts": [ @@ -706,28 +10815,28 @@ "filename": "extensions/bluebubbles/src/attachments.test.ts", "hashed_secret": "789cbe0407840b1c2041cb33452ff60f19bf58cc", "is_verified": false, - "line_number": 35 + "line_number": 21 }, { "type": "Secret Keyword", "filename": "extensions/bluebubbles/src/attachments.test.ts", "hashed_secret": "db1530e1ea43af094d3d75b8dbaf19a4a182a318", "is_verified": false, - "line_number": 99 + "line_number": 85 }, { "type": "Secret Keyword", "filename": "extensions/bluebubbles/src/attachments.test.ts", "hashed_secret": "a94a8fe5ccb19ba61c4c0873d391e987982fbbd3", "is_verified": false, - "line_number": 117 + "line_number": 103 }, { "type": "Secret Keyword", "filename": "extensions/bluebubbles/src/attachments.test.ts", "hashed_secret": "052f076c732648ab32d2fcde9fe255319bfa0c7b", "is_verified": false, - "line_number": 229 + "line_number": 215 } ], "extensions/bluebubbles/src/chat.test.ts": [ @@ -736,42 +10845,42 @@ "filename": "extensions/bluebubbles/src/chat.test.ts", "hashed_secret": "a94a8fe5ccb19ba61c4c0873d391e987982fbbd3", "is_verified": false, - "line_number": 33 + "line_number": 19 }, { "type": "Secret Keyword", "filename": "extensions/bluebubbles/src/chat.test.ts", "hashed_secret": "789cbe0407840b1c2041cb33452ff60f19bf58cc", "is_verified": false, - "line_number": 68 + "line_number": 54 }, { "type": "Secret Keyword", "filename": "extensions/bluebubbles/src/chat.test.ts", "hashed_secret": "5c5a15a8b0b3e154d77746945e563ba40100681b", "is_verified": false, - "line_number": 85 + "line_number": 82 }, { "type": "Secret Keyword", "filename": "extensions/bluebubbles/src/chat.test.ts", "hashed_secret": "faacad0ce4ea1c19b46e128fd79679d37d3d331d", "is_verified": false, - "line_number": 134 + "line_number": 131 }, { "type": "Secret Keyword", "filename": "extensions/bluebubbles/src/chat.test.ts", "hashed_secret": "4dcc26a1d99532846fedf1265df4f40f4e0005b8", "is_verified": false, - "line_number": 219 + "line_number": 227 }, { "type": "Secret Keyword", "filename": "extensions/bluebubbles/src/chat.test.ts", "hashed_secret": "fd2a721f7be1ee3d691a011affcdb11d0ca365a8", "is_verified": false, - "line_number": 282 + "line_number": 290 } ], "extensions/bluebubbles/src/monitor.test.ts": [ @@ -780,14 +10889,14 @@ "filename": "extensions/bluebubbles/src/monitor.test.ts", "hashed_secret": "789cbe0407840b1c2041cb33452ff60f19bf58cc", "is_verified": false, - "line_number": 187 + "line_number": 278 }, { "type": "Secret Keyword", "filename": "extensions/bluebubbles/src/monitor.test.ts", "hashed_secret": "1ae0af3fe72b3ba394f9fa95a6cffc090d726c23", "is_verified": false, - "line_number": 394 + "line_number": 552 } ], "extensions/bluebubbles/src/reactions.test.ts": [ @@ -796,28 +10905,28 @@ "filename": "extensions/bluebubbles/src/reactions.test.ts", "hashed_secret": "a94a8fe5ccb19ba61c4c0873d391e987982fbbd3", "is_verified": false, - "line_number": 38 + "line_number": 37 }, { "type": "Secret Keyword", "filename": "extensions/bluebubbles/src/reactions.test.ts", "hashed_secret": "789cbe0407840b1c2041cb33452ff60f19bf58cc", "is_verified": false, - "line_number": 179 + "line_number": 178 }, { "type": "Secret Keyword", "filename": "extensions/bluebubbles/src/reactions.test.ts", "hashed_secret": "a4a05c9a6449eb9d6cdac81dd7edc49230e327e6", "is_verified": false, - "line_number": 210 + "line_number": 209 }, { "type": "Secret Keyword", "filename": "extensions/bluebubbles/src/reactions.test.ts", "hashed_secret": "a2833da9f0a16f09994754d0a31749cecf8c8c77", "is_verified": false, - "line_number": 316 + "line_number": 315 } ], "extensions/bluebubbles/src/send.test.ts": [ @@ -826,14 +10935,14 @@ "filename": "extensions/bluebubbles/src/send.test.ts", "hashed_secret": "a94a8fe5ccb19ba61c4c0873d391e987982fbbd3", "is_verified": false, - "line_number": 38 + "line_number": 55 }, { "type": "Secret Keyword", "filename": "extensions/bluebubbles/src/send.test.ts", "hashed_secret": "faacad0ce4ea1c19b46e128fd79679d37d3d331d", "is_verified": false, - "line_number": 675 + "line_number": 692 } ], "extensions/bluebubbles/src/targets.test.ts": [ @@ -842,7 +10951,7 @@ "filename": "extensions/bluebubbles/src/targets.test.ts", "hashed_secret": "a3af2fb0c1e2a30bb038049e1e4b401593af6225", "is_verified": false, - "line_number": 62 + "line_number": 61 } ], "extensions/bluebubbles/src/targets.ts": [ @@ -851,7 +10960,7 @@ "filename": "extensions/bluebubbles/src/targets.ts", "hashed_secret": "a3af2fb0c1e2a30bb038049e1e4b401593af6225", "is_verified": false, - "line_number": 214 + "line_number": 265 } ], "extensions/copilot-proxy/index.ts": [ @@ -860,7 +10969,61 @@ "filename": "extensions/copilot-proxy/index.ts", "hashed_secret": "50f013532a9770a2c2cfdc38b7581dd01df69b70", "is_verified": false, - "line_number": 4 + "line_number": 9 + } + ], + "extensions/feishu/skills/feishu-doc/SKILL.md": [ + { + "type": "Hex High Entropy String", + "filename": "extensions/feishu/skills/feishu-doc/SKILL.md", + "hashed_secret": "8a2256bca273bb01a4e09ae6555b1e6652d9ff8c", + "is_verified": false, + "line_number": 20 + } + ], + "extensions/feishu/skills/feishu-wiki/SKILL.md": [ + { + "type": "Hex High Entropy String", + "filename": "extensions/feishu/skills/feishu-wiki/SKILL.md", + "hashed_secret": "8a2256bca273bb01a4e09ae6555b1e6652d9ff8c", + "is_verified": false, + "line_number": 40 + } + ], + "extensions/feishu/src/channel.test.ts": [ + { + "type": "Secret Keyword", + "filename": "extensions/feishu/src/channel.test.ts", + "hashed_secret": "8437d84cae482d10a2b9fd3f555d45006979e4be", + "is_verified": false, + "line_number": 21 + } + ], + "extensions/feishu/src/docx.test.ts": [ + { + "type": "Secret Keyword", + "filename": "extensions/feishu/src/docx.test.ts", + "hashed_secret": "f49922d511d666848f250663c4fca84074b856a8", + "is_verified": false, + "line_number": 97 + } + ], + "extensions/feishu/src/media.test.ts": [ + { + "type": "Secret Keyword", + "filename": "extensions/feishu/src/media.test.ts", + "hashed_secret": "f49922d511d666848f250663c4fca84074b856a8", + "is_verified": false, + "line_number": 45 + } + ], + "extensions/feishu/src/reply-dispatcher.test.ts": [ + { + "type": "Secret Keyword", + "filename": "extensions/feishu/src/reply-dispatcher.test.ts", + "hashed_secret": "f49922d511d666848f250663c4fca84074b856a8", + "is_verified": false, + "line_number": 48 } ], "extensions/google-antigravity-auth/index.ts": [ @@ -869,7 +11032,50 @@ "filename": "extensions/google-antigravity-auth/index.ts", "hashed_secret": "709d0f232b6ac4f8d24dec3e4fabfdb14257174f", "is_verified": false, - "line_number": 9 + "line_number": 14 + } + ], + "extensions/google-gemini-cli-auth/oauth.test.ts": [ + { + "type": "Secret Keyword", + "filename": "extensions/google-gemini-cli-auth/oauth.test.ts", + "hashed_secret": "021343c1f561d7bcbc3b513df45cc3a6baf67b43", + "is_verified": false, + "line_number": 30 + } + ], + "extensions/irc/src/accounts.ts": [ + { + "type": "Secret Keyword", + "filename": "extensions/irc/src/accounts.ts", + "hashed_secret": "920f8f5815b381ea692e9e7c2f7119f2b1aa620a", + "is_verified": false, + "line_number": 19 + } + ], + "extensions/irc/src/client.test.ts": [ + { + "type": "Secret Keyword", + "filename": "extensions/irc/src/client.test.ts", + "hashed_secret": "e5e9fa1ba31ecd1ae84f75caaa474f3a663f05f4", + "is_verified": false, + "line_number": 8 + }, + { + "type": "Secret Keyword", + "filename": "extensions/irc/src/client.test.ts", + "hashed_secret": "b1cc3814a07fc3d7094f4cc181df7b57b51d165b", + "is_verified": false, + "line_number": 39 + } + ], + "extensions/line/src/channel.startup.test.ts": [ + { + "type": "Secret Keyword", + "filename": "extensions/line/src/channel.startup.test.ts", + "hashed_secret": "e5e9fa1ba31ecd1ae84f75caaa474f3a663f05f4", + "is_verified": false, + "line_number": 103 } ], "extensions/matrix/src/matrix/accounts.test.ts": [ @@ -878,7 +11084,7 @@ "filename": "extensions/matrix/src/matrix/accounts.test.ts", "hashed_secret": "e5e9fa1ba31ecd1ae84f75caaa474f3a663f05f4", "is_verified": false, - "line_number": 75 + "line_number": 74 } ], "extensions/matrix/src/matrix/client.test.ts": [ @@ -887,14 +11093,14 @@ "filename": "extensions/matrix/src/matrix/client.test.ts", "hashed_secret": "fe7fcdaea49ece14677acd32374d2f1225819d5c", "is_verified": false, - "line_number": 14 + "line_number": 13 }, { "type": "Secret Keyword", "filename": "extensions/matrix/src/matrix/client.test.ts", "hashed_secret": "3dc927d80543dc0f643940b70d066bd4b4c4b78e", "is_verified": false, - "line_number": 24 + "line_number": 23 } ], "extensions/matrix/src/matrix/client/storage.ts": [ @@ -903,7 +11109,7 @@ "filename": "extensions/matrix/src/matrix/client/storage.ts", "hashed_secret": "7505d64a54e061b7acd54ccd58b49dc43500b635", "is_verified": false, - "line_number": 9 + "line_number": 8 } ], "extensions/memory-lancedb/config.ts": [ @@ -912,7 +11118,7 @@ "filename": "extensions/memory-lancedb/config.ts", "hashed_secret": "ecb252044b5ea0f679ee78ec1a12904739e2904d", "is_verified": false, - "line_number": 70 + "line_number": 101 } ], "extensions/memory-lancedb/index.test.ts": [ @@ -921,7 +11127,7 @@ "filename": "extensions/memory-lancedb/index.test.ts", "hashed_secret": "ed65c049bb2f78ee4f703b2158ba9cc6ea31fb7e", "is_verified": false, - "line_number": 70 + "line_number": 71 } ], "extensions/msteams/src/probe.test.ts": [ @@ -930,7 +11136,7 @@ "filename": "extensions/msteams/src/probe.test.ts", "hashed_secret": "1a91d62f7ca67399625a4368a6ab5d4a3baa6073", "is_verified": false, - "line_number": 34 + "line_number": 35 } ], "extensions/nextcloud-talk/src/accounts.ts": [ @@ -939,14 +11145,14 @@ "filename": "extensions/nextcloud-talk/src/accounts.ts", "hashed_secret": "920f8f5815b381ea692e9e7c2f7119f2b1aa620a", "is_verified": false, - "line_number": 26 + "line_number": 22 }, { "type": "Secret Keyword", "filename": "extensions/nextcloud-talk/src/accounts.ts", "hashed_secret": "71f8e7976e4cbc4561c9d62fb283e7f788202acb", "is_verified": false, - "line_number": 139 + "line_number": 151 } ], "extensions/nextcloud-talk/src/channel.ts": [ @@ -955,7 +11161,7 @@ "filename": "extensions/nextcloud-talk/src/channel.ts", "hashed_secret": "71f8e7976e4cbc4561c9d62fb283e7f788202acb", "is_verified": false, - "line_number": 390 + "line_number": 396 } ], "extensions/nostr/README.md": [ @@ -964,7 +11170,7 @@ "filename": "extensions/nostr/README.md", "hashed_secret": "edeb23e25a619c434d22bb7f1c3ca4841166b4e8", "is_verified": false, - "line_number": 43 + "line_number": 46 } ], "extensions/nostr/src/channel.test.ts": [ @@ -989,21 +11195,21 @@ "filename": "extensions/nostr/src/nostr-bus.fuzz.test.ts", "hashed_secret": "2b4489606a23fb31fcdc849fa7e577ba90f6d39a", "is_verified": false, - "line_number": 202 + "line_number": 193 }, { "type": "Hex High Entropy String", "filename": "extensions/nostr/src/nostr-bus.fuzz.test.ts", "hashed_secret": "ce4303f6b22257d9c9cf314ef1dee4707c6e1c13", "is_verified": false, - "line_number": 203 + "line_number": 194 }, { "type": "Hex High Entropy String", "filename": "extensions/nostr/src/nostr-bus.fuzz.test.ts", "hashed_secret": "b84cb0c3925d34496e6c8b0e55b8c1664a438035", "is_verified": false, - "line_number": 208 + "line_number": 199 } ], "extensions/nostr/src/nostr-bus.test.ts": [ @@ -1049,7 +11255,7 @@ "filename": "extensions/nostr/src/nostr-profile.fuzz.test.ts", "hashed_secret": "ce4303f6b22257d9c9cf314ef1dee4707c6e1c13", "is_verified": false, - "line_number": 12 + "line_number": 11 } ], "extensions/nostr/src/nostr-profile.test.ts": [ @@ -1067,21 +11273,21 @@ "filename": "extensions/nostr/src/types.test.ts", "hashed_secret": "ce4303f6b22257d9c9cf314ef1dee4707c6e1c13", "is_verified": false, - "line_number": 8 + "line_number": 4 }, { "type": "Secret Keyword", "filename": "extensions/nostr/src/types.test.ts", "hashed_secret": "ce4303f6b22257d9c9cf314ef1dee4707c6e1c13", "is_verified": false, - "line_number": 8 + "line_number": 4 }, { "type": "Secret Keyword", "filename": "extensions/nostr/src/types.test.ts", "hashed_secret": "3bee216ebc256d692260fc3adc765050508fef5e", "is_verified": false, - "line_number": 127 + "line_number": 123 } ], "extensions/open-prose/skills/prose/SKILL.md": [ @@ -1090,7 +11296,7 @@ "filename": "extensions/open-prose/skills/prose/SKILL.md", "hashed_secret": "9d4e1e23bd5b727046a9e3b4b7db57bd8d6ee684", "is_verified": false, - "line_number": 200 + "line_number": 204 } ], "extensions/open-prose/skills/prose/state/postgres.md": [ @@ -1099,14 +11305,66 @@ "filename": "extensions/open-prose/skills/prose/state/postgres.md", "hashed_secret": "fa9beb99e4029ad5a6615399e7bbae21356086b3", "is_verified": false, - "line_number": 75 + "line_number": 77 }, { "type": "Basic Auth Credentials", "filename": "extensions/open-prose/skills/prose/state/postgres.md", "hashed_secret": "9d4e1e23bd5b727046a9e3b4b7db57bd8d6ee684", "is_verified": false, - "line_number": 198 + "line_number": 200 + } + ], + "extensions/twitch/src/onboarding.test.ts": [ + { + "type": "Secret Keyword", + "filename": "extensions/twitch/src/onboarding.test.ts", + "hashed_secret": "f2b14f68eb995facb3a1c35287b778d5bd785511", + "is_verified": false, + "line_number": 239 + }, + { + "type": "Secret Keyword", + "filename": "extensions/twitch/src/onboarding.test.ts", + "hashed_secret": "c8d8f8140951794fa875ea2c2d010c4382f36566", + "is_verified": false, + "line_number": 249 + } + ], + "extensions/twitch/src/status.test.ts": [ + { + "type": "Secret Keyword", + "filename": "extensions/twitch/src/status.test.ts", + "hashed_secret": "f2b14f68eb995facb3a1c35287b778d5bd785511", + "is_verified": false, + "line_number": 122 + } + ], + "extensions/voice-call/README.md": [ + { + "type": "Secret Keyword", + "filename": "extensions/voice-call/README.md", + "hashed_secret": "48004f85d79e636cfd408c3baddcb1f0bbdd611a", + "is_verified": false, + "line_number": 49 + } + ], + "extensions/voice-call/src/config.test.ts": [ + { + "type": "Secret Keyword", + "filename": "extensions/voice-call/src/config.test.ts", + "hashed_secret": "62207a469ec2fdcfc7d66b04c2980ac1501acbf0", + "is_verified": false, + "line_number": 129 + } + ], + "extensions/voice-call/src/providers/telnyx.test.ts": [ + { + "type": "Secret Keyword", + "filename": "extensions/voice-call/src/providers/telnyx.test.ts", + "hashed_secret": "62207a469ec2fdcfc7d66b04c2980ac1501acbf0", + "is_verified": false, + "line_number": 30 } ], "extensions/zalo/README.md": [ @@ -1124,7 +11382,7 @@ "filename": "extensions/zalo/src/monitor.webhook.test.ts", "hashed_secret": "e5e9fa1ba31ecd1ae84f75caaa474f3a663f05f4", "is_verified": false, - "line_number": 43 + "line_number": 40 } ], "skills/1password/references/cli-examples.md": [ @@ -1136,22 +11394,13 @@ "line_number": 17 } ], - "skills/local-places/SERVER_README.md": [ - { - "type": "Secret Keyword", - "filename": "skills/local-places/SERVER_README.md", - "hashed_secret": "6d9c68c603e465077bdd49c62347fe54717f83a3", - "is_verified": false, - "line_number": 28 - } - ], "skills/openai-whisper-api/SKILL.md": [ { "type": "Secret Keyword", "filename": "skills/openai-whisper-api/SKILL.md", "hashed_secret": "1077361f94d70e1ddcc7c6dc581a489532a81d03", "is_verified": false, - "line_number": 39 + "line_number": 48 } ], "skills/trello/SKILL.md": [ @@ -1160,46 +11409,85 @@ "filename": "skills/trello/SKILL.md", "hashed_secret": "11fa7c37d697f30e6aee828b4426a10f83ab2380", "is_verified": false, - "line_number": 18 + "line_number": 22 } ], - "src/agents/memory-search.test.ts": [ + "src/agents/compaction.tool-result-details.e2e.test.ts": [ { "type": "Secret Keyword", - "filename": "src/agents/memory-search.test.ts", + "filename": "src/agents/compaction.tool-result-details.e2e.test.ts", + "hashed_secret": "a94a8fe5ccb19ba61c4c0873d391e987982fbbd3", + "is_verified": false, + "line_number": 50 + } + ], + "src/agents/memory-search.e2e.test.ts": [ + { + "type": "Secret Keyword", + "filename": "src/agents/memory-search.e2e.test.ts", "hashed_secret": "a1b49d68a91fdf9c9217773f3fac988d77fa0f50", "is_verified": false, - "line_number": 164 + "line_number": 189 } ], - "src/agents/model-auth.test.ts": [ + "src/agents/minimax-vlm.normalizes-api-key.e2e.test.ts": [ { "type": "Secret Keyword", - "filename": "src/agents/model-auth.test.ts", + "filename": "src/agents/minimax-vlm.normalizes-api-key.e2e.test.ts", + "hashed_secret": "8a8461b67e3fe515f248ac2610fd7b1f4fc3b412", + "is_verified": false, + "line_number": 28 + } + ], + "src/agents/model-auth.e2e.test.ts": [ + { + "type": "Secret Keyword", + "filename": "src/agents/model-auth.e2e.test.ts", "hashed_secret": "07a6b9cec637c806195e8aa7e5c0851ab03dc35e", "is_verified": false, - "line_number": 211 + "line_number": 228 }, { "type": "Secret Keyword", - "filename": "src/agents/model-auth.test.ts", + "filename": "src/agents/model-auth.e2e.test.ts", "hashed_secret": "21f296583ccd80c5ab9b3330a8b0d47e4a409fb9", "is_verified": false, - "line_number": 240 + "line_number": 254 }, { "type": "Secret Keyword", - "filename": "src/agents/model-auth.test.ts", + "filename": "src/agents/model-auth.e2e.test.ts", + "hashed_secret": "b65888424ecafcc98bfd803b24817e4dadf821f8", + "is_verified": false, + "line_number": 275 + }, + { + "type": "Secret Keyword", + "filename": "src/agents/model-auth.e2e.test.ts", "hashed_secret": "77e991e9f56e6fa4ed1a908208048421f1214c07", "is_verified": false, - "line_number": 264 + "line_number": 296 }, { "type": "Secret Keyword", - "filename": "src/agents/model-auth.test.ts", + "filename": "src/agents/model-auth.e2e.test.ts", "hashed_secret": "dff6d4ff5dc357cf451d1855ab9cbda562645c9f", "is_verified": false, - "line_number": 295 + "line_number": 319 + }, + { + "type": "Secret Keyword", + "filename": "src/agents/model-auth.e2e.test.ts", + "hashed_secret": "b43be360db55d89ec6afd74d6ed8f82002fe4982", + "is_verified": false, + "line_number": 374 + }, + { + "type": "Secret Keyword", + "filename": "src/agents/model-auth.e2e.test.ts", + "hashed_secret": "5b850e9dc678446137ff6d905ebd78634d687fdd", + "is_verified": false, + "line_number": 395 } ], "src/agents/model-auth.ts": [ @@ -1208,96 +11496,91 @@ "filename": "src/agents/model-auth.ts", "hashed_secret": "8956265d216d474a080edaa97880d37fc1386f33", "is_verified": false, - "line_number": 22 + "line_number": 25 } ], - "src/agents/models-config.auto-injects-github-copilot-provider-token-is.test.ts": [ + "src/agents/models-config.e2e-harness.ts": [ { "type": "Secret Keyword", - "filename": "src/agents/models-config.auto-injects-github-copilot-provider-token-is.test.ts", + "filename": "src/agents/models-config.e2e-harness.ts", "hashed_secret": "7cf31e8b6cda49f70c31f1f25af05d46f924142d", "is_verified": false, - "line_number": 16 + "line_number": 110 } ], - "src/agents/models-config.falls-back-default-baseurl-token-exchange-fails.test.ts": [ + "src/agents/models-config.fills-missing-provider-apikey-from-env-var.e2e.test.ts": [ { "type": "Secret Keyword", - "filename": "src/agents/models-config.falls-back-default-baseurl-token-exchange-fails.test.ts", - "hashed_secret": "7cf31e8b6cda49f70c31f1f25af05d46f924142d", - "is_verified": false, - "line_number": 16 - } - ], - "src/agents/models-config.fills-missing-provider-apikey-from-env-var.test.ts": [ - { - "type": "Secret Keyword", - "filename": "src/agents/models-config.fills-missing-provider-apikey-from-env-var.test.ts", - "hashed_secret": "7cf31e8b6cda49f70c31f1f25af05d46f924142d", - "is_verified": false, - "line_number": 16 - }, - { - "type": "Secret Keyword", - "filename": "src/agents/models-config.fills-missing-provider-apikey-from-env-var.test.ts", + "filename": "src/agents/models-config.fills-missing-provider-apikey-from-env-var.e2e.test.ts", "hashed_secret": "fcdd655b11f33ba4327695084a347b2ba192976c", "is_verified": false, - "line_number": 50 + "line_number": 19 }, { "type": "Secret Keyword", - "filename": "src/agents/models-config.fills-missing-provider-apikey-from-env-var.test.ts", + "filename": "src/agents/models-config.fills-missing-provider-apikey-from-env-var.e2e.test.ts", "hashed_secret": "3a81eb091f80c845232225be5663d270e90dacb7", "is_verified": false, - "line_number": 108 + "line_number": 73 } ], - "src/agents/models-config.normalizes-gemini-3-ids-preview-google-providers.test.ts": [ + "src/agents/models-config.normalizes-gemini-3-ids-preview-google-providers.e2e.test.ts": [ { "type": "Secret Keyword", - "filename": "src/agents/models-config.normalizes-gemini-3-ids-preview-google-providers.test.ts", - "hashed_secret": "7cf31e8b6cda49f70c31f1f25af05d46f924142d", - "is_verified": false, - "line_number": 16 - }, - { - "type": "Secret Keyword", - "filename": "src/agents/models-config.normalizes-gemini-3-ids-preview-google-providers.test.ts", + "filename": "src/agents/models-config.normalizes-gemini-3-ids-preview-google-providers.e2e.test.ts", "hashed_secret": "980d02eb9335ae7c9e9984f6c8ad432352a0d2ac", "is_verified": false, - "line_number": 57 + "line_number": 20 } ], - "src/agents/models-config.skips-writing-models-json-no-env-token.test.ts": [ + "src/agents/models-config.providers.nvidia.test.ts": [ { "type": "Secret Keyword", - "filename": "src/agents/models-config.skips-writing-models-json-no-env-token.test.ts", - "hashed_secret": "7cf31e8b6cda49f70c31f1f25af05d46f924142d", + "filename": "src/agents/models-config.providers.nvidia.test.ts", + "hashed_secret": "3acfb2c2b433c0ea7ff107e33df91b18e52f960f", "is_verified": false, - "line_number": 16 + "line_number": 13 }, { "type": "Secret Keyword", - "filename": "src/agents/models-config.skips-writing-models-json-no-env-token.test.ts", - "hashed_secret": "fcdd655b11f33ba4327695084a347b2ba192976c", + "filename": "src/agents/models-config.providers.nvidia.test.ts", + "hashed_secret": "be1a7be9d4d5af417882b267f4db6dddc08507bd", "is_verified": false, - "line_number": 112 + "line_number": 27 + } + ], + "src/agents/models-config.providers.ollama.e2e.test.ts": [ + { + "type": "Secret Keyword", + "filename": "src/agents/models-config.providers.ollama.e2e.test.ts", + "hashed_secret": "3acfb2c2b433c0ea7ff107e33df91b18e52f960f", + "is_verified": false, + "line_number": 37 + } + ], + "src/agents/models-config.providers.qianfan.e2e.test.ts": [ + { + "type": "Secret Keyword", + "filename": "src/agents/models-config.providers.qianfan.e2e.test.ts", + "hashed_secret": "3acfb2c2b433c0ea7ff107e33df91b18e52f960f", + "is_verified": false, + "line_number": 12 + } + ], + "src/agents/models-config.skips-writing-models-json-no-env-token.e2e.test.ts": [ + { + "type": "Secret Keyword", + "filename": "src/agents/models-config.skips-writing-models-json-no-env-token.e2e.test.ts", + "hashed_secret": "4c7bac93427c83bcc3beeceebfa54f16f801b78f", + "is_verified": false, + "line_number": 100 }, { "type": "Secret Keyword", - "filename": "src/agents/models-config.skips-writing-models-json-no-env-token.test.ts", - "hashed_secret": "94c4be5a1976115e8152960c21e04400a4fccdf6", + "filename": "src/agents/models-config.skips-writing-models-json-no-env-token.e2e.test.ts", + "hashed_secret": "4f2b3ddc953da005a97d825652080fe6eff21520", "is_verified": false, - "line_number": 146 - } - ], - "src/agents/models-config.uses-first-github-copilot-profile-env-tokens.test.ts": [ - { - "type": "Secret Keyword", - "filename": "src/agents/models-config.uses-first-github-copilot-profile-env-tokens.test.ts", - "hashed_secret": "7cf31e8b6cda49f70c31f1f25af05d46f924142d", - "is_verified": false, - "line_number": 16 + "line_number": 113 } ], "src/agents/openai-responses.reasoning-replay.test.ts": [ @@ -1306,138 +11589,120 @@ "filename": "src/agents/openai-responses.reasoning-replay.test.ts", "hashed_secret": "a94a8fe5ccb19ba61c4c0873d391e987982fbbd3", "is_verified": false, - "line_number": 124 + "line_number": 55 } ], - "src/agents/pi-embedded-runner.applygoogleturnorderingfix.test.ts": [ + "src/agents/pi-embedded-runner.e2e.test.ts": [ { "type": "Secret Keyword", - "filename": "src/agents/pi-embedded-runner.applygoogleturnorderingfix.test.ts", + "filename": "src/agents/pi-embedded-runner.e2e.test.ts", "hashed_secret": "e9a5f12a8ecbb3eb46eca5096b5c52aa5e7c9fdd", "is_verified": false, - "line_number": 58 - } - ], - "src/agents/pi-embedded-runner.buildembeddedsandboxinfo.test.ts": [ - { - "type": "Secret Keyword", - "filename": "src/agents/pi-embedded-runner.buildembeddedsandboxinfo.test.ts", - "hashed_secret": "e9a5f12a8ecbb3eb46eca5096b5c52aa5e7c9fdd", - "is_verified": false, - "line_number": 57 - } - ], - "src/agents/pi-embedded-runner.createsystempromptoverride.test.ts": [ - { - "type": "Secret Keyword", - "filename": "src/agents/pi-embedded-runner.createsystempromptoverride.test.ts", - "hashed_secret": "e9a5f12a8ecbb3eb46eca5096b5c52aa5e7c9fdd", - "is_verified": false, - "line_number": 56 - } - ], - "src/agents/pi-embedded-runner.get-dm-history-limit-from-session-key.falls-back-provider-default-per-dm-not.test.ts": [ - { - "type": "Secret Keyword", - "filename": "src/agents/pi-embedded-runner.get-dm-history-limit-from-session-key.falls-back-provider-default-per-dm-not.test.ts", - "hashed_secret": "e9a5f12a8ecbb3eb46eca5096b5c52aa5e7c9fdd", - "is_verified": false, - "line_number": 56 - } - ], - "src/agents/pi-embedded-runner.get-dm-history-limit-from-session-key.returns-undefined-sessionkey-is-undefined.test.ts": [ - { - "type": "Secret Keyword", - "filename": "src/agents/pi-embedded-runner.get-dm-history-limit-from-session-key.returns-undefined-sessionkey-is-undefined.test.ts", - "hashed_secret": "e9a5f12a8ecbb3eb46eca5096b5c52aa5e7c9fdd", - "is_verified": false, - "line_number": 56 - } - ], - "src/agents/pi-embedded-runner.limithistoryturns.test.ts": [ - { - "type": "Secret Keyword", - "filename": "src/agents/pi-embedded-runner.limithistoryturns.test.ts", - "hashed_secret": "e9a5f12a8ecbb3eb46eca5096b5c52aa5e7c9fdd", - "is_verified": false, - "line_number": 57 - } - ], - "src/agents/pi-embedded-runner.resolvesessionagentids.test.ts": [ - { - "type": "Secret Keyword", - "filename": "src/agents/pi-embedded-runner.resolvesessionagentids.test.ts", - "hashed_secret": "e9a5f12a8ecbb3eb46eca5096b5c52aa5e7c9fdd", - "is_verified": false, - "line_number": 56 - } - ], - "src/agents/pi-embedded-runner.splitsdktools.test.ts": [ - { - "type": "Secret Keyword", - "filename": "src/agents/pi-embedded-runner.splitsdktools.test.ts", - "hashed_secret": "e9a5f12a8ecbb3eb46eca5096b5c52aa5e7c9fdd", - "is_verified": false, - "line_number": 57 - } - ], - "src/agents/pi-embedded-runner.test.ts": [ - { - "type": "Secret Keyword", - "filename": "src/agents/pi-embedded-runner.test.ts", - "hashed_secret": "e9a5f12a8ecbb3eb46eca5096b5c52aa5e7c9fdd", - "is_verified": false, - "line_number": 117 + "line_number": 127 }, { "type": "Secret Keyword", - "filename": "src/agents/pi-embedded-runner.test.ts", + "filename": "src/agents/pi-embedded-runner.e2e.test.ts", "hashed_secret": "fcdd655b11f33ba4327695084a347b2ba192976c", "is_verified": false, - "line_number": 178 + "line_number": 238 } ], - "src/agents/skills.applyskillenvoverrides.test.ts": [ + "src/agents/pi-embedded-runner/model.ts": [ { "type": "Secret Keyword", - "filename": "src/agents/skills.applyskillenvoverrides.test.ts", + "filename": "src/agents/pi-embedded-runner/model.ts", + "hashed_secret": "e774aaeac31c6272107ba89080295e277050fa7c", + "is_verified": false, + "line_number": 118 + } + ], + "src/agents/pi-embedded-runner/run.overflow-compaction.mocks.shared.ts": [ + { + "type": "Secret Keyword", + "filename": "src/agents/pi-embedded-runner/run.overflow-compaction.mocks.shared.ts", + "hashed_secret": "3acfb2c2b433c0ea7ff107e33df91b18e52f960f", + "is_verified": false, + "line_number": 86 + } + ], + "src/agents/pi-tools.safe-bins.e2e.test.ts": [ + { + "type": "Secret Keyword", + "filename": "src/agents/pi-tools.safe-bins.e2e.test.ts", + "hashed_secret": "3ea88a727641fd5571b5e126ce87032377be1e7f", + "is_verified": false, + "line_number": 126 + } + ], + "src/agents/sanitize-for-prompt.test.ts": [ + { + "type": "Base64 High Entropy String", + "filename": "src/agents/sanitize-for-prompt.test.ts", + "hashed_secret": "9c62d3aa77c19e170c44b18129f967e2041fda41", + "is_verified": false, + "line_number": 28 + } + ], + "src/agents/skills.build-workspace-skills-prompt.prefers-workspace-skills-managed-skills.e2e.test.ts": [ + { + "type": "Secret Keyword", + "filename": "src/agents/skills.build-workspace-skills-prompt.prefers-workspace-skills-managed-skills.e2e.test.ts", + "hashed_secret": "7a85f4764bbd6daf1c3545efbbf0f279a6dc0beb", + "is_verified": false, + "line_number": 103 + } + ], + "src/agents/skills.build-workspace-skills-prompt.syncs-merged-skills-into-target-workspace.e2e.test.ts": [ + { + "type": "Secret Keyword", + "filename": "src/agents/skills.build-workspace-skills-prompt.syncs-merged-skills-into-target-workspace.e2e.test.ts", + "hashed_secret": "3acfb2c2b433c0ea7ff107e33df91b18e52f960f", + "is_verified": false, + "line_number": 147 + } + ], + "src/agents/skills.e2e.test.ts": [ + { + "type": "Secret Keyword", + "filename": "src/agents/skills.e2e.test.ts", "hashed_secret": "5df3a673d724e8a1eb673a8baf623e183940804d", "is_verified": false, - "line_number": 54 + "line_number": 250 }, { "type": "Secret Keyword", - "filename": "src/agents/skills.applyskillenvoverrides.test.ts", + "filename": "src/agents/skills.e2e.test.ts", "hashed_secret": "8921daaa546693e52bc1f9c40bdcf15e816e0448", "is_verified": false, - "line_number": 80 + "line_number": 277 } ], - "src/agents/skills.build-workspace-skills-prompt.prefers-workspace-skills-managed-skills.test.ts": [ + "src/agents/tools/web-fetch.firecrawl-api-key-normalization.e2e.test.ts": [ { "type": "Secret Keyword", - "filename": "src/agents/skills.build-workspace-skills-prompt.prefers-workspace-skills-managed-skills.test.ts", - "hashed_secret": "7a85f4764bbd6daf1c3545efbbf0f279a6dc0beb", + "filename": "src/agents/tools/web-fetch.firecrawl-api-key-normalization.e2e.test.ts", + "hashed_secret": "9da08ab1e27fe0ae2ba6101aea30edcec02d21a4", "is_verified": false, - "line_number": 124 + "line_number": 45 } ], - "src/agents/skills.build-workspace-skills-prompt.syncs-merged-skills-into-target-workspace.test.ts": [ + "src/agents/tools/web-fetch.ssrf.e2e.test.ts": [ { "type": "Secret Keyword", - "filename": "src/agents/skills.build-workspace-skills-prompt.syncs-merged-skills-into-target-workspace.test.ts", - "hashed_secret": "3acfb2c2b433c0ea7ff107e33df91b18e52f960f", - "is_verified": false, - "line_number": 102 - } - ], - "src/agents/tools/web-fetch.ssrf.test.ts": [ - { - "type": "Secret Keyword", - "filename": "src/agents/tools/web-fetch.ssrf.test.ts", + "filename": "src/agents/tools/web-fetch.ssrf.e2e.test.ts", "hashed_secret": "5ce8e9d54c77266fff990194d2219a708c59b76c", "is_verified": false, - "line_number": 55 + "line_number": 73 + } + ], + "src/agents/tools/web-search.e2e.test.ts": [ + { + "type": "Secret Keyword", + "filename": "src/agents/tools/web-search.e2e.test.ts", + "hashed_secret": "c8d313eac6d38274ccfc0fa7935c68bd61d5bc2f", + "is_verified": false, + "line_number": 129 } ], "src/agents/tools/web-search.ts": [ @@ -1446,53 +11711,53 @@ "filename": "src/agents/tools/web-search.ts", "hashed_secret": "dfba7aade0868074c2861c98e2a9a92f3178a51b", "is_verified": false, - "line_number": 85 + "line_number": 97 }, { "type": "Secret Keyword", "filename": "src/agents/tools/web-search.ts", "hashed_secret": "71f8e7976e4cbc4561c9d62fb283e7f788202acb", "is_verified": false, - "line_number": 190 + "line_number": 285 }, { "type": "Secret Keyword", "filename": "src/agents/tools/web-search.ts", "hashed_secret": "c4865ff9250aca23b0d98eb079dad70ebec1cced", "is_verified": false, - "line_number": 198 + "line_number": 295 }, { "type": "Secret Keyword", "filename": "src/agents/tools/web-search.ts", "hashed_secret": "527ee41f36386e85fa932ef09471ca017f3c95c8", "is_verified": false, - "line_number": 199 + "line_number": 298 } ], - "src/agents/tools/web-tools.enabled-defaults.test.ts": [ + "src/agents/tools/web-tools.enabled-defaults.e2e.test.ts": [ { "type": "Secret Keyword", - "filename": "src/agents/tools/web-tools.enabled-defaults.test.ts", + "filename": "src/agents/tools/web-tools.enabled-defaults.e2e.test.ts", "hashed_secret": "47b249a75ca78fdb578d0f28c33685e27ea82684", "is_verified": false, - "line_number": 213 + "line_number": 181 }, { "type": "Secret Keyword", - "filename": "src/agents/tools/web-tools.enabled-defaults.test.ts", + "filename": "src/agents/tools/web-tools.enabled-defaults.e2e.test.ts", "hashed_secret": "d0ffd81d6d7ad1bc3c365660fe8882480c9a986e", "is_verified": false, - "line_number": 242 + "line_number": 187 } ], - "src/agents/tools/web-tools.fetch.test.ts": [ + "src/agents/tools/web-tools.fetch.e2e.test.ts": [ { "type": "Secret Keyword", - "filename": "src/agents/tools/web-tools.fetch.test.ts", + "filename": "src/agents/tools/web-tools.fetch.e2e.test.ts", "hashed_secret": "5ce8e9d54c77266fff990194d2219a708c59b76c", "is_verified": false, - "line_number": 101 + "line_number": 246 } ], "src/auto-reply/reply.directive.directive-behavior.prefers-alias-matches-fuzzy-selection-is-ambiguous.e2e.test.ts": [ @@ -1501,14 +11766,14 @@ "filename": "src/auto-reply/reply.directive.directive-behavior.prefers-alias-matches-fuzzy-selection-is-ambiguous.e2e.test.ts", "hashed_secret": "e9a5f12a8ecbb3eb46eca5096b5c52aa5e7c9fdd", "is_verified": false, - "line_number": 90 + "line_number": 56 }, { "type": "Secret Keyword", "filename": "src/auto-reply/reply.directive.directive-behavior.prefers-alias-matches-fuzzy-selection-is-ambiguous.e2e.test.ts", "hashed_secret": "16c249e04e2be318050cb883c40137361c0c7209", "is_verified": false, - "line_number": 96 + "line_number": 62 } ], "src/auto-reply/reply.directive.directive-behavior.supports-fuzzy-model-matches-model-directive.e2e.test.ts": [ @@ -1517,14 +11782,14 @@ "filename": "src/auto-reply/reply.directive.directive-behavior.supports-fuzzy-model-matches-model-directive.e2e.test.ts", "hashed_secret": "e9a5f12a8ecbb3eb46eca5096b5c52aa5e7c9fdd", "is_verified": false, - "line_number": 87 + "line_number": 42 }, { "type": "Secret Keyword", "filename": "src/auto-reply/reply.directive.directive-behavior.supports-fuzzy-model-matches-model-directive.e2e.test.ts", "hashed_secret": "16c249e04e2be318050cb883c40137361c0c7209", "is_verified": false, - "line_number": 228 + "line_number": 149 } ], "src/auto-reply/status.test.ts": [ @@ -1533,16 +11798,32 @@ "filename": "src/auto-reply/status.test.ts", "hashed_secret": "3acfb2c2b433c0ea7ff107e33df91b18e52f960f", "is_verified": false, - "line_number": 20 + "line_number": 36 } ], - "src/browser/cdp.helpers.test.ts": [ + "src/browser/bridge-server.auth.test.ts": [ + { + "type": "Secret Keyword", + "filename": "src/browser/bridge-server.auth.test.ts", + "hashed_secret": "6af3c121ed4a752936c297cddfb7b00394eabf10", + "is_verified": false, + "line_number": 66 + } + ], + "src/browser/browser-utils.test.ts": [ + { + "type": "Hex High Entropy String", + "filename": "src/browser/browser-utils.test.ts", + "hashed_secret": "4e126c049580d66ca1549fa534d95a7263f27f46", + "is_verified": false, + "line_number": 38 + }, { "type": "Basic Auth Credentials", - "filename": "src/browser/cdp.helpers.test.ts", + "filename": "src/browser/browser-utils.test.ts", "hashed_secret": "9d4e1e23bd5b727046a9e3b4b7db57bd8d6ee684", "is_verified": false, - "line_number": 22 + "line_number": 159 } ], "src/browser/cdp.test.ts": [ @@ -1551,16 +11832,25 @@ "filename": "src/browser/cdp.test.ts", "hashed_secret": "9d4e1e23bd5b727046a9e3b4b7db57bd8d6ee684", "is_verified": false, - "line_number": 172 + "line_number": 186 } ], - "src/browser/target-id.test.ts": [ + "src/channels/plugins/plugins-channel.test.ts": [ { "type": "Hex High Entropy String", - "filename": "src/browser/target-id.test.ts", - "hashed_secret": "4e126c049580d66ca1549fa534d95a7263f27f46", + "filename": "src/channels/plugins/plugins-channel.test.ts", + "hashed_secret": "99c962e8c62296bdc9a17f5caf91ce9bb4c7e0e6", "is_verified": false, - "line_number": 13 + "line_number": 46 + } + ], + "src/cli/program.smoke.e2e.test.ts": [ + { + "type": "Secret Keyword", + "filename": "src/cli/program.smoke.e2e.test.ts", + "hashed_secret": "8689a958b58e4a6f7da6211e666da8e17651697c", + "is_verified": false, + "line_number": 215 } ], "src/cli/update-cli.test.ts": [ @@ -1569,7 +11859,51 @@ "filename": "src/cli/update-cli.test.ts", "hashed_secret": "e4f91dd323bac5bfc4f60a6e433787671dc2421d", "is_verified": false, - "line_number": 112 + "line_number": 239 + } + ], + "src/commands/auth-choice.e2e.test.ts": [ + { + "type": "Secret Keyword", + "filename": "src/commands/auth-choice.e2e.test.ts", + "hashed_secret": "2480500ff391183070fe22ba8665a8be19350833", + "is_verified": false, + "line_number": 454 + }, + { + "type": "Secret Keyword", + "filename": "src/commands/auth-choice.e2e.test.ts", + "hashed_secret": "844ae5308654406d80db6f2b3d0beb07d616f9e1", + "is_verified": false, + "line_number": 487 + }, + { + "type": "Secret Keyword", + "filename": "src/commands/auth-choice.e2e.test.ts", + "hashed_secret": "77e991e9f56e6fa4ed1a908208048421f1214c07", + "is_verified": false, + "line_number": 549 + }, + { + "type": "Secret Keyword", + "filename": "src/commands/auth-choice.e2e.test.ts", + "hashed_secret": "266e955b27b5fc2c2f532e446f2e71c3667a4cd9", + "is_verified": false, + "line_number": 584 + }, + { + "type": "Secret Keyword", + "filename": "src/commands/auth-choice.e2e.test.ts", + "hashed_secret": "1b4d8423b11d32dd0c466428ac81de84a4a9442b", + "is_verified": false, + "line_number": 726 + }, + { + "type": "Secret Keyword", + "filename": "src/commands/auth-choice.e2e.test.ts", + "hashed_secret": "c24e00b94c972ed497d5961212ac96f0dffb4f7a", + "is_verified": false, + "line_number": 798 } ], "src/commands/auth-choice.preferred-provider.ts": [ @@ -1581,73 +11915,84 @@ "line_number": 8 } ], - "src/commands/auth-choice.test.ts": [ + "src/commands/configure.gateway-auth.e2e.test.ts": [ { "type": "Secret Keyword", - "filename": "src/commands/auth-choice.test.ts", - "hashed_secret": "2480500ff391183070fe22ba8665a8be19350833", - "is_verified": false, - "line_number": 289 - }, - { - "type": "Secret Keyword", - "filename": "src/commands/auth-choice.test.ts", - "hashed_secret": "77e991e9f56e6fa4ed1a908208048421f1214c07", - "is_verified": false, - "line_number": 350 - }, - { - "type": "Secret Keyword", - "filename": "src/commands/auth-choice.test.ts", - "hashed_secret": "1b4d8423b11d32dd0c466428ac81de84a4a9442b", - "is_verified": false, - "line_number": 528 - } - ], - "src/commands/configure.gateway-auth.test.ts": [ - { - "type": "Secret Keyword", - "filename": "src/commands/configure.gateway-auth.test.ts", + "filename": "src/commands/configure.gateway-auth.e2e.test.ts", "hashed_secret": "e5e9fa1ba31ecd1ae84f75caaa474f3a663f05f4", "is_verified": false, - "line_number": 8 + "line_number": 21 + }, + { + "type": "Secret Keyword", + "filename": "src/commands/configure.gateway-auth.e2e.test.ts", + "hashed_secret": "d5d4cd07616a542891b7ec2d0257b3a24b69856e", + "is_verified": false, + "line_number": 62 } ], - "src/commands/models/list.status.test.ts": [ + "src/commands/daemon-install-helpers.e2e.test.ts": [ + { + "type": "Secret Keyword", + "filename": "src/commands/daemon-install-helpers.e2e.test.ts", + "hashed_secret": "3acfb2c2b433c0ea7ff107e33df91b18e52f960f", + "is_verified": false, + "line_number": 128 + } + ], + "src/commands/doctor-memory-search.test.ts": [ + { + "type": "Secret Keyword", + "filename": "src/commands/doctor-memory-search.test.ts", + "hashed_secret": "2e07956ffc9bc4fd624064c40b7495c85d5f1467", + "is_verified": false, + "line_number": 38 + } + ], + "src/commands/model-picker.e2e.test.ts": [ + { + "type": "Secret Keyword", + "filename": "src/commands/model-picker.e2e.test.ts", + "hashed_secret": "5b924ca5330ede58702a5b0e414207b90fb1aef3", + "is_verified": false, + "line_number": 127 + } + ], + "src/commands/models/list.status.e2e.test.ts": [ { "type": "Base64 High Entropy String", - "filename": "src/commands/models/list.status.test.ts", + "filename": "src/commands/models/list.status.e2e.test.ts", "hashed_secret": "d6ae2508a78a232d5378ef24b85ce40cbb4d7ff0", "is_verified": false, - "line_number": 11 + "line_number": 12 }, { "type": "Base64 High Entropy String", - "filename": "src/commands/models/list.status.test.ts", + "filename": "src/commands/models/list.status.e2e.test.ts", "hashed_secret": "2d8012102440ea97852b3152239218f00579bafa", "is_verified": false, - "line_number": 18 + "line_number": 19 }, { "type": "Base64 High Entropy String", - "filename": "src/commands/models/list.status.test.ts", + "filename": "src/commands/models/list.status.e2e.test.ts", "hashed_secret": "51848e2be4b461a549218d3167f19c01be6b98b8", "is_verified": false, - "line_number": 46 + "line_number": 51 }, { "type": "Secret Keyword", - "filename": "src/commands/models/list.status.test.ts", + "filename": "src/commands/models/list.status.e2e.test.ts", "hashed_secret": "51848e2be4b461a549218d3167f19c01be6b98b8", "is_verified": false, - "line_number": 46 + "line_number": 51 }, { "type": "Secret Keyword", - "filename": "src/commands/models/list.status.test.ts", + "filename": "src/commands/models/list.status.e2e.test.ts", "hashed_secret": "1c1e381bfb72d3b7bfca9437053d9875356680f0", "is_verified": false, - "line_number": 52 + "line_number": 57 } ], "src/commands/onboard-auth.config-minimax.ts": [ @@ -1656,39 +12001,104 @@ "filename": "src/commands/onboard-auth.config-minimax.ts", "hashed_secret": "16c249e04e2be318050cb883c40137361c0c7209", "is_verified": false, - "line_number": 30 + "line_number": 36 }, { "type": "Secret Keyword", "filename": "src/commands/onboard-auth.config-minimax.ts", "hashed_secret": "ddcb713196b974770575a9bea5a4e7d46361f8e9", "is_verified": false, - "line_number": 85 + "line_number": 78 } ], - "src/commands/onboard-auth.test.ts": [ + "src/commands/onboard-auth.e2e.test.ts": [ { "type": "Secret Keyword", - "filename": "src/commands/onboard-auth.test.ts", - "hashed_secret": "666c100dab549a6f56da7da546bd848ed5086541", + "filename": "src/commands/onboard-auth.e2e.test.ts", + "hashed_secret": "e184b402822abc549b37689c84e8e0e33c39a1f1", "is_verified": false, - "line_number": 230 + "line_number": 272 + } + ], + "src/commands/onboard-custom.e2e.test.ts": [ + { + "type": "Secret Keyword", + "filename": "src/commands/onboard-custom.e2e.test.ts", + "hashed_secret": "62e6748c6bb4c4a0f785a28cdd7d41ef212c0091", + "is_verified": false, + "line_number": 238 + } + ], + "src/commands/onboard-non-interactive.provider-auth.e2e.test.ts": [ + { + "type": "Secret Keyword", + "filename": "src/commands/onboard-non-interactive.provider-auth.e2e.test.ts", + "hashed_secret": "fcdd655b11f33ba4327695084a347b2ba192976c", + "is_verified": false, + "line_number": 153 }, { "type": "Secret Keyword", - "filename": "src/commands/onboard-auth.test.ts", - "hashed_secret": "e184b402822abc549b37689c84e8e0e33c39a1f1", + "filename": "src/commands/onboard-non-interactive.provider-auth.e2e.test.ts", + "hashed_secret": "07a6b9cec637c806195e8aa7e5c0851ab03dc35e", "is_verified": false, - "line_number": 262 - } - ], - "src/commands/onboard-non-interactive.ai-gateway.test.ts": [ + "line_number": 191 + }, { "type": "Secret Keyword", - "filename": "src/commands/onboard-non-interactive.ai-gateway.test.ts", + "filename": "src/commands/onboard-non-interactive.provider-auth.e2e.test.ts", "hashed_secret": "77e991e9f56e6fa4ed1a908208048421f1214c07", "is_verified": false, - "line_number": 50 + "line_number": 234 + }, + { + "type": "Secret Keyword", + "filename": "src/commands/onboard-non-interactive.provider-auth.e2e.test.ts", + "hashed_secret": "65547299f940eca3dc839f3eac85e8a78a6deb05", + "is_verified": false, + "line_number": 282 + }, + { + "type": "Secret Keyword", + "filename": "src/commands/onboard-non-interactive.provider-auth.e2e.test.ts", + "hashed_secret": "2833d098c110602e4c8d577fbfdb423a9ffd58e9", + "is_verified": false, + "line_number": 304 + }, + { + "type": "Secret Keyword", + "filename": "src/commands/onboard-non-interactive.provider-auth.e2e.test.ts", + "hashed_secret": "266e955b27b5fc2c2f532e446f2e71c3667a4cd9", + "is_verified": false, + "line_number": 338 + }, + { + "type": "Secret Keyword", + "filename": "src/commands/onboard-non-interactive.provider-auth.e2e.test.ts", + "hashed_secret": "995b80728ee01edb90ddfed07870bbab405df19f", + "is_verified": false, + "line_number": 366 + }, + { + "type": "Secret Keyword", + "filename": "src/commands/onboard-non-interactive.provider-auth.e2e.test.ts", + "hashed_secret": "b65888424ecafcc98bfd803b24817e4dadf821f8", + "is_verified": false, + "line_number": 383 + }, + { + "type": "Secret Keyword", + "filename": "src/commands/onboard-non-interactive.provider-auth.e2e.test.ts", + "hashed_secret": "62e6748c6bb4c4a0f785a28cdd7d41ef212c0091", + "is_verified": false, + "line_number": 402 + }, + { + "type": "Secret Keyword", + "filename": "src/commands/onboard-non-interactive.provider-auth.e2e.test.ts", + "hashed_secret": "8818d3b7c102fd6775af9e1390e5ed3a128473fb", + "is_verified": false, + "line_number": 447 } ], "src/commands/onboard-non-interactive/api-keys.ts": [ @@ -1697,7 +12107,43 @@ "filename": "src/commands/onboard-non-interactive/api-keys.ts", "hashed_secret": "112f3a99b283a4e1788dedd8e0e5d35375c33747", "is_verified": false, - "line_number": 10 + "line_number": 11 + } + ], + "src/commands/status.update.test.ts": [ + { + "type": "Hex High Entropy String", + "filename": "src/commands/status.update.test.ts", + "hashed_secret": "33c76f70af66754ca47d19b17da8dc232e125253", + "is_verified": false, + "line_number": 74 + } + ], + "src/commands/vllm-setup.ts": [ + { + "type": "Secret Keyword", + "filename": "src/commands/vllm-setup.ts", + "hashed_secret": "5b924ca5330ede58702a5b0e414207b90fb1aef3", + "is_verified": false, + "line_number": 60 + } + ], + "src/commands/zai-endpoint-detect.e2e.test.ts": [ + { + "type": "Secret Keyword", + "filename": "src/commands/zai-endpoint-detect.e2e.test.ts", + "hashed_secret": "e9a5f12a8ecbb3eb46eca5096b5c52aa5e7c9fdd", + "is_verified": false, + "line_number": 24 + } + ], + "src/config/config-misc.test.ts": [ + { + "type": "Secret Keyword", + "filename": "src/config/config-misc.test.ts", + "hashed_secret": "3acfb2c2b433c0ea7ff107e33df91b18e52f960f", + "is_verified": false, + "line_number": 62 } ], "src/config/config.env-vars.test.ts": [ @@ -1706,21 +12152,30 @@ "filename": "src/config/config.env-vars.test.ts", "hashed_secret": "a24ef9c1a27cac44823571ceef2e8262718eee36", "is_verified": false, - "line_number": 15 + "line_number": 13 }, { "type": "Secret Keyword", "filename": "src/config/config.env-vars.test.ts", "hashed_secret": "29d5f92e9ee44d4854d6dfaeefc3dc27d779fdf3", "is_verified": false, - "line_number": 47 + "line_number": 19 }, { "type": "Secret Keyword", "filename": "src/config/config.env-vars.test.ts", "hashed_secret": "1672b6a1e7956c6a70f45d699aa42a351b1f8b80", "is_verified": false, - "line_number": 63 + "line_number": 27 + } + ], + "src/config/config.irc.test.ts": [ + { + "type": "Secret Keyword", + "filename": "src/config/config.irc.test.ts", + "hashed_secret": "e5e9fa1ba31ecd1ae84f75caaa474f3a663f05f4", + "is_verified": false, + "line_number": 92 } ], "src/config/config.talk-api-key-fallback.test.ts": [ @@ -1729,16 +12184,53 @@ "filename": "src/config/config.talk-api-key-fallback.test.ts", "hashed_secret": "bea2f7b64fab8d1d414d0449530b1e088d36d5b1", "is_verified": false, - "line_number": 42 + "line_number": 33 } ], - "src/config/config.web-search-provider.test.ts": [ + "src/config/env-preserve-io.test.ts": [ { "type": "Secret Keyword", - "filename": "src/config/config.web-search-provider.test.ts", - "hashed_secret": "3acfb2c2b433c0ea7ff107e33df91b18e52f960f", + "filename": "src/config/env-preserve-io.test.ts", + "hashed_secret": "85639f0560fd9bf8704f52e01c5e764c9ed5a6aa", "is_verified": false, - "line_number": 14 + "line_number": 59 + }, + { + "type": "Secret Keyword", + "filename": "src/config/env-preserve-io.test.ts", + "hashed_secret": "996650087ab48bdb1ca80f0842c97d4fbb6f1c71", + "is_verified": false, + "line_number": 86 + } + ], + "src/config/env-preserve.test.ts": [ + { + "type": "Secret Keyword", + "filename": "src/config/env-preserve.test.ts", + "hashed_secret": "f6067ac4599b1cd5176f34897bb556a1a1eaf049", + "is_verified": false, + "line_number": 6 + }, + { + "type": "Secret Keyword", + "filename": "src/config/env-preserve.test.ts", + "hashed_secret": "5a41c5061e7279cec0566b3ef52cbe042e831192", + "is_verified": false, + "line_number": 7 + }, + { + "type": "Secret Keyword", + "filename": "src/config/env-preserve.test.ts", + "hashed_secret": "53d407242b91f07138abcf30ee0e6b71f304b87f", + "is_verified": false, + "line_number": 19 + }, + { + "type": "Secret Keyword", + "filename": "src/config/env-preserve.test.ts", + "hashed_secret": "c1b24294f00e281605f9dd6a298612e3060062b4", + "is_verified": false, + "line_number": 82 } ], "src/config/env-substitution.test.ts": [ @@ -1747,65 +12239,215 @@ "filename": "src/config/env-substitution.test.ts", "hashed_secret": "f2b14f68eb995facb3a1c35287b778d5bd785511", "is_verified": false, - "line_number": 38 + "line_number": 37 }, { "type": "Secret Keyword", "filename": "src/config/env-substitution.test.ts", "hashed_secret": "ec417f567082612f8fd6afafe1abcab831fca840", "is_verified": false, - "line_number": 69 + "line_number": 68 }, { "type": "Secret Keyword", "filename": "src/config/env-substitution.test.ts", "hashed_secret": "520bd69c3eb1646d9a78181ecb4c90c51fdf428d", "is_verified": false, - "line_number": 70 + "line_number": 69 }, { "type": "Secret Keyword", "filename": "src/config/env-substitution.test.ts", "hashed_secret": "f136444bf9b3d01a9f9b772b80ac6bf7b6a43ef0", "is_verified": false, - "line_number": 228 + "line_number": 227 } ], - "src/config/schema.ts": [ + "src/config/io.write-config.test.ts": [ { "type": "Secret Keyword", - "filename": "src/config/schema.ts", - "hashed_secret": "e73c9fcad85cd4eecc74181ec4bdb31064d68439", + "filename": "src/config/io.write-config.test.ts", + "hashed_secret": "13951588fd3325e25ed1e3b116d7009fb221c85e", "is_verified": false, - "line_number": 184 + "line_number": 65 + } + ], + "src/config/model-alias-defaults.test.ts": [ + { + "type": "Secret Keyword", + "filename": "src/config/model-alias-defaults.test.ts", + "hashed_secret": "e9a5f12a8ecbb3eb46eca5096b5c52aa5e7c9fdd", + "is_verified": false, + "line_number": 66 + } + ], + "src/config/redact-snapshot.test.ts": [ + { + "type": "Base64 High Entropy String", + "filename": "src/config/redact-snapshot.test.ts", + "hashed_secret": "3732e17b2d11ed6c64fef02c341958007af154e7", + "is_verified": false, + "line_number": 77 }, { "type": "Secret Keyword", - "filename": "src/config/schema.ts", - "hashed_secret": "2eda7cd978f39eebec3bf03e4410a40e14167fff", + "filename": "src/config/redact-snapshot.test.ts", + "hashed_secret": "3732e17b2d11ed6c64fef02c341958007af154e7", "is_verified": false, - "line_number": 220 + "line_number": 77 }, { "type": "Secret Keyword", - "filename": "src/config/schema.ts", + "filename": "src/config/redact-snapshot.test.ts", + "hashed_secret": "7f413afd37447cd321d79286be0f58d7a9875d9b", + "is_verified": false, + "line_number": 89 + }, + { + "type": "Secret Keyword", + "filename": "src/config/redact-snapshot.test.ts", + "hashed_secret": "c21afa950dee2a70f3e0f6ffdfbc87f8edb90262", + "is_verified": false, + "line_number": 99 + }, + { + "type": "Secret Keyword", + "filename": "src/config/redact-snapshot.test.ts", + "hashed_secret": "83a9937c6de261ffda22304834f30fe6c8f97926", + "is_verified": false, + "line_number": 110 + }, + { + "type": "Secret Keyword", + "filename": "src/config/redact-snapshot.test.ts", + "hashed_secret": "87ac76dfc9cba93bead43c191e31bd099a97cc11", + "is_verified": false, + "line_number": 198 + }, + { + "type": "Secret Keyword", + "filename": "src/config/redact-snapshot.test.ts", + "hashed_secret": "abb1aabcd0e49019c2873944a40671a80ccd64c7", + "is_verified": false, + "line_number": 309 + }, + { + "type": "Base64 High Entropy String", + "filename": "src/config/redact-snapshot.test.ts", + "hashed_secret": "8e22880b4e96bab354e1da6c91d2f58dabde3555", + "is_verified": false, + "line_number": 321 + }, + { + "type": "Secret Keyword", + "filename": "src/config/redact-snapshot.test.ts", + "hashed_secret": "8e22880b4e96bab354e1da6c91d2f58dabde3555", + "is_verified": false, + "line_number": 321 + }, + { + "type": "Secret Keyword", + "filename": "src/config/redact-snapshot.test.ts", + "hashed_secret": "a9c732e05044a08c760cce7f6d142cd0d35a19e5", + "is_verified": false, + "line_number": 375 + }, + { + "type": "Secret Keyword", + "filename": "src/config/redact-snapshot.test.ts", + "hashed_secret": "50843dd5651cfafbe7c5611c1eed195c63e6e3fd", + "is_verified": false, + "line_number": 691 + }, + { + "type": "Secret Keyword", + "filename": "src/config/redact-snapshot.test.ts", + "hashed_secret": "927e7cdedcb8f71af399a49fb90a381df8b8df28", + "is_verified": false, + "line_number": 808 + }, + { + "type": "Secret Keyword", + "filename": "src/config/redact-snapshot.test.ts", + "hashed_secret": "1996cc327bd39dad69cd8feb24250dafd51e7c08", + "is_verified": false, + "line_number": 814 + }, + { + "type": "Secret Keyword", + "filename": "src/config/redact-snapshot.test.ts", + "hashed_secret": "a5c0a65a4fa8874a486aa5072671927ceba82a90", + "is_verified": false, + "line_number": 838 + } + ], + "src/config/schema.help.ts": [ + { + "type": "Secret Keyword", + "filename": "src/config/schema.help.ts", "hashed_secret": "9f4cda226d3868676ac7f86f59e4190eb94bd208", "is_verified": false, - "line_number": 418 + "line_number": 109 }, { "type": "Secret Keyword", - "filename": "src/config/schema.ts", + "filename": "src/config/schema.help.ts", "hashed_secret": "01822c8bbf6a8b136944b14182cb885100ec2eae", "is_verified": false, - "line_number": 437 + "line_number": 130 }, { "type": "Secret Keyword", - "filename": "src/config/schema.ts", + "filename": "src/config/schema.help.ts", "hashed_secret": "bb7dfd9746e660e4a4374951ec5938ef0e343255", "is_verified": false, - "line_number": 487 + "line_number": 187 + } + ], + "src/config/schema.irc.ts": [ + { + "type": "Secret Keyword", + "filename": "src/config/schema.irc.ts", + "hashed_secret": "de18cf01737148de8ff7cb33fd38dd4d3e226384", + "is_verified": false, + "line_number": 6 + }, + { + "type": "Secret Keyword", + "filename": "src/config/schema.irc.ts", + "hashed_secret": "b362522192a2259c5d10ecb89fe728a66d6015e9", + "is_verified": false, + "line_number": 7 + }, + { + "type": "Secret Keyword", + "filename": "src/config/schema.irc.ts", + "hashed_secret": "383088054f9b38c21ec29db239e3fccb7eb0a485", + "is_verified": false, + "line_number": 20 + }, + { + "type": "Secret Keyword", + "filename": "src/config/schema.irc.ts", + "hashed_secret": "a3484eea8ccb96dd79f50edc14b8fbf2867a9180", + "is_verified": false, + "line_number": 21 + } + ], + "src/config/schema.labels.ts": [ + { + "type": "Secret Keyword", + "filename": "src/config/schema.labels.ts", + "hashed_secret": "e73c9fcad85cd4eecc74181ec4bdb31064d68439", + "is_verified": false, + "line_number": 104 + }, + { + "type": "Secret Keyword", + "filename": "src/config/schema.labels.ts", + "hashed_secret": "2eda7cd978f39eebec3bf03e4410a40e14167fff", + "is_verified": false, + "line_number": 145 } ], "src/config/slack-http-config.test.ts": [ @@ -1814,62 +12456,124 @@ "filename": "src/config/slack-http-config.test.ts", "hashed_secret": "e5e9fa1ba31ecd1ae84f75caaa474f3a663f05f4", "is_verified": false, - "line_number": 11 + "line_number": 10 + } + ], + "src/config/telegram-webhook-secret.test.ts": [ + { + "type": "Secret Keyword", + "filename": "src/config/telegram-webhook-secret.test.ts", + "hashed_secret": "e5e9fa1ba31ecd1ae84f75caaa474f3a663f05f4", + "is_verified": false, + "line_number": 10 + } + ], + "src/docker-setup.test.ts": [ + { + "type": "Base64 High Entropy String", + "filename": "src/docker-setup.test.ts", + "hashed_secret": "32ac33b537769e97787f70ef85576cc243fab934", + "is_verified": false, + "line_number": 131 + } + ], + "src/gateway/auth-rate-limit.ts": [ + { + "type": "Secret Keyword", + "filename": "src/gateway/auth-rate-limit.ts", + "hashed_secret": "76ed0a056aa77060de25754586440cff390791d0", + "is_verified": false, + "line_number": 37 } ], "src/gateway/auth.test.ts": [ + { + "type": "Secret Keyword", + "filename": "src/gateway/auth.test.ts", + "hashed_secret": "db5543cd7440bbdc4c5aaf8aa363715c31dd5a27", + "is_verified": false, + "line_number": 32 + }, + { + "type": "Secret Keyword", + "filename": "src/gateway/auth.test.ts", + "hashed_secret": "d51f846285cbc6d1dd76677a0fd588c8df44e506", + "is_verified": false, + "line_number": 48 + }, { "type": "Secret Keyword", "filename": "src/gateway/auth.test.ts", "hashed_secret": "e5e9fa1ba31ecd1ae84f75caaa474f3a663f05f4", "is_verified": false, - "line_number": 43 + "line_number": 95 }, { "type": "Secret Keyword", "filename": "src/gateway/auth.test.ts", "hashed_secret": "a4b48a81cdab1e1a5dd37907d6c85ca1c61ddc7c", "is_verified": false, - "line_number": 51 + "line_number": 103 } ], "src/gateway/call.test.ts": [ + { + "type": "Secret Keyword", + "filename": "src/gateway/call.test.ts", + "hashed_secret": "db5543cd7440bbdc4c5aaf8aa363715c31dd5a27", + "is_verified": false, + "line_number": 357 + }, + { + "type": "Secret Keyword", + "filename": "src/gateway/call.test.ts", + "hashed_secret": "de1c41e8ece73f5d5c259bb37eccb59a542b91dc", + "is_verified": false, + "line_number": 361 + }, { "type": "Secret Keyword", "filename": "src/gateway/call.test.ts", "hashed_secret": "e5e9fa1ba31ecd1ae84f75caaa474f3a663f05f4", "is_verified": false, - "line_number": 285 + "line_number": 398 }, { "type": "Secret Keyword", "filename": "src/gateway/call.test.ts", "hashed_secret": "e493f561d90c6638c1f51c5a8a069c3b129b79ed", "is_verified": false, - "line_number": 295 + "line_number": 408 }, { "type": "Secret Keyword", "filename": "src/gateway/call.test.ts", "hashed_secret": "2e07956ffc9bc4fd624064c40b7495c85d5f1467", "is_verified": false, - "line_number": 300 + "line_number": 413 }, { "type": "Secret Keyword", "filename": "src/gateway/call.test.ts", "hashed_secret": "bddc29032de580fb53b3a9a0357dd409086db800", "is_verified": false, - "line_number": 313 + "line_number": 426 + }, + { + "type": "Secret Keyword", + "filename": "src/gateway/call.test.ts", + "hashed_secret": "6255675480f681df08c1704b7b3cd2c49917f0e2", + "is_verified": false, + "line_number": 463 } ], - "src/gateway/client.test.ts": [ + "src/gateway/client.e2e.test.ts": [ { "type": "Private Key", - "filename": "src/gateway/client.test.ts", + "filename": "src/gateway/client.e2e.test.ts", "hashed_secret": "1348b145fa1a555461c1b790a2f66614781091e9", "is_verified": false, - "line_number": 83 + "line_number": 85 } ], "src/gateway/gateway-cli-backend.live.test.ts": [ @@ -1887,16 +12591,25 @@ "filename": "src/gateway/gateway-models.profiles.live.test.ts", "hashed_secret": "3e2fd4a90d5afbd27974730c4d6a9592fe300825", "is_verified": false, - "line_number": 219 + "line_number": 242 } ], - "src/gateway/gateway.e2e.test.ts": [ + "src/gateway/server-methods/skills.update.normalizes-api-key.test.ts": [ { "type": "Secret Keyword", - "filename": "src/gateway/gateway.e2e.test.ts", - "hashed_secret": "a94a8fe5ccb19ba61c4c0873d391e987982fbbd3", + "filename": "src/gateway/server-methods/skills.update.normalizes-api-key.test.ts", + "hashed_secret": "c17b6f497b392e2efc655e8b646b3455f4b28e58", "is_verified": false, - "line_number": 73 + "line_number": 29 + } + ], + "src/gateway/server-methods/talk.ts": [ + { + "type": "Secret Keyword", + "filename": "src/gateway/server-methods/talk.ts", + "hashed_secret": "e478a5eeba4907d2f12a68761996b9de745d826d", + "is_verified": false, + "line_number": 13 } ], "src/gateway/server.auth.e2e.test.ts": [ @@ -1905,14 +12618,32 @@ "filename": "src/gateway/server.auth.e2e.test.ts", "hashed_secret": "e5e9fa1ba31ecd1ae84f75caaa474f3a663f05f4", "is_verified": false, - "line_number": 179 + "line_number": 460 }, { "type": "Secret Keyword", "filename": "src/gateway/server.auth.e2e.test.ts", "hashed_secret": "a4b48a81cdab1e1a5dd37907d6c85ca1c61ddc7c", "is_verified": false, - "line_number": 197 + "line_number": 478 + } + ], + "src/gateway/server.skills-status.e2e.test.ts": [ + { + "type": "Secret Keyword", + "filename": "src/gateway/server.skills-status.e2e.test.ts", + "hashed_secret": "1cc6bff0f84efb2d3ff4fa1347f3b2bc173aaff0", + "is_verified": false, + "line_number": 13 + } + ], + "src/gateway/server.talk-config.e2e.test.ts": [ + { + "type": "Secret Keyword", + "filename": "src/gateway/server.talk-config.e2e.test.ts", + "hashed_secret": "3c310634864babb081f0b617c14bc34823d7e369", + "is_verified": false, + "line_number": 13 } ], "src/gateway/session-utils.test.ts": [ @@ -1921,16 +12652,16 @@ "filename": "src/gateway/session-utils.test.ts", "hashed_secret": "bb9a5d9483409d2c60b28268a0efcb93324d4cda", "is_verified": false, - "line_number": 156 + "line_number": 280 } ], - "src/gateway/tools-invoke-http.test.ts": [ + "src/gateway/test-openai-responses-model.ts": [ { "type": "Secret Keyword", - "filename": "src/gateway/tools-invoke-http.test.ts", - "hashed_secret": "e5e9fa1ba31ecd1ae84f75caaa474f3a663f05f4", + "filename": "src/gateway/test-openai-responses-model.ts", + "hashed_secret": "a94a8fe5ccb19ba61c4c0873d391e987982fbbd3", "is_verified": false, - "line_number": 56 + "line_number": 17 } ], "src/gateway/ws-log.test.ts": [ @@ -1948,14 +12679,14 @@ "filename": "src/infra/env.test.ts", "hashed_secret": "df98a117ddabf85991b9fe0e268214dc0e1254dc", "is_verified": false, - "line_number": 10 + "line_number": 9 }, { "type": "Secret Keyword", "filename": "src/infra/env.test.ts", "hashed_secret": "6d811dc1f59a55ca1a3d38b5042a062b9f79e8ec", "is_verified": false, - "line_number": 25 + "line_number": 30 } ], "src/infra/outbound/message-action-runner.test.ts": [ @@ -1964,23 +12695,46 @@ "filename": "src/infra/outbound/message-action-runner.test.ts", "hashed_secret": "804ec071803318791b835cffd6e509c8d32239db", "is_verified": false, - "line_number": 88 + "line_number": 129 }, { "type": "Secret Keyword", "filename": "src/infra/outbound/message-action-runner.test.ts", "hashed_secret": "789cbe0407840b1c2041cb33452ff60f19bf58cc", "is_verified": false, - "line_number": 385 + "line_number": 435 } ], - "src/infra/outbound/outbound-policy.test.ts": [ + "src/infra/outbound/outbound.test.ts": [ { "type": "Hex High Entropy String", - "filename": "src/infra/outbound/outbound-policy.test.ts", + "filename": "src/infra/outbound/outbound.test.ts", "hashed_secret": "804ec071803318791b835cffd6e509c8d32239db", "is_verified": false, - "line_number": 33 + "line_number": 631 + } + ], + "src/infra/provider-usage.auth.normalizes-keys.test.ts": [ + { + "type": "Secret Keyword", + "filename": "src/infra/provider-usage.auth.normalizes-keys.test.ts", + "hashed_secret": "45c7365e3b542cdb4fae6ec10c2ff149224d7656", + "is_verified": false, + "line_number": 80 + }, + { + "type": "Secret Keyword", + "filename": "src/infra/provider-usage.auth.normalizes-keys.test.ts", + "hashed_secret": "b67074884ab7ef7c7a8cd6a3da9565d96c792248", + "is_verified": false, + "line_number": 81 + }, + { + "type": "Secret Keyword", + "filename": "src/infra/provider-usage.auth.normalizes-keys.test.ts", + "hashed_secret": "d4d8027e64f9cf4180d3aecfe31ea409368022ee", + "is_verified": false, + "line_number": 82 } ], "src/infra/shell-env.test.ts": [ @@ -1989,21 +12743,89 @@ "filename": "src/infra/shell-env.test.ts", "hashed_secret": "65c10dc3549fe07424148a8a4790a3341ecbc253", "is_verified": false, - "line_number": 27 + "line_number": 26 }, { "type": "Secret Keyword", "filename": "src/infra/shell-env.test.ts", "hashed_secret": "e013ffda590d2178607c16d11b1ea42f75ceb0e7", "is_verified": false, - "line_number": 59 + "line_number": 58 }, { "type": "Base64 High Entropy String", "filename": "src/infra/shell-env.test.ts", "hashed_secret": "be6ee9a6bf9f2dad84a5a67d6c0576a5bacc391e", "is_verified": false, - "line_number": 61 + "line_number": 60 + } + ], + "src/line/accounts.test.ts": [ + { + "type": "Secret Keyword", + "filename": "src/line/accounts.test.ts", + "hashed_secret": "fe1bae27cb7c1fb823f496f286e78f1d2ae87734", + "is_verified": false, + "line_number": 30 + }, + { + "type": "Secret Keyword", + "filename": "src/line/accounts.test.ts", + "hashed_secret": "8a8281cec699f5e51330e21dd7fab3531af6ef0c", + "is_verified": false, + "line_number": 48 + }, + { + "type": "Secret Keyword", + "filename": "src/line/accounts.test.ts", + "hashed_secret": "b4924d9834a1126714643ac231fb6623c14c3449", + "is_verified": false, + "line_number": 74 + } + ], + "src/line/bot-handlers.test.ts": [ + { + "type": "Secret Keyword", + "filename": "src/line/bot-handlers.test.ts", + "hashed_secret": "e5e9fa1ba31ecd1ae84f75caaa474f3a663f05f4", + "is_verified": false, + "line_number": 106 + } + ], + "src/line/bot-message-context.test.ts": [ + { + "type": "Secret Keyword", + "filename": "src/line/bot-message-context.test.ts", + "hashed_secret": "e5e9fa1ba31ecd1ae84f75caaa474f3a663f05f4", + "is_verified": false, + "line_number": 18 + } + ], + "src/line/monitor.fail-closed.test.ts": [ + { + "type": "Secret Keyword", + "filename": "src/line/monitor.fail-closed.test.ts", + "hashed_secret": "e5e9fa1ba31ecd1ae84f75caaa474f3a663f05f4", + "is_verified": false, + "line_number": 22 + } + ], + "src/line/webhook-node.test.ts": [ + { + "type": "Secret Keyword", + "filename": "src/line/webhook-node.test.ts", + "hashed_secret": "e5e9fa1ba31ecd1ae84f75caaa474f3a663f05f4", + "is_verified": false, + "line_number": 28 + } + ], + "src/line/webhook.test.ts": [ + { + "type": "Secret Keyword", + "filename": "src/line/webhook.test.ts", + "hashed_secret": "e5e9fa1ba31ecd1ae84f75caaa474f3a663f05f4", + "is_verified": false, + "line_number": 23 } ], "src/logging/redact.test.ts": [ @@ -2012,37 +12834,37 @@ "filename": "src/logging/redact.test.ts", "hashed_secret": "dd7754662b89333191ff45e8257a3e6d3fcd3990", "is_verified": false, - "line_number": 9 + "line_number": 8 }, { "type": "Private Key", "filename": "src/logging/redact.test.ts", "hashed_secret": "1348b145fa1a555461c1b790a2f66614781091e9", "is_verified": false, - "line_number": 64 + "line_number": 73 }, { "type": "Hex High Entropy String", "filename": "src/logging/redact.test.ts", "hashed_secret": "7992945213f7d76889fa83ff0f2be352409c837e", "is_verified": false, - "line_number": 65 + "line_number": 74 }, { "type": "Base64 High Entropy String", "filename": "src/logging/redact.test.ts", "hashed_secret": "063995ecb4fa5afe2460397d322925cd867b7d74", "is_verified": false, - "line_number": 79 + "line_number": 88 } ], - "src/media-understanding/apply.test.ts": [ + "src/media-understanding/apply.e2e.test.ts": [ { "type": "Secret Keyword", - "filename": "src/media-understanding/apply.test.ts", + "filename": "src/media-understanding/apply.e2e.test.ts", "hashed_secret": "3acfb2c2b433c0ea7ff107e33df91b18e52f960f", "is_verified": false, - "line_number": 14 + "line_number": 12 } ], "src/media-understanding/providers/deepgram/audio.test.ts": [ @@ -2051,7 +12873,7 @@ "filename": "src/media-understanding/providers/deepgram/audio.test.ts", "hashed_secret": "3acfb2c2b433c0ea7ff107e33df91b18e52f960f", "is_verified": false, - "line_number": 31 + "line_number": 27 } ], "src/media-understanding/providers/google/video.test.ts": [ @@ -2060,7 +12882,7 @@ "filename": "src/media-understanding/providers/google/video.test.ts", "hashed_secret": "3acfb2c2b433c0ea7ff107e33df91b18e52f960f", "is_verified": false, - "line_number": 28 + "line_number": 64 } ], "src/media-understanding/providers/openai/audio.test.ts": [ @@ -2069,7 +12891,7 @@ "filename": "src/media-understanding/providers/openai/audio.test.ts", "hashed_secret": "3acfb2c2b433c0ea7ff107e33df91b18e52f960f", "is_verified": false, - "line_number": 26 + "line_number": 22 } ], "src/media-understanding/runner.auto-audio.test.ts": [ @@ -2078,7 +12900,7 @@ "filename": "src/media-understanding/runner.auto-audio.test.ts", "hashed_secret": "3acfb2c2b433c0ea7ff107e33df91b18e52f960f", "is_verified": false, - "line_number": 42 + "line_number": 40 } ], "src/media-understanding/runner.deepgram.test.ts": [ @@ -2087,7 +12909,23 @@ "filename": "src/media-understanding/runner.deepgram.test.ts", "hashed_secret": "3acfb2c2b433c0ea7ff107e33df91b18e52f960f", "is_verified": false, - "line_number": 46 + "line_number": 44 + } + ], + "src/memory/embeddings-voyage.test.ts": [ + { + "type": "Secret Keyword", + "filename": "src/memory/embeddings-voyage.test.ts", + "hashed_secret": "7c2020578bbe5e2e3f78d7f954eb2ad8ab5b0403", + "is_verified": false, + "line_number": 33 + }, + { + "type": "Secret Keyword", + "filename": "src/memory/embeddings-voyage.test.ts", + "hashed_secret": "8afdb3da9b79c8957ae35978ea8f33fbc3bfdf60", + "is_verified": false, + "line_number": 77 } ], "src/memory/embeddings.test.ts": [ @@ -2096,21 +12934,21 @@ "filename": "src/memory/embeddings.test.ts", "hashed_secret": "a47110e348a3063541fb1f1f640d635d457181a0", "is_verified": false, - "line_number": 32 + "line_number": 45 }, { "type": "Secret Keyword", "filename": "src/memory/embeddings.test.ts", "hashed_secret": "c734e47630dda71619c696d88381f06f7511bd78", "is_verified": false, - "line_number": 149 + "line_number": 160 }, { "type": "Secret Keyword", "filename": "src/memory/embeddings.test.ts", "hashed_secret": "56e1d57b8db262b08bc73c60ed08d8c92e59503f", "is_verified": false, - "line_number": 179 + "line_number": 189 } ], "src/pairing/pairing-store.ts": [ @@ -2119,37 +12957,64 @@ "filename": "src/pairing/pairing-store.ts", "hashed_secret": "f8c6f1ff98c5ee78c27d34a3ca68f35ad79847af", "is_verified": false, - "line_number": 12 + "line_number": 13 + } + ], + "src/pairing/setup-code.test.ts": [ + { + "type": "Base64 High Entropy String", + "filename": "src/pairing/setup-code.test.ts", + "hashed_secret": "4914c103484773b5a8e18448b11919bb349cbff8", + "is_verified": false, + "line_number": 22 + }, + { + "type": "Secret Keyword", + "filename": "src/pairing/setup-code.test.ts", + "hashed_secret": "e5e9fa1ba31ecd1ae84f75caaa474f3a663f05f4", + "is_verified": false, + "line_number": 96 } ], "src/security/audit.test.ts": [ - { - "type": "Hex High Entropy String", - "filename": "src/security/audit.test.ts", - "hashed_secret": "b1775a785f09a6ebaf2dc33d6eaeb98974d9cdb8", - "is_verified": false, - "line_number": 180 - }, - { - "type": "Hex High Entropy String", - "filename": "src/security/audit.test.ts", - "hashed_secret": "fa8da98a5bdb77b4902cbb4338e6e94ea825300e", - "is_verified": false, - "line_number": 209 - }, { "type": "Secret Keyword", "filename": "src/security/audit.test.ts", "hashed_secret": "21f688ab56f76a99e5c6ed342291422f4e57e47f", "is_verified": false, - "line_number": 1046 + "line_number": 2063 }, { "type": "Secret Keyword", "filename": "src/security/audit.test.ts", "hashed_secret": "3dc927d80543dc0f643940b70d066bd4b4c4b78e", "is_verified": false, - "line_number": 1077 + "line_number": 2094 + } + ], + "src/telegram/monitor.test.ts": [ + { + "type": "Secret Keyword", + "filename": "src/telegram/monitor.test.ts", + "hashed_secret": "e5e9fa1ba31ecd1ae84f75caaa474f3a663f05f4", + "is_verified": false, + "line_number": 205 + }, + { + "type": "Secret Keyword", + "filename": "src/telegram/monitor.test.ts", + "hashed_secret": "5934c4d4a4fa5d66ddb3d3fc0bba84996c17a5b7", + "is_verified": false, + "line_number": 233 + } + ], + "src/telegram/webhook.test.ts": [ + { + "type": "Secret Keyword", + "filename": "src/telegram/webhook.test.ts", + "hashed_secret": "e5e9fa1ba31ecd1ae84f75caaa474f3a663f05f4", + "is_verified": false, + "line_number": 42 } ], "src/tts/tts.test.ts": [ @@ -2158,34 +13023,82 @@ "filename": "src/tts/tts.test.ts", "hashed_secret": "2e7a7ee14caebf378fc32d6cf6f557f347c96773", "is_verified": false, - "line_number": 33 + "line_number": 36 }, { "type": "Hex High Entropy String", "filename": "src/tts/tts.test.ts", "hashed_secret": "b214f706bb602c1cc2adc5c6165e73622305f4bb", "is_verified": false, - "line_number": 68 - } - ], - "src/web/qr-image.test.ts": [ - { - "type": "Hex High Entropy String", - "filename": "src/web/qr-image.test.ts", - "hashed_secret": "564666dc1ca6e7318b2d5feeb1ce7b5bf717411e", - "is_verified": false, - "line_number": 12 - } - ], - "test/provider-timeout.e2e.test.ts": [ + "line_number": 98 + }, { "type": "Secret Keyword", - "filename": "test/provider-timeout.e2e.test.ts", - "hashed_secret": "a94a8fe5ccb19ba61c4c0873d391e987982fbbd3", + "filename": "src/tts/tts.test.ts", + "hashed_secret": "75ddfb45216fe09680dfe70eda4f559a910c832c", "is_verified": false, - "line_number": 182 + "line_number": 397 + }, + { + "type": "Secret Keyword", + "filename": "src/tts/tts.test.ts", + "hashed_secret": "e29af93630aa18cc3457cb5b13937b7ab7c99c9b", + "is_verified": false, + "line_number": 413 + }, + { + "type": "Secret Keyword", + "filename": "src/tts/tts.test.ts", + "hashed_secret": "3acfb2c2b433c0ea7ff107e33df91b18e52f960f", + "is_verified": false, + "line_number": 447 + } + ], + "src/tui/gateway-chat.test.ts": [ + { + "type": "Secret Keyword", + "filename": "src/tui/gateway-chat.test.ts", + "hashed_secret": "6255675480f681df08c1704b7b3cd2c49917f0e2", + "is_verified": false, + "line_number": 85 + } + ], + "src/web/login.test.ts": [ + { + "type": "Hex High Entropy String", + "filename": "src/web/login.test.ts", + "hashed_secret": "564666dc1ca6e7318b2d5feeb1ce7b5bf717411e", + "is_verified": false, + "line_number": 60 + } + ], + "ui/src/i18n/locales/en.ts": [ + { + "type": "Secret Keyword", + "filename": "ui/src/i18n/locales/en.ts", + "hashed_secret": "de0ff6b974d6910aca8d6b830e1b761f076d8fe6", + "is_verified": false, + "line_number": 60 + } + ], + "ui/src/i18n/locales/pt-BR.ts": [ + { + "type": "Secret Keyword", + "filename": "ui/src/i18n/locales/pt-BR.ts", + "hashed_secret": "ef7b6f95faca2d7d3a5aa5a6434c89530c6dd243", + "is_verified": false, + "line_number": 60 + } + ], + "vendor/a2ui/README.md": [ + { + "type": "Secret Keyword", + "filename": "vendor/a2ui/README.md", + "hashed_secret": "2619a5397a5d054dab3fe24e6a8da1fbd76ec3a6", + "is_verified": false, + "line_number": 123 } ] }, - "generated_at": "2026-01-25T10:55:04Z" + "generated_at": "2026-02-17T13:34:38Z" } diff --git a/AGENTS.md b/AGENTS.md index 3aaed9009a8..00ae79a0551 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,6 +2,9 @@ - Repo: https://github.com/openclaw/openclaw - GitHub issues/comments/PR comments: use literal multiline strings or `-F - <<'EOF'` (or $'...') for real newlines; never embed "\\n". +- GitHub comment footgun: never use `gh issue/pr comment -b "..."` when body contains backticks or shell chars. Always use single-quoted heredoc (`-F - <<'EOF'`) so no command substitution/escaping corruption. +- GitHub linking footgun: don’t wrap issue/PR refs like `#24643` in backticks when you want auto-linking. Use plain `#24643` (optionally add full URL). +- Security advisory analysis: before triage/severity decisions, read `SECURITY.md` to align with OpenClaw's trust model and design boundaries. ## Project Structure & Module Organization @@ -70,6 +73,10 @@ - Language: TypeScript (ESM). Prefer strict typing; avoid `any`. - Formatting/linting via Oxlint and Oxfmt; run `pnpm check` before commits. +- Never add `@ts-nocheck` and do not disable `no-explicit-any`; fix root causes and update Oxlint/Oxfmt config only when required. +- Never share class behavior via prototype mutation (`applyPrototypeMixins`, `Object.defineProperty` on `.prototype`, or exporting `Class.prototype` for merges). Use explicit inheritance/composition (`A extends B extends C`) or helper composition so TypeScript can typecheck. +- If this pattern is needed, stop and get explicit approval before shipping; default behavior is to split/refactor into an explicit class hierarchy and keep members strongly typed. +- In tests, prefer per-instance stubs over prototype mutation (`SomeClass.prototype.method = ...`) unless a test explicitly documents why prototype-level patching is required. - Add brief code comments for tricky or non-obvious logic. - Keep files concise; extract helpers instead of “V2” copies. Use existing patterns for CLI options and dependency injection via `createDefaultDeps`. - Aim to keep files under ~700 LOC; guideline only (not a hard guardrail). Split/refactor when it improves clarity or testability. @@ -79,6 +86,7 @@ - stable: tagged releases only (e.g. `vYYYY.M.D`), npm dist-tag `latest`. - beta: prerelease tags `vYYYY.M.D-beta.N`, npm dist-tag `beta` (may ship without macOS app). +- beta naming: prefer `-beta.N`; do not mint new `-1/-2` betas. Legacy `vYYYY.M.D-` and `vYYYY.M.D.beta.N` remain recognized. - dev: moving head on `main` (no tag; git checkout main). ## Testing Guidelines @@ -87,6 +95,7 @@ - Naming: match source names with `*.test.ts`; e2e in `*.e2e.test.ts`. - Run `pnpm test` (or `pnpm test:coverage`) before pushing when you touch logic. - Do not set test workers above 16; tried already. +- If local Vitest runs cause memory pressure (common on non-Mac-Studio hosts), use `OPENCLAW_TEST_PROFILE=low OPENCLAW_TEST_SERIAL_GATEWAY=1 pnpm test` for land/gate runs. - Live tests (real keys): `CLAWDBOT_LIVE_TEST=1 pnpm test:live` (OpenClaw-only) or `LIVE=1 pnpm test:live` (includes provider live tests). Docker: `pnpm test:docker:live-models`, `pnpm test:docker:live-gateway`. Onboarding Docker E2E: `pnpm test:docker:onboard`. - Full kit + what’s covered: `docs/testing.md`. - Changelog: user-facing changes only; no internal/meta notes (version alignment, appcast reminders, release process). @@ -110,6 +119,16 @@ ## Git Notes - If `git branch -d/-D ` is policy-blocked, delete the local ref directly: `git update-ref -d refs/heads/`. +- Bulk PR close/reopen safety: if a close action would affect more than 5 PRs, first ask for explicit user confirmation with the exact PR count and target scope/query. + +## GitHub Search (`gh`) + +- Prefer targeted keyword search before proposing new work or duplicating fixes. +- Use `--repo openclaw/openclaw` + `--match title,body` first; add `--match comments` when triaging follow-up threads. +- PRs: `gh search prs --repo openclaw/openclaw --match title,body --limit 50 -- "auto-update"` +- Issues: `gh search issues --repo openclaw/openclaw --match title,body --limit 50 -- "auto-update"` +- Structured output example: + `gh search issues --repo openclaw/openclaw --match title,body --limit 50 --json number,title,state,url,updatedAt -- "auto update" --jq '.[] | "\(.number) | \(.state) | \(.title) | \(.url)"'` ## Security & Configuration Tips @@ -121,6 +140,7 @@ ## GHSA (Repo Advisory) Patch/Publish +- Before reviewing security advisories, read `SECURITY.md`. - Fetch: `gh api /repos/openclaw/openclaw/security-advisories/` - Latest npm: `npm view openclaw version --userconfig "$(mktemp)"` - Private fork PRs must be closed: @@ -128,6 +148,7 @@ `gh pr list -R "$fork" --state open` (must be empty) - Description newline footgun: write Markdown via heredoc to `/tmp/ghsa.desc.md` (no `"\\n"` strings) - Build patch JSON via jq: `jq -n --rawfile desc /tmp/ghsa.desc.md '{summary,severity,description:$desc,vulnerabilities:[...]}' > /tmp/ghsa.patch.json` +- GHSA API footgun: cannot set `severity` and `cvss_vector_string` in the same PATCH; do separate calls. - Patch + publish: `gh api -X PATCH /repos/openclaw/openclaw/security-advisories/ --input /tmp/ghsa.patch.json` (publish = include `"state":"published"`; no `/publish` endpoint) - If publish fails (HTTP 422): missing `severity`/`description`/`vulnerabilities[]`, or private fork has open PRs - Verify: re-fetch; ensure `state=published`, `published_at` set; `jq -r .description | rg '\\\\n'` returns nothing @@ -213,7 +234,7 @@ - skip if package is missing on npm or version already matches. - Keep `openclaw` untouched: never run publish from repo root unless explicitly requested. - Post-check for each release: - - per-plugin: `npm view @openclaw/ version --userconfig "$(mktemp)"` should be `2026.2.16` + - per-plugin: `npm view @openclaw/ version --userconfig "$(mktemp)"` should be `2026.2.17` - core guard: `npm view openclaw version --userconfig "$(mktemp)"` should stay at previous version unless explicitly requested. ## Changelog Release Notes diff --git a/CHANGELOG.md b/CHANGELOG.md index 8ec200ba620..368a6561482 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,810 @@ Docs: https://docs.openclaw.ai -## 2026.2.16 (Unreleased) +## Unreleased + +### Breaking + +- **BREAKING:** non-loopback Control UI now requires explicit `gateway.controlUi.allowedOrigins` (full origins). Startup fails closed when missing unless `gateway.controlUi.dangerouslyAllowHostHeaderOriginFallback=true` is set to use Host-header origin fallback mode. +- **BREAKING:** channel `allowFrom` matching is now ID-only by default across channels that previously allowed mutable name/tag/email principal matching. If you relied on direct mutable-name matching, migrate allowlists to stable IDs (recommended) or explicitly opt back in with `channels..dangerouslyAllowNameMatching=true` (break-glass compatibility mode). (#24907) + +### Changes + +- Subagents/Sessions: add `agents.defaults.subagents.runTimeoutSeconds` so `sessions_spawn` can inherit a configurable default timeout when the tool call omits `runTimeoutSeconds` (unset remains `0`, meaning no timeout). (#24594) Thanks @mitchmcalister. +- Config/Kilo Gateway: Kilo provider flow now surfaces an updated list of models. (#24921) thanks @gumadeiras. +- Auto-reply/Abort shortcuts: expand standalone stop phrases (`stop openclaw`, `stop action`, `stop run`, `stop agent`, `please stop`, and related variants), accept trailing punctuation (for example `STOP OPENCLAW!!!`), and add multilingual stop keywords (including ES/FR/ZH/HI/AR/JP/DE/PT/RU forms) so emergency stop messages are caught more reliably. Thanks @steipete and @vincentkoc. + +### Fixes + +- Control UI/Chat images: harden image-open clicks against reverse tabnabbing by using opener isolation (`noopener,noreferrer` plus `window.opener = null`). (#18685) Thanks @Mariana-Codebase. +- Security/iOS deep links: require local confirmation (or trusted key) before forwarding `openclaw://agent` requests from iOS to gateway `agent.request`, and strip unkeyed delivery-routing fields to reduce exfiltration risk. This ships in the next npm release. Thanks @GCXWLP for reporting. +- Security/Export session HTML: escape raw HTML markdown tokens in the exported session viewer, harden tree/header metadata rendering against HTML injection, and sanitize image data-URL MIME types in export output to prevent stored XSS when opening exported HTML files. This ships in the next npm release. Thanks @allsmog for reporting. +- Security/Session export: harden exported HTML image rendering against data-URL attribute injection by validating image MIME/base64 fields, rejecting malformed base64 input in media ingestion paths, and dropping invalid tool-image payloads. +- Security/Image tool: enforce `tools.fs.workspaceOnly` for sandboxed `image` path resolution so mounted out-of-workspace paths are blocked before media bytes are loaded/sent to vision providers. This ships in the next npm release. Thanks @tdjackey for reporting. +- Security/Sandbox: enforce `tools.exec.applyPatch.workspaceOnly` and `tools.fs.workspaceOnly` for `apply_patch` in sandbox-mounted paths so writes/deletes cannot escape the workspace boundary via mounts like `/agent` unless explicitly opted out (`tools.exec.applyPatch.workspaceOnly=false`). This ships in the next npm release. Thanks @tdjackey for reporting. +- Security/Commands: enforce sender-only matching for `commands.allowFrom` by blocking conversation-shaped `From` identities (`channel:`, `group:`, `thread:`, `@g.us`) while preserving direct-message fallback when sender fields are missing. Ships in the next npm release. Thanks @jiseoung. +- Security/Config writes: block reserved prototype keys in account-id normalization and route account config resolution through own-key lookups, hardening `/allowlist` and account-scoped config paths against prototype-chain pollution. +- Security/Channels: unify dangerous name-matching policy checks (`dangerouslyAllowNameMatching`) across core and extension channels, share mutable-allowlist detectors between `openclaw doctor` and `openclaw security audit`, and scan all configured accounts (not only the default account) in channel security audit findings. +- Security/Exec approvals: bind `host=node` approvals to explicit `nodeId`, reject cross-node replay of approved `system.run` requests, and include the target node in approval prompts. This ships in the next npm release. Thanks @tdjackey for reporting. +- Security/Exec approvals: restore two-phase approval registration + wait-decision handling for gateway/node exec paths, requiring approval IDs to be registered before returning `approval-pending` and honoring server-assigned approval IDs during wait resolution to prevent orphaned `/approve` flows and immediate-return races (`ask:on-miss`). This ships in the next npm release. Thanks @vitalyis for reporting. +- Security/Exec approvals: enforce canonical wrapper execution plans across allowlist analysis and runtime execution (node host + gateway host), fail closed on semantic `env` wrapper usage, and reject unknown short safe-bin flags to prevent `env -S/--split-string` interpretation-mismatch bypasses. This ships in the next npm release. Thanks @tdjackey for reporting. +- Security/Exec approvals: recognize `busybox`/`toybox` shell applets in wrapper analysis and allow-always persistence, persist inner executables instead of multiplexer wrapper binaries, and fail closed when multiplexer unwrapping is unsafe to prevent allow-always bypasses. This ships in the next npm release. Thanks @jiseoung for reporting. +- Security/Exec approvals: for non-default setups that enable `autoAllowSkills`, require pathless invocations plus trusted resolved-path matches so `./`/absolute-path basename collisions cannot satisfy skill auto-allow checks under allowlist mode. This ships in the next npm release. Thanks @tdjackey for reporting. +- Security/Exec: harden `safeBins` long-option validation by rejecting unknown/ambiguous GNU long-option abbreviations and denying sort filesystem-dependent flags (`--random-source`, `--temporary-directory`, `-T`), closing safe-bin denylist bypasses. This ships in the next npm release. Thanks @tdjackey and @jiseoung for reporting. +- Security/Shell env fallback: remove trusted-prefix shell-path fallback and only trust login shells explicitly registered in `/etc/shells`, defaulting to `/bin/sh` when `SHELL` is not registered. This ships in the next npm release. Thanks @tdjackey for reporting. +- Security/Voice Call: harden Twilio webhook replay handling by preserving provider event IDs through normalization, adding bounded replay dedupe, and enforcing per-call turn-token matching for call-state transitions. This ships in the next npm release. Thanks @jiseoung for reporting. +- Telegram/Media SSRF: keep RFC2544 benchmark range (`198.18.0.0/15`) blocked by default, add an explicit SSRF-policy opt-in for Telegram media downloads, and keep other channels/URL fetch paths blocked. (#24982) Thanks @stakeswky. +- WhatsApp/Auto-reply: send only final payloads to WhatsApp, suppress tool/block payload leakage (reasoning/thinking), and force block streaming off for WhatsApp dispatch so final-only delivery cannot cause silent turns. (#24962) Thanks @SidQin-cyber. +- Channels/Reasoning: suppress reasoning/thinking payload segments in the shared channel dispatch path so non-Telegram channels (including WhatsApp and Web) no longer emit internal reasoning blocks as user-visible replies. (#24991) Thanks @stakeswky. +- Discord/Reasoning: suppress reasoning/thinking-only payload blocks from Discord delivery output. (#24969) +- WhatsApp/DM routing: only update main-session last-route state when DM traffic is bound to the main session, preserving isolated `dmScope` routing. (#24949) Thanks @kevinWangSheng. +- WhatsApp/Access control: honor `selfChatMode` in inbound access-control checks. (#24738) +- WhatsApp/Logging: redact outbound recipient identifiers in WhatsApp outbound + heartbeat logs and remove message/poll preview text from those log lines. (#24980) Thanks @coygeek. +- Discord/Threading: recover missing thread parent IDs by refetching thread metadata before resolving parent channel context. (#24897) Thanks @z-x-yang. +- Web UI/i18n: load and hydrate saved locale translations during startup so non-English sessions apply immediately without manual toggling. (#24795) Thanks @chilu18. +- Gateway/Browser control: load `src/browser/server.js` during browser-control startup so the control listener starts reliably when browser control is enabled. (#23974) Thanks @ieaves. +- Browser/Chrome relay: harden debugger detach handling during full-page navigation with bounded auto-reattach retries and better cancellation behavior for user/devtools detaches. (#19766) Thanks @nishantkabra77. +- Browser/Chrome extension options: validate relay `/json/version` payload shape and content type (not just HTTP status) to detect wrong-port gateway checks, and clarify relay port derivation for custom gateway ports (`gateway + 3`). (#22252) Thanks @krizpoon. +- Status/Pairing recovery: show explicit pairing-approval command hints (including requestId when safe) when gateway probe failures report pairing-required closures. (#24771) Thanks @markmusson. +- Onboarding/Custom providers: raise verification probe token budgets for OpenAI and Anthropic compatibility checks to avoid false negatives on strict provider defaults. (#24743) Thanks @Glucksberg. +- Auth/OAuth: classify missing OAuth scopes as auth failures for clearer remediation and retry behavior. (#24761) +- Providers/OpenRouter: when thinking is explicitly off, avoid injecting `reasoning.effort` so reasoning-required models can use provider defaults instead of failing request validation. (#24863) Thanks @DevSecTim. +- Sessions/Reasoning: persist `reasoningLevel: "off"` explicitly instead of deleting it so session overrides survive patch/update flows. (#24406, #24559) +- Cron/Isolated sessions: use full prompt mode for isolated cron runs so skills/extensions are available during cron execution. (#24944) +- Synology Chat/Webhooks: deregister stale webhook routes before re-registering on channel restart to prevent duplicate route handling. (#24971) +- Plugins/Config: use plugin manifest `id` (instead of npm package name) for config entry keys so plugin settings stay bound correctly. (#24796) +- Plugins/Config schema: support legacy plugin schemas without `toJSONSchema()` by falling back to permissive object schema generation. (#24933) Thanks @pandego. +- Gateway/Prompt builder: safely extract text from mixed content arrays when assembling prompts to avoid malformed prompt payloads. (#24946) +- Gateway/Slug generation: respect agent-level model config in slug generation flows. (#24776) +- Agents/Workspace paths: strip null bytes and guard undefined `.trim()` calls for workspace-path handling to avoid `ENOTDIR`/`TypeError` crashes. (#24876, #24875) +- Agents/Tool warnings: suppress `sessions_send` relay errors from chat-facing warning payloads to avoid leaking transient inter-session transport failures. (#24740) Thanks @Glucksberg. +- Sessions/Model overrides: keep stored sub-agent model overrides when `agents.defaults.models` is empty (allow-any mode) instead of resetting to defaults. (#21088) Thanks @Slats24. +- Subagents/Registry: prune orphaned restored runs (missing child session/sessionId) before retry/announce resume to prevent zombie entries and stale completion retries, and clarify status output to report bootstrap-file presence semantics. (#24244) Thanks @HeMuling. +- Subagents/Announce queue: add exponential backoff when queue-drain delivery fails to reduce retry storms. (#24783) +- Doctor/UX: suppress the redundant "Run doctor --fix" hint when already in fix mode with no changes. (#24666) +- CLI/Doctor: correct stale recovery hints to use valid commands (`openclaw gateway status --deep` and `openclaw configure --section model`). (#24485) Thanks @chilu18. +- Doctor/Nix: skip false-positive permission warnings for Nix store symlinks in state-integrity checks. (#24901) +- Update/Systemd: back up an existing systemd unit before overwriting it during update flows. (#24350, #24937) +- Install/Global detection: resolve symlinks when detecting pnpm/bun global install paths. (#24744) +- Infra/Windows TOCTOU: handle Windows `dev=0` edge cases in same-file identity checks. (#24939) +- Exec/Bash tools: clamp poll sleep duration to non-negative values in process polling loops. (#24889) + +## 2026.2.23 + +### Changes + +- Providers/Kilo Gateway: add first-class `kilocode` provider support (auth, onboarding, implicit provider detection, model defaults, transcript/cache-ttl handling, and docs), with default model `kilocode/anthropic/claude-opus-4.6`. (#20212) Thanks @jrf0110 and @markijbema. +- Providers/Vercel AI Gateway: accept Claude shorthand model refs (`vercel-ai-gateway/claude-*`) by normalizing to canonical Anthropic-routed model ids. (#23985) Thanks @sallyom, @markbooch, and @vincentkoc. +- Docs/Prompt caching: add a dedicated prompt-caching reference covering `cacheRetention`, per-agent `params` merge precedence, Bedrock/OpenRouter behavior, and cache-ttl + heartbeat tuning. Thanks @svenssonaxel. +- Gateway/HTTP security headers: add optional `gateway.http.securityHeaders.strictTransportSecurity` support to emit `Strict-Transport-Security` for direct HTTPS deployments, with runtime wiring, validation, tests, and hardening docs. +- Sessions/Cron: harden session maintenance with `openclaw sessions cleanup`, per-agent store targeting, disk-budget controls (`session.maintenance.maxDiskBytes` / `highWaterBytes`), and safer transcript/archive cleanup + run-log retention behavior. (#24753) thanks @gumadeiras. +- Tools/web_search: add `provider: "kimi"` (Moonshot) support with key/config schema wiring and a corrected two-step `$web_search` tool flow that echoes tool results before final synthesis, including citation extraction from search results. (#16616, #18822) Thanks @adshine. +- Media understanding/Video: add a native Moonshot video provider and include Moonshot in auto video key detection, plus refactor video execution to honor `entry/config/provider` baseUrl+header precedence (matching audio behavior). (#12063) Thanks @xiaoyaner0201. +- Agents/Config: support per-agent `params` overrides merged on top of model defaults (including `cacheRetention`) so mixed-traffic agents can tune cache behavior independently. (#17470, #17112) Thanks @rrenamed. +- Agents/Bootstrap: cache bootstrap file snapshots per session key and clear them on session reset/delete, reducing prompt-cache invalidations from in-session `AGENTS.md`/`MEMORY.md` writes. (#22220) Thanks @anisoptera. + +### Breaking + +- **BREAKING:** browser SSRF policy now defaults to trusted-network mode (`browser.ssrfPolicy.dangerouslyAllowPrivateNetwork=true` when unset), and canonical config uses `browser.ssrfPolicy.dangerouslyAllowPrivateNetwork` instead of `browser.ssrfPolicy.allowPrivateNetwork`. `openclaw doctor --fix` migrates the legacy key automatically. + +### Fixes + +- Security/Config: redact sensitive-looking dynamic catchall keys in `config.get` snapshots (for example `env.*` and `skills.entries.*.env.*`) and preserve round-trip restore behavior for those redacted sentinels. Thanks @merc1305. +- Tests/Vitest: tier local parallel worker defaults by host memory, keep gateway serial by default on non-high-memory hosts, and document a low-profile fallback command for memory-constrained land/gate runs to prevent local OOMs. (#24719) Thanks @ngutman. +- WhatsApp/Group policy: fix `groupAllowFrom` sender filtering when `groupPolicy: "allowlist"` is set without explicit `groups` — previously all group messages were blocked even for allowlisted senders. (#24670) +- Agents/Context pruning: extend `cache-ttl` eligibility to Moonshot/Kimi and ZAI/GLM providers (including OpenRouter model refs), so `contextPruning.mode: "cache-ttl"` is no longer silently skipped for those sessions. (#24497) Thanks @lailoo. +- Doctor/Memory: query gateway-side default-agent memory embedding readiness during `openclaw doctor` (instead of inferring from generic gateway health), and warn when the gateway memory probe is unavailable or not ready while keeping `openclaw configure` remediation guidance. (#22327) thanks @therk. +- Sessions/Store: canonicalize inbound mixed-case session keys for metadata and route updates, and migrate legacy case-variant entries to a single lowercase key to prevent duplicate sessions and missing TUI/WebUI history. (#9561) Thanks @hillghost86. +- Telegram/Reactions: soft-fail reaction action errors (policy/token/emoji/API), accept snake_case `message_id`, and fallback to inbound message-id context when explicit `messageId` is omitted so DM reactions stay stable without regeneration loops. (#20236, #21001) Thanks @PeterShanxin and @vincentkoc. +- Telegram/Polling: scope persisted polling offsets to bot identity and reuse a single awaited runner-stop path on abort/retry, preventing cross-token offset bleed and overlapping pollers during restart/error recovery. (#10850, #11347) Thanks @talhaorak, @anooprdawar, and @vincentkoc. +- Telegram/Reasoning: when `/reasoning off` is active, suppress reasoning-only delivery segments and block raw fallback resend of suppressed `Reasoning:`/`` text, preventing internal reasoning leakage in legacy sessions while preserving answer delivery. (#24626, #24518) +- Agents/Reasoning: when model-default thinking is active (for example `thinking=low`), keep auto-reasoning disabled unless explicitly enabled, preventing `Reasoning:` thinking-block leakage in channel replies. (#24335, #24290) thanks @Kay-051. +- Agents/Reasoning: avoid classifying provider reasoning-required errors as context overflows so these failures no longer trigger compaction-style overflow recovery. (#24593) Thanks @vincentkoc. +- Agents/Models: codify `agents.defaults.model` / `agents.defaults.imageModel` config-boundary input as `string | {primary,fallbacks}`, split explicit vs effective model resolution, and fix `models status --agent` source attribution so defaults-inherited agents are labeled as `defaults` while runtime selection still honors defaults fallback. (#24210) thanks @bianbiandashen. +- Agents/Compaction: pass `agentDir` into manual `/compact` command runs so compaction auth/profile resolution stays scoped to the active agent. (#24133) thanks @Glucksberg. +- Agents/Compaction: pass model metadata through the embedded runtime so safeguard summarization can run when `ctx.model` is unavailable, avoiding repeated `"Summary unavailable due to context limits"` fallback summaries. (#3479) Thanks @battman21, @hanxiao and @vincentkoc. +- Agents/Compaction: cancel safeguard compaction when summary generation cannot run (missing model/API key or summarization failure), preserving history instead of truncating to fallback `"Summary unavailable"` text. (#10711) Thanks @DukeDeSouth and @vincentkoc. +- Agents/Tools: make `session_status` read transcript-derived usage mid-turn and tail-read session logs for cache-aware context reporting without full-log scans. (#22387) Thanks @1ucian. +- Agents/Overflow: detect additional provider context-overflow error shapes (including `input length` + `max_tokens` exceed-context variants) so failures route through compaction/recovery paths instead of leaking raw provider errors to users. (#9951) Thanks @echoVic and @Glucksberg. +- Agents/Overflow: add Chinese context-overflow pattern detection in `isContextOverflowError` so localized provider errors route through overflow recovery paths. (#22855) Thanks @Clawborn. +- Agents/Failover: treat HTTP 502/503/504 errors as failover-eligible transient timeouts so fallback chains can switch providers/models during upstream outages instead of retrying the same failing target. (#20999) Thanks @taw0002 and @vincentkoc. +- Auto-reply/Inbound metadata: hide direct-chat `message_id`/`message_id_full` and sender metadata only from normalized chat type (not sender-id sentinels), preserving group metadata visibility and preventing sender-id spoofed direct-mode classification. (#24373) thanks @jd316. +- Auto-reply/Inbound metadata: move dynamic inbound `flags` (reply/forward/thread/history) from system metadata to user-context conversation info, preventing turn-by-turn prompt-cache invalidation from flag toggles. (#21785) Thanks @aidiffuser. +- Auto-reply/Sessions: remove auth-key labels from `/new` and `/reset` confirmation messages so session reset notices never expose API key prefixes or env-key labels in chat output. (#24384, #24409) Thanks @Clawborn. +- Slack/Group policy: move Slack account `groupPolicy` defaulting to provider-level schema defaults so multi-account configs inherit top-level `channels.slack.groupPolicy` instead of silently overriding inheritance with per-account `allowlist`. (#17579) Thanks @ZetiMente. +- Providers/Anthropic: skip `context-1m-*` beta injection for OAuth/subscription tokens (`sk-ant-oat-*`) while preserving OAuth-required betas, avoiding Anthropic 401 auth failures when `params.context1m` is enabled. (#10647, #20354) Thanks @ClumsyWizardHands and @dcruver. +- Providers/DashScope: mark DashScope-compatible `openai-completions` endpoints as `supportsDeveloperRole=false` so OpenClaw sends `system` instead of unsupported `developer` role on Qwen/DashScope APIs. (#19130) Thanks @Putzhuawa and @vincentkoc. +- Providers/Bedrock: disable prompt-cache retention for non-Anthropic Bedrock models so Nova/Mistral requests do not send unsupported cache metadata. (#20866) Thanks @pierreeurope. +- Providers/Bedrock: apply Anthropic-Claude cacheRetention defaults and runtime pass-through for `amazon-bedrock/*anthropic.claude*` model refs, while keeping non-Anthropic Bedrock models excluded. (#22303) Thanks @snese. +- Providers/OpenRouter: remove conflicting top-level `reasoning_effort` when injecting nested `reasoning.effort`, preventing OpenRouter 400 payload-validation failures for reasoning models. (#24120) thanks @tenequm. +- Providers/Groq: avoid classifying Groq TPM limit errors as context overflow so throttling paths no longer trigger overflow recovery logic. (#16176) Thanks @dddabtc. +- Gateway/WS: close repeated post-handshake `unauthorized role:*` request floods per connection and sample duplicate rejection logs, preventing a single misbehaving client from degrading gateway responsiveness. (#20168) Thanks @acy103, @vibecodooor, and @vincentkoc. +- Gateway/Restart: treat child listener PIDs as owned by the service runtime PID during restart health checks to avoid false stale-process kills and restart timeouts on launchd/systemd. (#24696) Thanks @gumadeiras. +- Config/Write: apply `unsetPaths` with immutable path-copy updates so config writes never mutate caller-provided objects, and harden `openclaw config get/set/unset` path traversal by rejecting prototype-key segments and inherited-property traversal. (#24134) thanks @frankekn. +- Channels/WhatsApp: accept `channels.whatsapp.enabled` in config validation to match built-in channel auto-enable behavior, preventing `Unrecognized key: "enabled"` failures during channel setup. (#24263) +- Security/Exec: detect obfuscated commands before exec allowlist decisions and require explicit approval for obfuscation patterns. (#8592) Thanks @CornBrother0x and @vincentkoc. +- Security/ACP: harden ACP client permission auto-approval to require trusted core tool IDs, ignore untrusted `toolCall.kind` hints, and scope `read` auto-approval to the active working directory so unknown tool names and out-of-scope file reads always prompt. This ships in the next npm release. Thanks @nedlir for reporting. +- Security/Skills: escape user-controlled prompt, filename, and output-path values in `openai-image-gen` HTML gallery generation to prevent stored XSS in generated `index.html` output. (#12538) Thanks @CornBrother0x. +- Security/Skills: harden `skill-creator` packaging by skipping symlink entries and rejecting files whose resolved paths escape the selected skill root. (#24260, #16959) Thanks @CornBrother0x and @vincentkoc. +- Security/OTEL: redact sensitive values (API keys, tokens, credential fields) from diagnostics-otel log bodies, log attributes, and error/reason span fields before OTLP export. (#12542) Thanks @brandonwise. +- Security/CI: add pre-commit security hook coverage for private-key detection and production dependency auditing, and enforce those checks in CI alongside baseline secret scanning. Thanks @vincentkoc. +- Skills/Python: harden skill script packaging and validation edge cases (self-including `.skill` outputs, CRLF frontmatter parsing, strict `--days` validation, and safer image file loading), with expanded Python regression coverage. Thanks @vincentkoc. +- Skills/Python: add CI + pre-commit linting (`ruff`) and pytest discovery coverage for Python scripts/tests under `skills/`, including package test execution from repo root. Thanks @vincentkoc. + +## 2026.2.22 + +### Changes + +- Control UI/Agents: make the Tools panel data-driven from runtime `tools.catalog`, add per-tool provenance labels (`core` / `plugin:` + optional marker), and keep a static fallback list when the runtime catalog is unavailable. +- Web Search/Gemini: add grounded Gemini provider support with provider auto-detection and config/docs updates. (#13075, #13074) Thanks @akoscz. +- Control UI/Cron: add full web cron edit parity (including clone and richer validation/help text), plus all-jobs run history with pagination/search/sort/multi-filter controls and improved cron page layout for cleaner scheduling and failure triage workflows. +- Provider/Mistral: add support for the Mistral provider, including memory embeddings and voice support. (#23845) Thanks @vincentkoc. +- Update/Core: add an optional built-in auto-updater for package installs (`update.auto.*`), default-off, with stable rollout delay+jitter and beta hourly cadence. +- CLI/Update: add `openclaw update --dry-run` to preview channel/tag/target/restart actions without mutating config, installing, syncing plugins, or restarting. +- Config/UI: add tag-aware settings filtering and broaden config labels/help copy so fields are easier to discover and understand in the dashboard config screen. +- Channels/Synology Chat: add a native Synology Chat channel plugin with webhook ingress, direct-message routing, outbound send/media support, per-account config, and DM policy controls. (#23012) +- iOS/Talk: prefetch TTS segments and suppress expected speech-cancellation errors for smoother talk playback. (#22833) Thanks @ngutman. +- Memory/FTS: add Spanish and Portuguese stop-word filtering for query expansion in FTS-only search mode, improving conversational recall for both languages. Thanks @vincentkoc. +- Memory/FTS: add Japanese-aware query expansion tokenization and stop-word filtering (including mixed-script terms like ASCII + katakana) for FTS-only search mode. Thanks @vincentkoc. +- Memory/FTS: add Korean stop-word filtering and particle-aware keyword extraction (including mixed Korean/English stems) for query expansion in FTS-only search mode. (#18899) Thanks @ruypang. +- Memory/FTS: add Arabic stop-word filtering for query expansion in FTS-only search mode to reduce conversational filler in Arabic memory searches. Thanks @vincentkoc. +- Discord/Allowlist: canonicalize resolved Discord allowlist names to IDs and split resolution flow for clearer fail-closed behavior. +- Channels/Config: unify channel preview streaming config handling with a shared resolver and canonical migration path. +- Gateway/Auth: unify call/probe/status/auth credential-source precedence on shared resolver helpers, with table-driven parity coverage across gateway entrypoints. +- Gateway/Auth: refactor gateway credential resolution and websocket auth handshake paths to use shared typed auth contexts, including explicit `auth.deviceToken` support in connect frames and tests. +- Skills: remove bundled `food-order` skill from this repo; manage/install it from ClawHub instead. +- Docs/Subagents: make thread-bound session guidance channel-first instead of Discord-specific, and list thread-supporting channels explicitly. (#23589) Thanks @osolmaz. + +### Breaking + +- **BREAKING:** removed Google Antigravity provider support and the bundled `google-antigravity-auth` plugin. Existing `google-antigravity/*` model/profile configs no longer work; migrate to `google-gemini-cli` or other supported providers. +- **BREAKING:** tool-failure replies now hide raw error details by default. OpenClaw still sends a failure summary, but detailed error suffixes (for example provider/runtime messages and local path fragments) now require `/verbose on` or `/verbose full`. +- **BREAKING:** CLI local onboarding now sets `session.dmScope` to `per-channel-peer` by default for new/implicit DM scope configuration. If you depend on shared DM continuity across senders, explicitly set `session.dmScope` to `main`. (#23468) Thanks @bmendonca3. +- **BREAKING:** unify channel preview-streaming config to `channels..streaming` with enum values `off | partial | block | progress`, and move Slack native stream toggle to `channels.slack.nativeStreaming`. Legacy keys (`streamMode`, Slack boolean `streaming`) are still read and migrated by `openclaw doctor --fix`, but canonical saved config/docs now use the unified names. +- **BREAKING:** remove legacy Gateway device-auth signature `v1`. Device-auth clients must now sign `v2` payloads with the per-connection `connect.challenge` nonce and send `device.nonce`; nonce-less connects are rejected. + +### Fixes + +- Sessions/Resilience: ignore invalid persisted `sessionFile` metadata and fall back to the derived safe transcript path instead of aborting session resolution for handlers and tooling. (#16061) Thanks @haoyifan and @vincentkoc. +- Sessions/Paths: resolve symlinked state-dir aliases during transcript-path validation while preserving safe cross-agent/state-root compatibility for valid `agents//sessions/**` paths. (#18593) Thanks @EpaL and @vincentkoc. +- Agents/Compaction: count auto-compactions only after a non-retry `auto_compaction_end`, keeping session `compactionCount` aligned to completed compactions. +- Security/CLI: redact sensitive values in `openclaw config get` output before printing config paths, preventing credential leakage to terminal output/history. (#13683) Thanks @SleuthCo. +- Agents/Moonshot: force `supportsDeveloperRole=false` for Moonshot-compatible `openai-completions` models (provider `moonshot` and Moonshot base URLs), so initial runs no longer send unsupported `developer` roles that trigger `ROLE_UNSPECIFIED` errors. (#21060, #22194) Thanks @ShengFuC. +- Agents/Kimi: classify Moonshot `Your request exceeded model token limit` failures as context overflows so auto-compaction and user-facing overflow recovery trigger correctly instead of surfacing raw invalid-request errors. (#9562) Thanks @danilofalcao. +- Providers/Moonshot: mark Kimi K2.5 as image-capable in implicit + onboarding model definitions, and refresh stale explicit provider capability fields (`input`/`reasoning`/context limits) from implicit catalogs so existing configs pick up Moonshot vision support without manual model rewrites. (#13135, #4459) Thanks @manikv12. +- Agents/Transcript: enable consecutive-user turn merging for strict non-OpenAI `openai-completions` providers (for example Moonshot/Kimi), reducing `roles must alternate` ordering failures on OpenAI-compatible endpoints while preserving current OpenRouter/Opencode behavior. (#7693) +- Install/Discord Voice: make `@discordjs/opus` an optional dependency so `openclaw` install/update no longer hard-fails when native Opus builds fail, while keeping `opusscript` as the runtime fallback decoder for Discord voice flows. (#23737, #23733, #23703) Thanks @jeadland, @Sheetaa, and @Breakyman. +- Docker/Setup: precreate `$OPENCLAW_CONFIG_DIR/identity` during `docker-setup.sh` so CLI commands that need device identity (for example `devices list`) avoid `EACCES ... /home/node/.openclaw/identity` failures on restrictive bind mounts. (#23948) Thanks @ackson-beep. +- Exec/Background: stop applying the default exec timeout to background sessions (`background: true` or explicit `yieldMs`) when no explicit timeout is set, so long-running background jobs are no longer terminated at the default timeout boundary. (#23303) +- Slack/Threading: sessions: keep parent-session forking and thread-history context active beyond first turn by removing first-turn-only gates in session init, thread-history fetch, and reply prompt context injection. (#23843, #23090) Thanks @vincentkoc and @Taskle. +- Slack/Threading: respect `replyToMode` when Slack auto-populates top-level `thread_ts`, and ignore inline `replyToId` directive tags when `replyToMode` is `off` so thread forcing stays disabled unless explicitly configured. (#23839, #23320, #23513) Thanks @vincentkoc and @dorukardahan. +- Slack/Extension: forward `message read` `threadId` to `readMessages` and use delivery-context `threadId` as outbound `thread_ts` fallback so extension replies/reads stay in the correct Slack thread. (#22216, #22485, #23836) Thanks @vincentkoc, @lan17 and @dorukardahan. +- Slack/Upload: resolve bare user IDs (U-prefix) to DM channel IDs via `conversations.open` before calling `files.uploadV2`, which rejects non-channel IDs. `chat.postMessage` tolerates user IDs directly, but `files.uploadV2` → `completeUploadExternal` validates `channel_id` against `^[CGDZ][A-Z0-9]{8,}$`, causing `invalid_arguments` when agents reply with media to DM conversations. +- Webchat/Chat: apply assistant `final` payload messages directly to chat state so sent turns render without waiting for a full history refresh cycle. (#14928) Thanks @BradGroux. +- Webchat/Chat: for out-of-band final events (for example tool-call side runs), append provided final assistant payloads directly instead of forcing a transient history reset. (#11139) Thanks @AkshayNavle. +- Webchat/Performance: reload `chat.history` after final events only when the final payload lacks a renderable assistant message, avoiding expensive full-history refreshes on normal turns. (#20588) Thanks @amzzzzzzz. +- Webchat/Sessions: preserve external session routing metadata when internal `chat.send` turns run under `webchat`, so explicit channel-keyed sessions (for example Telegram) no longer get rewritten to `webchat` and misroute follow-up delivery. (#23258) Thanks @binary64. +- Webchat/Sessions: preserve existing session `label` across `/new` and `/reset` rollovers so reset sessions remain discoverable in session history lists. (#23755) Thanks @ThunderStormer. +- Gateway/Chat UI: strip inline reply/audio directive tags from non-streaming final webchat broadcasts (including `chat.inject`) while preserving empty-string message content when tags are the entire reply. (#23298) Thanks @SidQin-cyber. +- Chat/UI: strip inline reply/audio directive tags (`[[reply_to_current]]`, `[[reply_to:]]`, `[[audio_as_voice]]`) from displayed chat history, live chat event output, and session preview snippets so control tags no longer leak into user-visible surfaces. +- Gateway/Chat UI: sanitize non-streaming final `chat.send`/`chat.inject` payload text with the same envelope/untrusted-context stripping used by `chat.history`, preventing `<<>>` wrapper markup from rendering in Control UI chat. (#24012) Thanks @mittelaltergouda. +- Telegram/Media: send a user-facing Telegram reply when media download fails (non-size errors) instead of silently dropping the message. +- Telegram/Webhook: keep webhook monitors alive until gateway abort signals fire, preventing false channel exits and immediate webhook auto-restart loops. +- Telegram/Polling: retry recoverable setup-time network failures in monitor startup and await runner teardown before retry to avoid overlapping polling sessions. +- Telegram/Polling: clear Telegram webhooks (`deleteWebhook`) before starting long-poll `getUpdates`, including retry handling for transient cleanup failures. +- Telegram/Webhook: add `channels.telegram.webhookPort` config support and pass it through plugin startup wiring to the monitor listener. +- Browser/Extension Relay: refactor the MV3 worker to preserve debugger attachments across relay drops, auto-reconnect with bounded backoff+jitter, persist and rehydrate attached tab state via `chrome.storage.session`, recover from `target_closed` navigation detaches, guard stale socket handlers, enforce per-tab operation locks and per-request timeouts, and add lifecycle keepalive/badge refresh hooks (`alarms`, `webNavigation`). (#15099, #6175, #8468, #9807) +- Browser/Relay: treat extension websocket as connected only when `OPEN`, allow reconnect when a stale `CLOSING/CLOSED` extension socket lingers, and guard stale socket message/close handlers so late events cannot clear active relay state; includes regression coverage for live-duplicate `409` rejection and immediate reconnect-after-close races. (#15099, #18698, #20688) +- Browser/Remote CDP: extend stale-target recovery so `ensureTabAvailable()` now reuses the sole available tab for remote CDP profiles (same behavior as extension profiles) while preserving strict `tab not found` errors when multiple tabs exist; includes remote-profile regression tests. (#15989) +- Gateway/Pairing: treat `operator.admin` as satisfying other `operator.*` scope checks during device-auth verification so local CLI/TUI sessions stop entering pairing-required loops for pairing/approval-scoped commands. (#22062, #22193, #21191) Thanks @Botaccess, @jhartshorn, and @ctbritt. +- Gateway/Pairing: auto-approve loopback `scope-upgrade` pairing requests (including device-token reconnects) so local clients do not disconnect on pairing-required scope elevation. (#23708) Thanks @widingmarcus-cyber. +- Gateway/Scopes: include `operator.read` and `operator.write` in default operator connect scope bundles across CLI, Control UI, and macOS clients so write-scoped announce/sub-agent follow-up calls no longer hit `pairing required` disconnects on loopback gateways. (#22582) thanks @YuzuruS. +- Gateway/Pairing: treat operator.admin pairing tokens as satisfying operator.write requests so legacy devices stop looping through scope-upgrade prompts introduced in 2026.2.19. (#23125, #23006) Thanks @vignesh07. +- Gateway/Restart: fix restart-loop edge cases by keeping `openclaw.mjs -> dist/entry.js` bootstrap detection explicit, reacquiring the gateway lock for in-process restart fallback paths, and tightening restart-loop regression coverage. (#23416) Thanks @jeffwnli. +- Gateway/Lock: use optional gateway-port reachability as a primary stale-lock liveness signal (and wire gateway run-loop lock acquisition to the resolved port), reducing false "already running" lockouts after unclean exits. (#23760) Thanks @Operative-001. +- Delivery/Queue: quarantine queue entries immediately on known permanent delivery errors (for example invalid recipients or missing conversation references) by moving them to `failed/` instead of retrying on every restart. (#23794) Thanks @aldoeliacim. +- Cron/Status: split execution outcome (`lastRunStatus`) from delivery outcome (`lastDeliveryStatus`) in persisted cron state, finished events, and run history so failed/unknown announcement delivery is visible without conflating it with run errors. +- Cron/Delivery: route text-only announce jobs with explicit thread/topic targets through direct outbound delivery so forum/thread destinations do not get dropped by intermediary announce turns. (#23841) Thanks @AndrewArto. +- Cron: honor `cron.maxConcurrentRuns` in the timer loop so due jobs can execute up to the configured parallelism instead of always running serially. (#11595) Thanks @Takhoffman. +- Cron/Run: enforce the same per-job timeout guard for manual `cron.run` executions as timer-driven runs, including abort propagation for isolated agent jobs, so forced runs cannot wedge indefinitely. (#23704) Thanks @tkuehnl. +- Cron/Run: persist the manual-run `runningAtMs` marker before releasing the cron lock so overlapping timer ticks cannot start the same job concurrently. +- Cron/Startup: enforce per-job timeout guards for startup catch-up replay runs so missed isolated jobs cannot hang indefinitely during gateway boot recovery. +- Cron/Main session: honor abort/timeout signals while retrying `wakeMode=now` heartbeat contention loops so main-target cron runs stop promptly instead of waiting through the full busy-retry window. +- Cron/Schedule: for `every` jobs, prefer `lastRunAtMs + everyMs` when still in the future after restarts, then fall back to anchor scheduling for catch-up windows, so NEXT timing matches the last successful cadence. (#22895) Thanks @SidQin-cyber. +- Cron/Service: execute manual `cron.run` jobs outside the cron lock (while still persisting started/finished state atomically) so `cron.list` and `cron.status` remain responsive during long forced runs. (#23628) Thanks @dsgraves. +- Cron/Timer: keep a watchdog recheck timer armed while `onTimer` is actively executing so the scheduler continues polling even if a due-run tick stalls for an extended period. (#23628) Thanks @dsgraves. +- Cron/Run log: clean up settled per-path run-log write queue entries so long-running cron uptime does not retain stale promise bookkeeping in memory. +- Cron/Run log: harden `cron.runs` run-log path resolution by rejecting path-separator `id`/`jobId` inputs and enforcing reads within the per-cron `runs/` directory. +- Cron/Announce: when announce delivery target resolution fails (for example multiple configured channels with no explicit target), skip injecting fallback `Cron (error): ...` into the main session so runs fail cleanly without accidental last-route sends. (#24074) +- Cron/Telegram: validate cron `delivery.to` with shared Telegram target parsing and resolve legacy `@username`/`t.me` targets to numeric IDs at send-time for deterministic delivery target writeback. (#21930) Thanks @kesor. +- Telegram/Targets: normalize unprefixed topic-qualified targets through the shared parse/normalize path so valid `@channel:topic:` and `:topic:` routes are recognized again. (#24166) Thanks @obviyus. +- Cron/Isolation: force fresh session IDs for isolated cron runs so `sessionTarget="isolated"` executions never reuse prior run context. (#23470) Thanks @echoVic. +- Plugins/Install: strip `workspace:*` devDependency entries from copied plugin manifests before `npm install --omit=dev`, preventing `EUNSUPPORTEDPROTOCOL` install failures for npm-published channel plugins (including Feishu and MS Teams). +- Feishu/Plugins: restore bundled Feishu SDK availability for global installs and strip `openclaw: workspace:*` from plugin `devDependencies` during plugin-version sync so npm-installed Feishu plugins do not fail dependency install. (#23611, #23645, #23603) +- Config/Channels: auto-enable built-in channels by writing `channels..enabled=true` (not `plugins.entries.`), and stop adding built-ins to `plugins.allow`, preventing `plugins.entries.telegram: plugin not found` validation failures. +- Config/Channels: when `plugins.allow` is active, auto-enable/enable flows now also allowlist configured built-in channels so `channels..enabled=true` cannot remain blocked by restrictive plugin allowlists. +- Plugins/Discovery: ignore scanned extension backup/disabled directory patterns (for example `.backup-*`, `.bak`, `.disabled*`) and move updater backup directories under `.openclaw-install-backups`, preventing duplicate plugin-id collisions from archived copies. +- Plugins/CLI: make `openclaw plugins enable` and plugin install/link flows update allowlists via shared plugin-enable policy so enabled plugins are not left disabled by allowlist mismatch. (#23190) Thanks @downwind7clawd-ctrl. +- Security/Voice Call: harden media stream WebSocket handling against pre-auth idle-connection DoS by adding strict pre-start timeouts, pending/per-IP connection limits, and total connection caps for streaming endpoints. This ships in the next npm release. Thanks @jiseoung for reporting. +- Security/Sessions: redact sensitive token patterns from `sessions_history` tool output and surface `contentRedacted` metadata when masking occurs. (#16928) Thanks @aether-ai-agent. +- Security/Exec: stop trusting `PATH`-derived directories for safe-bin allowlist checks, add explicit `tools.exec.safeBinTrustedDirs`, and pin safe-bin shell execution to resolved absolute executable paths to prevent binary-shadowing approval bypasses. This ships in the next npm release. Thanks @tdjackey for reporting. +- Security/Elevated: match `tools.elevated.allowFrom` against sender identities only (not recipient `ctx.To`), closing a recipient-token bypass for `/elevated` authorization. This ships in the next npm release. Thanks @jiseoung for reporting. +- Security/Feishu: enforce ID-only allowlist matching for DM/group sender authorization, normalize Feishu ID prefixes during checks, and ignore mutable display names so display-name collisions cannot satisfy allowlist entries. This ships in the next npm release. Thanks @jiseoung for reporting. +- Security/Group policy: harden `channels.*.groups.*.toolsBySender` matching by requiring explicit sender-key types (`id:`, `e164:`, `username:`, `name:`), preventing cross-identifier collisions across mutable/display-name fields while keeping legacy untyped keys on a deprecated ID-only path. This ships in the next npm release. Thanks @jiseoung for reporting. +- Channels/Group policy: fail closed when `groupPolicy: "allowlist"` is set without explicit `groups`, honor account-level `groupPolicy` overrides, and enforce `groupPolicy: "disabled"` as a hard group block. (#22215) Thanks @etereo. +- Telegram/Discord extensions: propagate trusted `mediaLocalRoots` through extension outbound `sendMedia` options so extension direct-send media paths honor agent-scoped local-media allowlists. (#20029, #21903, #23227) +- Agents/Exec: honor explicit agent context when resolving `tools.exec` defaults for runs with opaque/non-agent session keys, so per-agent `host/security/ask` policies are applied consistently. (#11832) +- CLI/Sessions: resolve implicit session-store path templates with the configured default agent ID so named-agent setups do not silently read/write stale `agent:main` session/auth stores. (#22685) Thanks @sene1337. +- Doctor/Security: add an explicit warning that `approvals.exec.enabled=false` disables forwarding only, while enforcement remains driven by host-local `exec-approvals.json` policy. (#15047) +- Sandbox/Docker: default sandbox container user to the workspace owner `uid:gid` when `agents.*.sandbox.docker.user` is unset, fixing non-root gateway file-tool permissions under capability-dropped containers. (#20979) +- Plugins/Media sandbox: propagate trusted `mediaLocalRoots` through plugin action dispatch (including Discord/Telegram action adapters) so plugin send paths enforce the same agent-scoped local-media sandbox roots as core outbound sends. (#20258, #22718) +- Agents/Workspace guard: map sandbox container-workdir file-tool paths (for example `/workspace/...` and `file:///workspace/...`) to host workspace roots before workspace-only validation, preventing false `Path escapes sandbox root` rejections for sandbox file tools. (#9560) +- Gateway/Exec approvals: expire approval requests immediately when no approval-capable gateway clients are connected and no forwarding targets are available, avoiding delayed approvals after restarts/offline approver windows. (#22144) +- Security/Exec approvals: when approving wrapper commands with allow-always in allowlist mode, persist inner executable paths for known dispatch wrappers (`env`, `nice`, `nohup`, `stdbuf`, `timeout`) and fail closed (no persisted entry) when wrapper unwrapping is not safe, preventing wrapper-path approval bypasses. Thanks @tdjackey for reporting. +- Node/macOS exec host: default headless macOS node `system.run` to local execution and only route through the companion app when `OPENCLAW_NODE_EXEC_HOST=app` is explicitly set, avoiding companion-app filesystem namespace mismatches during exec. (#23547) +- Sandbox/Media: map container workspace paths (`/workspace/...` and `file:///workspace/...`) back to the host sandbox root for outbound media validation, preventing false deny errors for sandbox-generated local media. (#23083) Thanks @echo931. +- Sandbox/Docker: apply custom bind mounts after workspace mounts and prioritize bind-source resolution on overlapping paths, so explicit workspace binds are no longer ignored. (#22669) Thanks @tasaankaeris. +- Exec approvals/Forwarding: restore Discord text forwarding when component approvals are not configured, and carry request snapshots through resolve events so resolved notices still forward after cache misses/restarts. (#22988) Thanks @bubmiller. +- Control UI/WebSocket: stop and clear the browser gateway client on UI teardown so remounts cannot leave orphan websocket clients that create duplicate active connections. (#23422) Thanks @floatinggball-design. +- Control UI/WebSocket: send a stable per-tab `instanceId` in websocket connect frames so reconnect cycles keep a consistent client identity for diagnostics and presence tracking. (#23616) Thanks @zq58855371-ui. +- Config/Memory: allow `"mistral"` in `agents.defaults.memorySearch.provider` and `agents.defaults.memorySearch.fallback` schema validation. (#14934) Thanks @ThomsenDrake. +- Feishu/Commands: in group chats, command authorization now falls back to top-level `channels.feishu.allowFrom` when per-group `allowFrom` is not set, so `/command` no longer gets blocked by an unintended empty allowlist. (#23756) +- Dev tooling: prevent `CLAUDE.md` symlink target regressions by excluding CLAUDE symlink sentinels from `oxfmt` and marking them `-text` in `.gitattributes`, so formatter/EOL normalization cannot reintroduce trailing-newline targets. Thanks @vincentkoc. +- Agents/Compaction: restore embedded compaction safeguard/context-pruning extension loading in production by wiring bundled extension factories into the resource loader instead of runtime file-path resolution. (#22349) Thanks @Glucksberg. +- Feishu/Media: for inbound video messages that include both `file_key` (video) and `image_key` (thumbnail), prefer `file_key` when downloading media so video attachments are saved instead of silently failing on thumbnail keys. (#23633) +- Hooks/Loader: avoid redundant hook-module recompilation on gateway restart by skipping cache-busting for bundled hooks and using stable file metadata keys (`mtime+size`) for mutable workspace/managed/plugin hook imports. (#16953) Thanks @mudrii. +- Hooks/Cron: suppress duplicate main-session events for delivered hook turns and mark `SILENT_REPLY_TOKEN` (`NO_REPLY`) early exits as delivered to prevent hook context pollution. (#20678) Thanks @JonathanWorks. +- Providers/OpenRouter: inject `cache_control` on system prompts for OpenRouter Anthropic models to improve prompt-cache reuse. (#17473) Thanks @rrenamed. +- Installer/Smoke tests: remove legacy `OPENCLAW_USE_GUM` overrides from docker install-smoke runs so tests exercise installer auto TTY detection behavior directly. +- Providers/OpenRouter: allow pass-through OpenRouter and Opencode model IDs in live model filtering so custom routed model IDs are treated as modern refs. (#14312) Thanks @Joly0. +- Providers/OpenRouter: default reasoning to enabled when the selected model advertises `reasoning: true` and no session/directive override is set. (#22513) Thanks @zwffff. +- Providers/OpenRouter: map `/think` levels to `reasoning.effort` in embedded runs while preserving explicit `reasoning.max_tokens` payloads. (#17236) Thanks @robbyczgw-cla. +- Providers/OpenRouter: preserve stored session provider when model IDs are vendor-prefixed (for example, `anthropic/...`) so follow-up turns do not incorrectly route to direct provider APIs. (#22753) Thanks @dndodson. +- Providers/OpenRouter: preserve the required `openrouter/` prefix for OpenRouter-native model IDs during model-ref normalization. (#12942) Thanks @omair445. +- Providers/OpenRouter: pass through provider routing parameters from model params.provider to OpenRouter request payloads for provider selection controls. (#17148) Thanks @carrotRakko. +- Providers/OpenRouter: preserve model allowlist entries containing OpenRouter preset paths (for example `openrouter/@preset/...`) by treating `/model ...@profile` auth-profile parsing as a suffix-only override. (#14120) Thanks @NotMainstream. +- Cron/Auth: propagate auth-profile resolution to isolated cron sessions so provider API keys are resolved the same way as main sessions, fixing 401 errors when using providers configured via auth-profiles. (#20689) Thanks @lailoo. +- Cron/Follow-up: pass resolved `agentDir` through isolated cron and queued follow-up embedded runs so auth/profile lookups stay scoped to the correct agent directory. (#22845) Thanks @seilk. +- Agents/Media: route tool-result `MEDIA:` extraction through shared parser validation so malformed prose like `MEDIA:-prefixed ...` is no longer treated as a local file path (prevents Telegram ENOENT tool-error overrides). (#18780) Thanks @HOYALIM. +- Logging: cap single log-file size with `logging.maxFileBytes` (default 500 MB) and suppress additional writes after cap hit to prevent disk exhaustion from repeated error storms. +- Memory/Remote HTTP: centralize remote memory HTTP calls behind a shared guarded helper (`withRemoteHttpResponse`) so embeddings and batch flows use one request/release path. +- Memory/Embeddings: apply configured remote-base host pinning (`allowedHostnames`) across OpenAI/Voyage/Gemini embedding requests to keep private/self-hosted endpoints working without cross-host drift. (#18198) Thanks @ianpcook. +- Memory/Batch: route OpenAI/Voyage/Gemini batch upload/create/status/download requests through the same guarded HTTP path for consistent SSRF policy enforcement. +- Memory/Index: detect memory source-set changes (for example enabling `sessions` after an existing memory-only index) and trigger a full reindex so existing session transcripts are indexed without requiring `--force`. (#17576) Thanks @TarsAI-Agent. +- Memory/Embeddings: enforce a per-input 8k safety cap before embedding batching and apply a conservative 2k fallback limit for local providers without declared input limits, preventing oversized session/memory chunks from triggering provider context-size failures during sync/indexing. (#6016) Thanks @batumilove. +- Memory/QMD: on Windows, resolve bare `qmd`/`mcporter` command names to npm shim executables (`.cmd`) before spawning, so qmd boot updates and mcporter-backed searches no longer fail with `spawn ... ENOENT` on default npm installs. (#23899) Thanks @arcbuilder-ai. +- Memory/QMD: parse plain-text `qmd collection list --json` output when older qmd builds ignore JSON mode, and retry memory searches once after re-ensuring managed collections when qmd returns `Collection not found ...`. (#23613) Thanks @leozhucn. +- Signal/RPC: guard malformed Signal RPC JSON responses with a clear status-scoped error and add regression coverage for invalid JSON responses. (#22995) Thanks @adhitShet. +- Gateway/Subagents: guard gateway and subagent session-key/message trim paths against undefined inputs to prevent early `Cannot read properties of undefined (reading 'trim')` crashes during subagent spawn and wait flows. +- Agents/Workspace: guard `resolveUserPath` against undefined/null input to prevent `Cannot read properties of undefined (reading 'trim')` crashes when workspace paths are missing in embedded runner flows. +- Auth/Profiles: keep active `cooldownUntil`/`disabledUntil` windows immutable across retries so mid-window failures cannot extend recovery indefinitely; only recompute a backoff window after the previous deadline has expired. This resolves cron/inbound retry loops that could trap gateways until manual `usageStats` cleanup. (#23516, #23536) Thanks @arosstale. +- Channels/Security: fail closed on missing provider group policy config by defaulting runtime group policy to `allowlist` (instead of inheriting `channels.defaults.groupPolicy`) when `channels.` is absent across message channels, and align runtime + security warnings/docs to the same fallback behavior (Slack, Discord, iMessage, Telegram, WhatsApp, Signal, LINE, Matrix, Mattermost, Google Chat, IRC, Nextcloud Talk, Feishu, and Zalo user flows; plus Discord message/native-command paths). (#23367) Thanks @bmendonca3. +- Gateway/Onboarding: harden remote gateway onboarding defaults and guidance by defaulting discovered direct URLs to `wss://`, rejecting insecure non-loopback `ws://` targets in onboarding validation, and expanding remote-security remediation messaging across gateway client/call/doctor flows. (#23476) Thanks @bmendonca3. +- CLI/Sessions: pass the configured sessions directory when resolving transcript paths in `agentCommand`, so custom `session.store` locations resume sessions reliably. Thanks @davidrudduck. +- Signal/Monitor: treat user-initiated abort shutdowns as clean exits when auto-started `signal-cli` is terminated, while still surfacing unexpected daemon exits as startup/runtime failures. (#23379) Thanks @frankekn. +- Channels/Dedupe: centralize plugin dedupe primitives in plugin SDK (memory + persistent), move Feishu inbound dedupe to a namespace-scoped persistent store, and reuse shared dedupe cache logic for Zalo webhook replay + Tlon processed-message tracking to reduce duplicate handling during reconnect/replay paths. (#23377) Thanks @SidQin-cyber. +- Channels/Delivery: remove hardcoded WhatsApp delivery fallbacks; require explicit/session channel context or auto-pick the sole configured channel when unambiguous. (#23357) Thanks @lbo728. +- ACP/Gateway: wait for gateway hello before opening ACP requests, and fail fast on pre-hello connect failures to avoid startup hangs and early `gateway not connected` request races. (#23390) Thanks @janckerchen. +- Gateway/Auth: preserve `OPENCLAW_GATEWAY_PASSWORD` env override precedence for remote gateway call credentials after shared resolver refactors, preventing stale configured remote passwords from overriding runtime secret rotation. +- Gateway/Auth: preserve shared-token `gateway token mismatch` auth errors when `auth.token` fallback device-token checks fail, and reserve `device token mismatch` guidance for explicit `auth.deviceToken` failures. +- Gateway/Tools: when agent tools pass an allowlisted `gatewayUrl` override, resolve local override tokens from env/config fallback but keep remote overrides strict to `gateway.remote.token`, preventing local token leakage to remote targets. +- Gateway/Client: keep cached device-auth tokens on `device token mismatch` closes when the client used explicit shared token/password credentials, avoiding accidental pairing-token churn during explicit-auth failures. +- Node host/Exec: keep strict Windows allowlist behavior for `cmd.exe /c` shell-wrapper runs, and return explicit approval guidance when blocked (`SYSTEM_RUN_DENIED: allowlist miss`). +- Control UI: show pairing-required guidance (commands + mobile tokenized URL reminder) when the dashboard disconnects with `1008 pairing required`. +- Security/Audit: add `openclaw security audit` detection for open group policies that expose runtime/filesystem tools without sandbox/workspace guards (`security.exposure.open_groups_with_runtime_or_fs`). +- Security/Audit: make `gateway.real_ip_fallback_enabled` severity conditional for loopback trusted-proxy setups (warn for loopback-only `trustedProxies`, critical when non-loopback proxies are trusted). (#23428) Thanks @bmendonca3. +- Security/Exec env: block request-scoped `HOME` and `ZDOTDIR` overrides in host exec env sanitizers (Node + macOS), preventing shell startup-file execution before allowlist-evaluated command bodies. This ships in the next npm release. Thanks @tdjackey for reporting. +- Security/Exec env: block `SHELLOPTS`/`PS4` in host exec env sanitizers and restrict shell-wrapper (`bash|sh|zsh ... -c/-lc`) request env overrides to a small explicit allowlist (`TERM`, `LANG`, `LC_*`, `COLORTERM`, `NO_COLOR`, `FORCE_COLOR`) on both node host and macOS companion paths, preventing xtrace prompt command-substitution allowlist bypasses. This ships in the next npm release. Thanks @tdjackey for reporting. +- WhatsApp/Security: enforce `allowFrom` for direct-message outbound targets in all send modes (including `mode: "explicit"`), preventing sends to non-allowlisted numbers. (#20108) Thanks @zahlmann. +- Security/Exec approvals: fail closed on shell line continuations (`\\\n`/`\\\r\n`) and treat shell-wrapper execution as approval-required in allowlist mode, preventing `$\\` newline command-substitution bypasses. This ships in the next npm release. Thanks @tdjackey for reporting. +- Security/Gateway: emit a startup security warning when insecure/dangerous config flags are enabled (including `gateway.controlUi.dangerouslyDisableDeviceAuth=true`) and point operators to `openclaw security audit`. +- Security/Hooks auth: normalize hook auth rate-limit client IP keys so IPv4 and IPv4-mapped IPv6 addresses share one throttle bucket, preventing dual-form auth-attempt budget bypasses. This ships in the next npm release. Thanks @aether-ai-agent for reporting. +- Security/Exec approvals: treat `env` and shell-dispatch wrappers as transparent during allowlist analysis on node-host and macOS companion paths so policy checks match the effective executable/inline shell payload instead of the wrapper binary, blocking wrapper-smuggled allowlist bypasses. This ships in the next npm release. Thanks @tdjackey for reporting. +- Security/Exec approvals: require explicit safe-bin profiles for `tools.exec.safeBins` entries in allowlist mode (remove generic safe-bin profile fallback), and add `tools.exec.safeBinProfiles` for safe custom binaries so unprofiled interpreter-style entries cannot be treated as stdin-safe. This ships in the next npm release. Thanks @tdjackey for reporting. +- Security/Channels: harden Slack external menu token handling by switching to CSPRNG tokens, validating token shape, requiring user identity for external option lookups, and avoiding fabricated timestamp `trigger_id` fallbacks; also switch Tlon Urbit channel IDs to CSPRNG UUIDs, centralize secure ID/token generation via shared infra helpers, and add a guardrail test to block new runtime `Date.now()+Math.random()` token/id patterns. +- Security/Hooks transforms: enforce symlink-safe containment for webhook transform module paths (including `hooks.transformsDir` and `hooks.mappings[].transform.module`) by resolving existing-path ancestors via realpath before import, while preserving in-root symlink support; add regression coverage for both escape and allow cases. This ships in the next npm release. Thanks @aether-ai-agent for reporting. +- Telegram/WSL2: disable `autoSelectFamily` by default on WSL2 and memoize WSL2 detection in Telegram network decision logic to avoid repeated sync `/proc/version` probes on fetch/send paths. (#21916) Thanks @MizukiMachine. +- Telegram/Network: default Node 22+ DNS result ordering to `ipv4first` for Telegram fetch paths and add `OPENCLAW_TELEGRAM_DNS_RESULT_ORDER`/`channels.telegram.network.dnsResultOrder` overrides to reduce IPv6-path fetch failures. (#5405) Thanks @Glucksberg. +- Telegram/Forward bursts: coalesce forwarded text+media updates through a dedicated forward lane debounce window that works with default inbound debounce config, while keeping forwarded control commands immediate. (#19476) thanks @napetrov. +- Telegram/Streaming: preserve archived draft preview mapping after flush and clean superseded reasoning preview bubbles so multi-message preview finals no longer cross-edit or orphan stale messages under send/rotation races. (#23202) Thanks @obviyus. +- Telegram/Replies: scope messaging-tool text/media dedupe to same-target sends only, so cross-target tool sends can no longer silently suppress Telegram final replies. +- Telegram/Replies: normalize `file://` and local-path media variants during messaging dedupe so equivalent media paths do not produce duplicate Telegram replies. +- Telegram/Replies: extract forwarded-origin context from unified reply targets (`reply_to_message` and `external_reply`) so forward+comment metadata is preserved across partial reply shapes. (#9720) thanks @mcaxtr. +- Telegram/Polling: persist a safe update-offset watermark bounded by pending updates so crash/restart cannot skip queued lower `update_id` updates after out-of-order completion. (#23284) thanks @frankekn. +- Telegram/Polling: force-restart stuck runner instances when recoverable unhandled network rejections escape the polling task path, so polling resumes instead of silently stalling. (#19721) Thanks @jg-noncelogic. +- Slack/Slash commands: preserve the Bolt app receiver when registering external select options handlers so monitor startup does not crash on runtimes that require bound `app.options` calls. (#23209) Thanks @0xgaia. +- Slack/Telegram slash sessions: await session metadata persistence before dispatch so first-turn native slash runs do not race session-origin metadata updates. (#23065) thanks @hydro13. +- Slack/Queue routing: preserve string `thread_ts` values through collect-mode queue drain and DM `deliveryContext` updates so threaded follow-ups do not leak to the main channel when Slack thread IDs are strings. (#11934) Thanks @sandieman2 and @vincentkoc. +- Telegram/Native commands: set `ctx.Provider="telegram"` for native slash-command context so elevated gate checks resolve provider correctly (fixes `provider (ctx.Provider)` failures in `/elevated` flows). (#23748) Thanks @serhii12. +- Agents/Ollama: preserve unsafe integer tool-call arguments as exact strings during NDJSON parsing, preventing large numeric IDs from being rounded before tool execution. (#23170) Thanks @BestJoester. +- Cron/Gateway: keep `cron.list` and `cron.status` responsive during startup catch-up by avoiding a long-held cron lock while missed jobs execute. (#23106) Thanks @jayleekr. +- Gateway/Config reload: compare array-valued config paths structurally during diffing so unchanged `memory.qmd.paths` and `memory.qmd.scope.rules` no longer trigger false restart-required reloads. (#23185) Thanks @rex05ai. +- Gateway/Config reload: retry short-lived missing config snapshots during reload before skipping, preventing atomic-write unlink windows from triggering restart loops. (#23343) Thanks @lbo728. +- Cron/Scheduling: validate runtime cron expressions before schedule/stagger evaluation so malformed persisted jobs report a clear `invalid cron schedule: expr is required` error instead of crashing with `undefined.trim` failures and auto-disable churn. (#23223) Thanks @asimons81. +- Memory/QMD: migrate legacy unscoped collection bindings (for example `memory-root`) to per-agent scoped names (for example `memory-root-main`) during startup when safe, so QMD-backed `memory_search` no longer fails with `Collection not found` after upgrades. (#23228, #20727) Thanks @JLDynamics and @AaronFaby. +- Memory/QMD: normalize Han-script BM25 search queries before invoking `qmd search` so mixed CJK+Latin prompts no longer return empty results due to tokenizer mismatch. (#23426) Thanks @LunaLee0130. +- TUI/Input: enable multiline-paste burst coalescing on macOS Terminal.app and iTerm so pasted blocks no longer submit line-by-line as separate messages. (#18809) Thanks @fwends. +- TUI/RTL: isolate right-to-left script lines (Arabic/Hebrew ranges) with Unicode bidi isolation marks in TUI text sanitization so RTL assistant output no longer renders in reversed visual order in terminal chat panes. (#21936) Thanks @Asm3r96. +- TUI/Status: request immediate renders after setting `sending`/`waiting` activity states so in-flight runs always show visible progress indicators instead of appearing idle until completion. (#21549) Thanks @13Guinness. +- TUI/Input: arm Ctrl+C exit timing when clearing non-empty composer text and add a SIGINT fallback path so double Ctrl+C exits remain responsive during active runs instead of requiring an extra press or appearing stuck. (#23407) Thanks @tinybluedev. +- Agents/Fallbacks: treat JSON payloads with `type: "api_error"` + `"Internal server error"` as transient failover errors so Anthropic 500-style failures trigger model fallback. (#23193) Thanks @jarvis-lane. +- Agents/Google: sanitize non-base64 `thought_signature`/`thoughtSignature` values from assistant replay transcripts for native Google Gemini requests while preserving valid signatures and tool-call order. (#23457) Thanks @echoVic. +- Agents/Transcripts: validate assistant tool-call names (syntax/length + registered tool allowlist) before transcript persistence and during replay sanitization so malformed failover tool names no longer poison sessions with repeated provider HTTP 400 errors. (#23324) Thanks @johnsantry. +- Agents/Mistral: sanitize tool-call IDs in the embedded agent loop and generate strict provider-safe pending tool-call IDs, preventing Mistral strict9 `HTTP 400` failures on tool continuations. (#23698) Thanks @echoVic. +- Agents/Compaction: strip stale assistant usage snapshots from pre-compaction turns when replaying history after a compaction summary so context-token estimation no longer reuses pre-compaction totals and immediately re-triggers destructive follow-up compactions. (#19127) Thanks @tedwatson. +- Agents/Replies: emit a default completion acknowledgement (`✅ Done.`) only for direct/private tool-only completions with no final assistant text, while suppressing synthetic acknowledgements for channel/group sessions and runs that already delivered output via messaging tools. (#22834) Thanks @Oldshue. +- Agents/Subagents: honor `tools.subagents.tools.alsoAllow` and explicit subagent `allow` entries when resolving built-in subagent deny defaults, so explicitly granted tools (for example `sessions_send`) are no longer blocked unless re-denied in `tools.subagents.tools.deny`. (#23359) Thanks @goren-beehero. +- Agents/Subagents: make announce call timeouts configurable via `agents.defaults.subagents.announceTimeoutMs` and restore a 60s default to prevent false timeout failures on slower announce paths. (#22719) Thanks @Valadon. +- Agents/Diagnostics: include resolved lifecycle error text in `embedded run agent end` warnings so UI/TUI “Connection error” runs expose actionable provider failure reasons in gateway logs. (#23054) Thanks @Raize. +- Agents/Auth profiles: resolve `agentCommand` session scope before choosing `agentDir`/workspace so resumed runs no longer read auth from `agents/main/agent` when the resolved session belongs to a different/default agent (for example `agent:exec:*` sessions). (#24016) Thanks @abersonFAC. +- Agents/Auth profiles: skip auth-profile cooldown writes for timeout failures in embedded runner rotation so model/network timeouts do not poison same-provider fallback model selection while still allowing in-turn account rotation. (#22622) Thanks @vageeshkumar. +- Plugins/Hooks: run legacy `before_agent_start` once per agent turn and reuse that result across model-resolve and prompt-build compatibility paths, preventing duplicate hook side effects (for example duplicate external API calls). (#23289) Thanks @ksato8710. +- Models/Config: default missing Anthropic provider/model `api` fields to `anthropic-messages` during config validation so custom relay model entries are preserved instead of being dropped by runtime model registry validation. (#23332) Thanks @bigbigmonkey123. +- Gateway/Pairing: preserve existing approved token scopes when processing repair pairings that omit `scopes`, preventing empty-scope token regressions on reconnecting clients. (#21906) Thanks @paki81. +- Memory/QMD: add optional `memory.qmd.mcporter` search routing so QMD `query/search/vsearch` can run through mcporter keep-alive flows (including multi-collection paths) to reduce cold starts, while keeping searches on agent-scoped QMD state for consistent recall. (#19617) Thanks @nicole-luxe and @vignesh07. +- Infra/Network: classify undici `TypeError: fetch failed` as transient in unhandled-rejection detection even when nested causes are unclassified, preventing avoidable gateway crash loops on flaky networks. (#14345) Thanks @Unayung. +- Telegram/Retry: classify undici `TypeError: fetch failed` as recoverable in both polling and send retry paths so transient fetch failures no longer fail fast. (#16699) thanks @Glucksberg. +- Docs/Telegram: correct Node 22+ network defaults (`autoSelectFamily`, `dnsResultOrder`) and clarify Telegram setup does not use positional `openclaw channels login telegram`. (#23609) Thanks @ryanbastic. +- BlueBubbles/DM history: restore DM backfill context with account-scoped rolling history, bounded backfill retries, and safer history payload limits. (#20302) Thanks @Ryan-Haines. +- BlueBubbles/Private API cache: treat unknown (`null`) private-API cache status as disabled for send/attachment/reply flows to avoid stale-cache 500s, and log a warning when reply/effect features are requested while capability is unknown. (#23459) Thanks @echoVic. +- BlueBubbles/Webhooks: accept inbound/reaction webhook payloads when BlueBubbles omits `handle` but provides DM `chatGuid`, and harden payload extraction for array/string-wrapped message bodies so valid webhook events no longer get rejected as unparseable. (#23275) Thanks @toph31. +- Security/Audit: add `openclaw security audit` finding `gateway.nodes.allow_commands_dangerous` for risky `gateway.nodes.allowCommands` overrides, with severity upgraded to critical on remote gateway exposure. +- Gateway/Control plane: reduce cross-client write limiter contention by adding `connId` fallback keying when device ID and client IP are both unavailable. +- Security/Config: block prototype-key traversal during config merge patch and legacy migration merge helpers (`__proto__`, `constructor`, `prototype`) to prevent prototype pollution during config mutation flows. (#22968) Thanks @Clawborn. +- Security/Shell env: validate login-shell executable paths for shell-env fallback (`/etc/shells` + trusted prefixes), block `SHELL`/`HOME`/`ZDOTDIR` in config env ingestion before fallback execution, and sanitize fallback shell exec env to pin `HOME` to the real user home while dropping `ZDOTDIR` and other dangerous startup vars. This ships in the next npm release. Thanks @tdjackey for reporting. +- Network/SSRF: enable `autoSelectFamily` on pinned undici dispatchers (with attempt timeout) so IPv6-unreachable environments can quickly fall back to IPv4 for guarded fetch paths. (#19950) Thanks @ENAwareness. +- Security/Config: make parsed chat allowlist checks fail closed when `allowFrom` is empty, restoring expected DM/pairing gating. +- Security/Exec: in non-default setups that manually add `sort` to `tools.exec.safeBins`, block `sort --compress-program` so allowlist-mode safe-bin checks cannot bypass approval. Thanks @tdjackey for reporting. +- Security/Exec approvals: when users choose `allow-always` for shell-wrapper commands (for example `/bin/zsh -lc ...`), persist allowlist patterns for the inner executable(s) instead of the wrapper shell binary, preventing accidental broad shell allowlisting in moderate mode. (#23276) Thanks @xrom2863. +- Security/Exec: fail closed when `tools.exec.host=sandbox` is configured/requested but sandbox runtime is unavailable. (#23398) Thanks @bmendonca3. +- Security/macOS app beta: enforce path-only `system.run` allowlist matching (drop basename matches like `echo`), migrate legacy basename entries to last resolved paths when available, and harden shell-chain handling to fail closed on unsafe parse/control syntax (including quoted command substitution/backticks). This is an optional allowlist-mode feature; default installs remain deny-by-default. This ships in the next npm release. Thanks @tdjackey for reporting. +- Security/Agents: auto-generate and persist a dedicated `commands.ownerDisplaySecret` when `commands.ownerDisplay=hash`, remove gateway token fallback from owner-ID prompt hashing across CLI and embedded agent runners, and centralize owner-display secret resolution in one shared helper. This ships in the next npm release. Thanks @aether-ai-agent for reporting. +- Security/SSRF: expand IPv4 fetch guard blocking to include RFC special-use/non-global ranges (including benchmarking, TEST-NET, multicast, and reserved/broadcast blocks), centralize range checks into a single CIDR policy table, and reuse one shared host/IP classifier across literal + DNS checks to reduce classifier drift. This ships in the next npm release. Thanks @princeeismond-dot for reporting. +- Security/SSRF: block RFC2544 benchmarking range (`198.18.0.0/15`) across direct and embedded-IP paths, and normalize IPv6 dotted-quad transition literals (for example `::127.0.0.1`, `64:ff9b::8.8.8.8`) in shared IP parsing/classification. +- Security/Archive: block zip symlink escapes during archive extraction. +- Security/Media sandbox: keep tmp media allowance for absolute tmp paths only and enforce symlink-escape checks before sandbox-validated reads, preventing tmp symlink exfiltration and relative `../` sandbox escapes when sandboxes live under tmp. (#17892) Thanks @dashed. +- Browser/Upload: accept canonical in-root upload paths when the configured uploads directory is a symlink alias (for example `/tmp` -> `/private/tmp` on macOS), so browser upload validation no longer rejects valid files during client->server revalidation. (#23300, #23222, #22848) Thanks @bgaither4, @parkerati, and @Nabsku. +- Security/Discord: add `openclaw security audit` warnings for name/tag-based Discord allowlist entries (DM allowlists, guild/channel `users`, and pairing-store entries), highlighting slug-collision risk while keeping name-based matching supported, and canonicalize resolved Discord allowlist names to IDs at runtime without rewriting config files. Thanks @tdjackey for reporting. +- Security/Gateway: block node-role connections when device identity metadata is missing. +- Security/Media: enforce inbound media byte limits during download/read across Discord, Telegram, Zalo, Microsoft Teams, and BlueBubbles to prevent oversized payload memory spikes before rejection. This ships in the next npm release. Thanks @tdjackey for reporting. +- Media/Understanding: preserve `application/pdf` MIME classification during text-like file heuristics so PDF uploads use PDF extraction paths instead of being inlined as raw text. (#23191) Thanks @claudeplay2026-byte. +- Security/Control UI: block symlink-based out-of-root static file reads by enforcing realpath containment and file-identity checks when serving Control UI assets and SPA fallback `index.html`. This ships in the next npm release. Thanks @tdjackey for reporting. +- Security/Gateway avatars: block symlink traversal during local avatar `data:` URL resolution by enforcing realpath containment and file-identity checks before reads. This ships in the next npm release. Thanks @tdjackey for reporting. +- Security/Control UI: centralize avatar URL/path validation across gateway/config helpers and enforce a 2 MB max size for local agent avatar files before `/avatar` resolution, reducing oversized-avatar memory risk without changing supported avatar formats. +- Security/Control UI avatars: harden `/avatar/:agentId` local avatar serving by rejecting symlink paths and requiring fd-level file identity + size checks before reads. This ships in the next npm release. Thanks @tdjackey for reporting. +- Security/MSTeams media: enforce allowlist checks for SharePoint reference attachment URLs and redirect targets during Graph-backed media fetches so redirect chains cannot escape configured media host boundaries. This ships in the next npm release. Thanks @tdjackey for reporting. +- Security/MSTeams media: route attachment auth-retry and Graph SharePoint download redirects through shared `safeFetch` so each hop is validated with allowlist + DNS/IP checks across the full redirect chain. (#23598) Thanks @Asm3r96 and @lewiswigmore. +- Security/macOS discovery: fail closed for unresolved discovery endpoints by clearing stale remote selection values, use resolved service host only for SSH target derivation, and keep remote URL config aligned with resolved endpoint availability. (#21618) Thanks @bmendonca3. +- Chat/Usage/TUI: strip synthetic inbound metadata blocks (including `Conversation info` and trailing `Untrusted context` channel metadata wrappers) from displayed conversation history so internal prompt context no longer leaks into user-visible logs. +- CI/Tests: fix TypeScript case-table typing and lint assertion regressions so `pnpm check` passes again after Synology Chat landing. (#23012) Thanks @druide67. +- Security/Browser relay: harden extension relay auth token handling for `/extension` and `/cdp` pathways. +- Cron: persist `delivered` state in cron job records so delivery failures remain visible in status and logs. (#19174) Thanks @simonemacario. +- Config/Doctor: only repair the OAuth credentials directory when affected channels are configured, avoiding fresh-install noise. +- Config/Channels: whitelist `channels.modelByChannel` in config validation and exclude it from plugin auto-enable channel detection so model overrides no longer trigger `unknown channel id` validation errors or bogus `modelByChannel` plugin enables. (#23412) Thanks @ProspectOre. +- Config/Bindings: allow optional `bindings[].comment` in strict config validation so annotated binding entries no longer fail load. (#23458) Thanks @echoVic. +- Usage/Pricing: correct MiniMax M2.5 pricing defaults to fix inflated cost reporting. (#22755) Thanks @miloudbelarebia. +- Gateway/Daemon: verify gateway health after daemon restart. +- Agents/UI text: stop rewriting normal assistant billing/payment language outside explicit error contexts. (#17834) Thanks @niceysam. + +## 2026.2.21 + +### Changes + +- Models/Google: add Gemini 3.1 support (`google/gemini-3.1-pro-preview`). +- Providers/Onboarding: add Volcano Engine (Doubao) and BytePlus providers/models (including coding variants), wire onboarding auth choices for interactive + non-interactive flows, and align docs to `volcengine-api-key`. (#7967) Thanks @funmore123. +- Channels/CLI: add per-account/channel `defaultTo` outbound routing fallback so `openclaw agent --deliver` can send without explicit `--reply-to` when a default target is configured. (#16985) Thanks @KirillShchetinin. +- Channels: allow per-channel model overrides via `channels.modelByChannel` and note them in /status. Thanks @thewilloftheshadow. +- Telegram/Streaming: simplify preview streaming config to `channels.telegram.streaming` (boolean), auto-map legacy `streamMode` values, and remove block-vs-partial preview branching. (#22012) thanks @obviyus. +- Discord/Streaming: add stream preview mode for live draft replies with partial/block options and configurable chunking. Thanks @thewilloftheshadow. Inspiration @neoagentic-ship-it. +- Discord/Telegram: add configurable lifecycle status reactions for queued/thinking/tool/done/error phases with a shared controller and emoji/timing overrides. Thanks @wolly-tundracube and @thewilloftheshadow. +- Discord/Voice: add voice channel join/leave/status via `/vc`, plus auto-join configuration for realtime voice conversations. Thanks @thewilloftheshadow. +- Discord: add configurable ephemeral defaults for slash-command responses. (#16563) Thanks @wei. +- Discord: support updating forum `available_tags` via channel edit actions for forum tag management. (#12070) Thanks @xiaoyaner0201. +- Discord: include channel topics in trusted inbound metadata on new sessions. Thanks @thewilloftheshadow. +- Discord/Subagents: add thread-bound subagent sessions on Discord with per-thread focus/list controls and thread-bound continuation routing for spawned helper agents. (#21805) Thanks @onutc. +- iOS/Chat: clean chat UI noise by stripping inbound untrusted metadata/timestamp prefixes, formatting tool outputs into concise summaries/errors, compacting the composer while typing, and supporting tap-to-dismiss keyboard in chat view. (#22122) thanks @mbelinky. +- iOS/Watch: bridge mirrored watch prompt notification actions into iOS quick-reply handling, including queued action handoff until app model initialization. (#22123) thanks @mbelinky. +- iOS/Gateway: stabilize background wake and reconnect behavior with background reconnect suppression/lease windows, BGAppRefresh wake fallback, location wake hook throttling, and APNs wake retry+nudge instrumentation. (#21226) thanks @mbelinky. +- Auto-reply/UI: add model fallback lifecycle visibility in verbose logs, /status active-model context with fallback reason, and cohesive WebUI fallback indicators. (#20704) Thanks @joshavant. +- MSTeams: dedupe sent-message cache storage by removing duplicate per-message Set storage and using timestamps Map keys as the single membership source. (#22514) Thanks @TaKO8Ki. +- Agents/Subagents: default subagent spawn depth now uses shared `maxSpawnDepth=2`, enabling depth-1 orchestrator spawning by default while keeping depth policy checks consistent across spawn and prompt paths. (#22223) Thanks @tyler6204. +- Security/Agents: make owner-ID obfuscation use a dedicated HMAC secret from configuration (`ownerDisplaySecret`) and update hashing behavior so obfuscation is decoupled from gateway token handling for improved control. (#7343) Thanks @vincentkoc. +- Security/Infra: switch gateway lock and tool-call synthetic IDs from SHA-1 to SHA-256 with unchanged truncation length to strengthen hash basis while keeping deterministic behavior and lock key format. (#7343) Thanks @vincentkoc. +- Dependencies/Tooling: add non-blocking dead-code scans in CI via Knip/ts-prune/ts-unused-exports to surface unused dependencies and exports earlier. (#22468) Thanks @vincentkoc. +- Dependencies/Unused Dependencies: remove or scope unused root and extension deps (`@larksuiteoapi/node-sdk`, `signal-utils`, `ollama`, `lit`, `@lit/context`, `@lit-labs/signals`, `@microsoft/agents-hosting-express`, `@microsoft/agents-hosting-extensions-teams`, and plugin-local `openclaw` devDeps in `extensions/open-prose`, `extensions/lobster`, and `extensions/llm-task`). (#22471, #22495) Thanks @vincentkoc. +- Dependencies/A2UI: harden dependency resolution after root cleanup (resolve `lit`, `@lit/context`, `@lit-labs/signals`, and `signal-utils` from workspace/root) and simplify bundling fallback behavior, including `pnpm dlx rolldown` compatibility. (#22481, #22507) Thanks @vincentkoc. + +### Fixes + +- Agents/Bootstrap: skip malformed bootstrap files with missing/invalid paths instead of crashing agent sessions; hooks using `filePath` (or non-string `path`) are skipped with a warning. (#22693, #22698) Thanks @arosstale. +- Security/Agents: cap embedded Pi runner outer retry loop with a higher profile-aware dynamic limit (32-160 attempts) and return an explicit `retry_limit` error payload when retries never converge, preventing unbounded internal retry cycles (`GHSA-76m6-pj3w-v7mf`). +- Telegram: detect duplicate bot-token ownership across Telegram accounts at startup/status time, mark secondary accounts as not configured with an explicit fix message, and block duplicate account startup before polling to avoid endless `getUpdates` conflict loops. +- Agents/Tool images: include source filenames in `agents/tool-images` resize logs so compression events can be traced back to specific files. +- Providers/OAuth: harden Qwen and Chutes refresh handling by validating refresh response expiry values and preserving prior refresh tokens when providers return empty refresh token fields, with regression coverage for empty-token responses. +- Models/Kimi-Coding: add missing implicit provider template for `kimi-coding` with correct `anthropic-messages` API type and base URL, fixing 403 errors when using Kimi for Coding. (#22409) +- Auto-reply/Tools: forward `senderIsOwner` through embedded queued/followup runner params so owner-only tools remain available for authorized senders. (#22296) thanks @hcoj. +- Discord: restore model picker back navigation when a provider is missing and document the Discord picker flow. (#21458) Thanks @pejmanjohn and @thewilloftheshadow. +- Memory/QMD: respect per-agent `memorySearch.enabled=false` during gateway QMD startup initialization, split multi-collection QMD searches into per-collection queries (`search`/`vsearch`/`query`) to avoid sparse-term drops, prefer collection-hinted doc resolution to avoid stale-hash collisions, retry boot updates on transient lock/timeout failures, skip `qmd embed` in BM25-only `search` mode (including `memory index --force`), and serialize embed runs globally with failure backoff to prevent CPU storms on multi-agent hosts. (#20581, #21590, #20513, #20001, #21266, #21583, #20346, #19493) Thanks @danielrevivo, @zanderkrause, @sunyan034-cmd, @tilleulenspiegel, @dae-oss, @adamlongcreativellc, @jonathanadams96, and @kiliansitel. +- Memory/Builtin: prevent automatic sync races with manager shutdown by skipping post-close sync starts and waiting for in-flight sync before closing SQLite, so `onSearch`/`onSessionStart` no longer fail with `database is not open` in ephemeral CLI flows. (#20556, #7464) Thanks @FuzzyTG and @henrybottter. +- Providers/Copilot: drop persisted assistant `thinking` blocks for Claude models (while preserving turn structure/tool blocks) so follow-up requests no longer fail on invalid `thinkingSignature` payloads. (#19459) Thanks @jackheuberger. +- Providers/Copilot: add `claude-sonnet-4.6` and `claude-sonnet-4.5` to the default GitHub Copilot model catalog and add coverage for model-list/definition helpers. (#20270, fixes #20091) Thanks @Clawborn. +- Auto-reply/WebChat: avoid defaulting inbound runtime channel labels to unrelated providers (for example `whatsapp`) for webchat sessions so channel-specific formatting guidance stays accurate. (#21534) Thanks @lbo728. +- Status: include persisted `cacheRead`/`cacheWrite` in session summaries so compact `/status` output consistently shows cache hit percentages from real session data. +- Sessions/Usage: persist `totalTokens` from `promptTokens` snapshots even when providers omit structured usage payloads, so session history/status no longer regress to `unknown` token utilization for otherwise successful runs. (#21819) Thanks @zymclaw. +- Heartbeat/Cron: restore interval heartbeat behavior so missing `HEARTBEAT.md` no longer suppresses runs (only effectively empty files skip), preserving prompt-driven and tagged-cron execution paths. +- WhatsApp/Cron/Heartbeat: enforce allowlisted routing for implicit scheduled/system delivery by merging pairing-store + configured `allowFrom` recipients, selecting authorized recipients when last-route context points to a non-allowlisted chat, and preventing heartbeat fan-out to recent unauthorized chats. +- Heartbeat/Active hours: constrain active-hours `24` sentinel parsing to `24:00` in time validation so invalid values like `24:30` are rejected early. (#21410) thanks @adhitShet. +- Heartbeat: treat `activeHours` windows with identical `start`/`end` times as zero-width (always outside the window) instead of always-active. (#21408) thanks @adhitShet. +- CLI/Pairing: default `pairing list` and `pairing approve` to the sole available pairing channel when omitted, so TUI-only setups can recover from `pairing required` without guessing channel arguments. (#21527) Thanks @losts1. +- TUI/Pairing: show explicit pairing-required recovery guidance after gateway disconnects that return `pairing required`, including approval steps to unblock quickstart TUI hatching on fresh installs. (#21841) Thanks @nicolinux. +- TUI/Input: suppress duplicate backspace events arriving in the same input burst window so SSH sessions no longer delete two characters per backspace press in the composer. (#19318) Thanks @eheimer. +- TUI/Models: scope `models.list` to the configured model allowlist (`agents.defaults.models`) so `/model` picker no longer floods with unrelated catalog entries by default. (#18816) Thanks @fwends. +- TUI/Heartbeat: suppress heartbeat ACK/prompt noise in chat streaming when `showOk` is disabled, while still preserving non-ACK heartbeat alerts in final output. (#20228) Thanks @bhalliburton. +- TUI/History: cap chat-log component growth and prune stale render nodes/references so large default history loads no longer overflow render recursion with `RangeError: Maximum call stack size exceeded`. (#18068) Thanks @JaniJegoroff. +- Memory/QMD: diversify mixed-source search ranking when both session and memory collections are present so session transcript hits no longer crowd out durable memory-file matches in top results. (#19913) Thanks @alextempr. +- Memory/Tools: return explicit `unavailable` warnings/actions from `memory_search` when embedding/provider failures occur (including quota exhaustion), so disabled memory does not look like an empty recall result. (#21894) Thanks @XBS9. +- Session/Startup: require the `/new` and `/reset` greeting path to run Session Startup file-reading instructions before responding, so daily memory startup context is not skipped on fresh-session greetings. (#22338) Thanks @armstrong-pv. +- Auth/Onboarding: align OAuth profile-id config mapping with stored credential IDs for OpenAI Codex and Chutes flows, preventing `provider:default` mismatches when OAuth returns email-scoped credentials. (#12692) thanks @mudrii. +- Provider/HTTP: treat HTTP 503 as failover-eligible for LLM provider errors. (#21086) Thanks @Protocol-zero-0. +- Slack: pass `recipient_team_id` / `recipient_user_id` through Slack native streaming calls so `chat.startStream`/`appendStream`/`stopStream` work reliably across DMs and Slack Connect setups, and disable block streaming when native streaming is active. (#20988) Thanks @Dithilli. Earlier recipient-ID groundwork was contributed in #20377 by @AsserAl1012. +- CLI/Config: add canonical `--strict-json` parsing for `config set` and keep `--json` as a legacy alias to reduce help/behavior drift. (#21332) thanks @adhitShet. +- CLI/Config: preserve explicitly unset config paths in persisted JSON after writes so `openclaw config unset ` no longer re-introduces defaulted keys (for example `commands.ownerDisplay`) through schema normalization. (#22984) Thanks @aronchick. +- CLI: keep `openclaw -v` as a root-only version alias so subcommand `-v, --verbose` flags (for example ACP/hooks/skills) are no longer intercepted globally. (#21303) thanks @adhitShet. +- Memory: return empty snippets when `memory_get`/QMD read files that have not been created yet, and harden memory indexing/session helpers against ENOENT races so missing Markdown no longer crashes tools. (#20680) Thanks @pahdo. +- Telegram/Streaming: always clean up draft previews even when dispatch throws before fallback handling, preventing orphaned preview messages during failed runs. (#19041) thanks @mudrii. +- Telegram/Streaming: split reasoning and answer draft preview lanes to prevent cross-lane overwrites, and ignore literal `` tags inside inline/fenced code snippets so sample markup is not misrouted as reasoning. (#20774) Thanks @obviyus. +- Telegram/Streaming: restore 30-char first-preview debounce and scope `NO_REPLY` prefix suppression to partial sentinel fragments so normal `No...` text is not filtered. (#22613) thanks @obviyus. +- Telegram/Status reactions: refresh stall timers on repeated phase updates and honor ack-reaction scope when lifecycle reactions are enabled, preventing false stall emojis and unwanted group reactions. Thanks @wolly-tundracube and @thewilloftheshadow. +- Telegram/Status reactions: keep lifecycle reactions active when available-reactions lookup fails by falling back to unrestricted variant selection instead of suppressing reaction updates. (#22380) thanks @obviyus. +- Discord/Events: await `DiscordMessageListener` message handlers so regular `MESSAGE_CREATE` traffic is processed through queue ordering/timeout flow instead of fire-and-forget drops. (#22396) Thanks @sIlENtbuffER. +- Discord/Streaming: apply `replyToMode: first` only to the first Discord chunk so block-streamed replies do not spam mention pings. (#20726) Thanks @thewilloftheshadow for the report. +- Discord/Components: map DM channel targets back to user-scoped component sessions so button/select interactions stay in the main DM session. Thanks @thewilloftheshadow. +- Discord/Allowlist: lazy-load guild lists when resolving Discord user allowlists so ID-only entries resolve even if guild fetch fails. (#20208) Thanks @zhangjunmengyang. +- Discord/Gateway: handle close code 4014 (missing privileged gateway intents) without crashing the gateway. Thanks @thewilloftheshadow. +- Discord: ingest inbound stickers as media so sticker-only messages and forwarded stickers are visible to agents. Thanks @thewilloftheshadow. +- Auto-reply/Runner: emit `onAgentRunStart` only after agent lifecycle or tool activity begins (and only once per run), so fallback preflight errors no longer mark runs as started. (#21165) Thanks @shakkernerd. +- Auto-reply/Tool results: serialize tool-result delivery and keep the delivery chain progressing after individual failures so concurrent tool outputs preserve user-visible ordering. (#21231) thanks @ahdernasr. +- Auto-reply/Prompt caching: restore prefix-cache stability by keeping inbound system metadata session-stable and moving per-message IDs (`message_id`, `message_id_full`, `reply_to_id`, `sender_id`) into untrusted conversation context. (#20597) Thanks @anisoptera. +- iOS/Watch: add actionable watch approval/reject controls and quick-reply actions so watch-originated approvals and responses can be sent directly from notification flows. (#21996) Thanks @mbelinky. +- iOS/Watch: refresh iOS and watch app icon assets with the lobster icon set to keep phone/watch branding aligned. (#21997) Thanks @mbelinky. +- CLI/Onboarding: fix Anthropic-compatible custom provider verification by normalizing base URLs to avoid duplicate `/v1` paths during setup checks. (#21336) Thanks @17jmumford. +- iOS/Gateway/Tools: prefer uniquely connected node matches when duplicate display names exist, surface actionable `nodes invoke` pairing-required guidance with request IDs, and refresh active iOS gateway registration after location-capability setting changes so capability updates apply immediately. (#22120) thanks @mbelinky. +- Gateway/Auth: require `gateway.trustedProxies` to include a loopback proxy address when `auth.mode="trusted-proxy"` and `bind="loopback"`, preventing same-host proxy misconfiguration from silently blocking auth. (#22082, follow-up to #20097) thanks @mbelinky. +- Gateway/Auth: allow trusted-proxy mode with loopback bind for same-host reverse-proxy deployments, while still requiring configured `gateway.trustedProxies`. (#20097) thanks @xinhuagu. +- Gateway/Auth: allow authenticated clients across roles/scopes to call `health` while preserving role and scope enforcement for non-health methods. (#19699) thanks @Nachx639. +- Gateway/Hooks: include transform export name in hook-transform cache keys so distinct exports from the same module do not reuse the wrong cached transform function. (#13855) thanks @mcaxtr. +- Gateway/Control UI: return 404 for missing static-asset paths instead of serving SPA fallback HTML, while preserving client-route fallback behavior for extensionless and non-asset dotted paths. (#12060) thanks @mcaxtr. +- Gateway/Pairing: prevent device-token rotate scope escalation by enforcing an approved-scope baseline, preserving approved scopes across metadata updates, and rejecting rotate requests that exceed approved role scope implications. (#20703) thanks @coygeek. +- Gateway/Pairing: clear persisted paired-device state when the gateway client closes with `device token mismatch` (`1008`) so reconnect flows can cleanly re-enter pairing. (#22071) Thanks @mbelinky. +- Gateway/Config: allow `gateway.customBindHost` in strict config validation when `gateway.bind="custom"` so valid custom bind-host configurations no longer fail startup. (#20318, fixes #20289) Thanks @MisterGuy420. +- Gateway/Pairing: tolerate legacy paired devices missing `roles`/`scopes` metadata in websocket upgrade checks and backfill metadata on reconnect. (#21447, fixes #21236) Thanks @joshavant. +- Gateway/Pairing/CLI: align read-scope compatibility in pairing/device-token checks and add local `openclaw devices` fallback recovery for loopback `pairing required` deadlocks, with explicit fallback notice to unblock approval bootstrap flows. (#21616) Thanks @shakkernerd. +- Cron: honor `cron.maxConcurrentRuns` in the timer loop so due jobs can execute up to the configured parallelism instead of always running serially. (#11595) Thanks @Takhoffman. +- Agents/Compaction: restore embedded compaction safeguard/context-pruning extension loading in production by wiring bundled extension factories into the resource loader instead of runtime file-path resolution. (#22349) Thanks @Glucksberg. +- Agents/Subagents: restore announce-chain delivery to agent injection, defer nested announce output until descendant follow-up content is ready, and prevent descendant deferrals from consuming announce retry budget so deep chains do not drop final completions. (#22223) Thanks @tyler6204. +- Agents/System Prompt: label allowlisted senders as authorized senders to avoid implying ownership. Thanks @thewilloftheshadow. +- Agents/Tool display: fix exec cwd suffix inference so `pushd ... && popd ... && ` does not keep stale `(in )` context in summaries. (#21925) Thanks @Lukavyi. +- Agents/Google: flatten residual nested `anyOf`/`oneOf` unions in Gemini tool-schema cleanup so Cloud Code Assist no longer rejects unsupported union keywords that survive earlier simplification. (#22825) Thanks @Oceanswave. +- Tools/web_search: handle xAI Responses API payloads that emit top-level `output_text` blocks (without a `message` wrapper) so Grok web_search no longer returns `No response` for those results. (#20508) Thanks @echoVic. +- Agents/Failover: treat non-default override runs as direct fallback-to-configured-primary (skip configured fallback chain), normalize default-model detection for provider casing/whitespace, and add regression coverage for override/auth error paths. (#18820) Thanks @Glucksberg. +- Docker/Build: include `ownerDisplay` in `CommandsSchema` object-level defaults so Docker `pnpm build` no longer fails with `TS2769` during plugin SDK d.ts generation. (#22558) Thanks @obviyus. +- Docker/Browser: install Playwright Chromium into `/home/node/.cache/ms-playwright` and set `node:node` ownership so browser binaries are available to the runtime user in browser-enabled images. (#22585) thanks @obviyus. +- Hooks/Session memory: trigger bundled `session-memory` persistence on both `/new` and `/reset` so reset flows no longer skip markdown transcript capture before archival. (#21382) Thanks @mofesolapaul. +- Dependencies/Agents: bump embedded Pi SDK packages (`@mariozechner/pi-agent-core`, `@mariozechner/pi-ai`, `@mariozechner/pi-coding-agent`, `@mariozechner/pi-tui`) to `0.54.0`. (#21578) Thanks @Takhoffman. +- Config/Agents: expose Pi compaction tuning values `agents.defaults.compaction.reserveTokens` and `agents.defaults.compaction.keepRecentTokens` in config schema/types and apply them in embedded Pi runner settings overrides with floor enforcement via `reserveTokensFloor`. (#21568) Thanks @Takhoffman. +- Docker: pin base images to SHA256 digests in Docker builds to prevent mutable tag drift. (#7734) Thanks @coygeek. +- Docker: run build steps as the `node` user and use `COPY --chown` to avoid recursive ownership changes, trimming image size and layer churn. Thanks @huntharo. +- Config/Memory: restore schema help/label metadata for hybrid `mmr` and `temporalDecay` settings so configuration surfaces show correct names and guidance. (#18786) Thanks @rodrigouroz. +- Skills/SonosCLI: add troubleshooting guidance for `sonos discover` failures on macOS direct mode (`sendto: no route to host`) and sandbox network restrictions (`bind: operation not permitted`). (#21316) Thanks @huntharo. +- macOS/Build: default release packaging to `BUNDLE_ID=ai.openclaw.mac` in `scripts/package-mac-dist.sh`, so Sparkle feed URL is retained and auto-update no longer fails with an empty appcast feed. (#19750) thanks @loganprit. +- Signal/Outbound: preserve case for Base64 group IDs during outbound target normalization so cross-context routing and policy checks no longer break when group IDs include uppercase characters. (#5578) Thanks @heyhudson. +- Anthropic/Agents: preserve required pi-ai default OAuth beta headers when `context1m` injects `anthropic-beta`, preventing 401 auth failures for `sk-ant-oat-*` tokens. (#19789, fixes #19769) Thanks @minupla. +- Security/Exec: block unquoted heredoc body expansion tokens in shell allowlist analysis, reject unterminated heredocs, and require explicit approval for allowlisted heredoc execution on gateway hosts to prevent heredoc substitution allowlist bypass. Thanks @torturado for reporting. +- macOS/Security: evaluate `system.run` allowlists per shell segment in macOS node runtime and companion exec host (including chained shell operators), fail closed on shell/process substitution parsing, and require explicit approval on unsafe parse cases to prevent allowlist bypass via `rawCommand` chaining. Thanks @tdjackey for reporting. +- WhatsApp/Security: enforce allowlist JID authorization for reaction actions so authenticated callers cannot target non-allowlisted chats by forging `chatJid` + valid `messageId` pairs. Thanks @aether-ai-agent for reporting. +- ACP/Security: escape control and delimiter characters in ACP `resource_link` title/URI metadata before prompt interpolation to prevent metadata-driven prompt injection through resource links. Thanks @aether-ai-agent for reporting. +- TTS/Security: make model-driven provider switching opt-in by default (`messages.tts.modelOverrides.allowProvider=false` unless explicitly enabled), while keeping voice/style overrides available, to reduce prompt-injection-driven provider hops and unexpected TTS cost escalation. Thanks @aether-ai-agent for reporting. +- Security/Agents: keep overflow compaction retry budgeting global across tool-result truncation recovery so successful truncation cannot reset the overflow retry counter and amplify retry/cost cycles. Thanks @aether-ai-agent for reporting. +- BlueBubbles/Security: require webhook token authentication for all BlueBubbles webhook requests (including loopback/proxied setups), removing passwordless webhook fallback behavior. Thanks @zpbrent. +- iOS/Security: force `https://` for non-loopback manual gateway hosts during iOS onboarding to block insecure remote transport URLs. (#21969) Thanks @mbelinky. +- Gateway/Security: remove shared-IP fallback for canvas endpoints and require token or session capability for canvas access. Thanks @thewilloftheshadow. +- Gateway/Security: require secure context and paired-device checks for Control UI auth even when `gateway.controlUi.allowInsecureAuth` is set, and align audit messaging with the hardened behavior. (#20684) Thanks @coygeek and @Vasco0x4 for reporting. +- Gateway/Security: scope tokenless Tailscale forwarded-header auth to Control UI websocket auth only, so HTTP gateway routes still require token/password even on trusted hosts. Thanks @zpbrent for reporting. +- Docker/Security: run E2E and install-sh test images as non-root by adding appuser directives. Thanks @thewilloftheshadow. +- Skills/Security: sanitize skill env overrides to block unsafe runtime injection variables and only allow sensitive keys when declared in skill metadata, with warnings for suspicious values. Thanks @thewilloftheshadow. +- Security/Commands: block prototype-key injection in runtime `/debug` overrides and require own-property checks for gated command flags (`bash`, `config`, `debug`) so inherited prototype values cannot enable privileged commands. Thanks @tdjackey for reporting. +- Security/Browser: block non-network browser navigation protocols (including `file:`, `data:`, and `javascript:`) while preserving `about:blank`, preventing local file reads via browser tool navigation. Thanks @q1uf3ng for reporting. +- Security/Exec: block shell startup-file env injection (`BASH_ENV`, `ENV`, `BASH_FUNC_*`, `LD_*`, `DYLD_*`) across config env ingestion, node-host inherited environment sanitization, and macOS exec host runtime to prevent pre-command execution from attacker-controlled environment variables. Thanks @tdjackey. +- Security/Exec (Windows): canonicalize `cmd.exe /c` command text across validation, approval binding, and audit/event rendering to prevent trailing-argument approval mismatches in `system.run`. Thanks @tdjackey for reporting. +- Security/Gateway/Hooks: block `__proto__`, `constructor`, and `prototype` traversal in webhook template path resolution to prevent prototype-chain payload data leakage in `messageTemplate` rendering. (#22213) Thanks @SleuthCo. +- Security/OpenClawKit/UI: prevent injected inbound user context metadata blocks from leaking into chat history in TUI, webchat, and macOS surfaces by stripping all untrusted metadata prefixes at display boundaries. (#22142) Thanks @Mellowambience, @vincentkoc. +- Security/OpenClawKit/UI: strip inbound metadata blocks from user messages in TUI rendering while preserving user-authored content. (#22345) Thanks @kansodata, @vincentkoc. +- Security/OpenClawKit/UI: prevent inbound metadata leaks and reply-tag streaming artifacts in TUI rendering by stripping untrusted metadata prefixes at display boundaries. (#22346) Thanks @akramcodez, @vincentkoc. +- Security/Agents: restrict local MEDIA tool attachments to core tools and the OpenClaw temp root to prevent untrusted MCP tool file exfiltration. Thanks @NucleiAv and @thewilloftheshadow. +- Security/Net: strip sensitive headers (`Authorization`, `Proxy-Authorization`, `Cookie`, `Cookie2`) on cross-origin redirects in `fetchWithSsrFGuard` to prevent credential forwarding across origin boundaries. (#20313) Thanks @afurm. +- Security/Systemd: reject CR/LF in systemd unit environment values and fix argument escaping so generated units cannot be injected with extra directives. Thanks @thewilloftheshadow. +- Security/Tools: add per-wrapper random IDs to untrusted-content markers from `wrapExternalContent`/`wrapWebContent`, preventing marker spoofing from escaping content boundaries. (#19009) Thanks @Whoaa512. +- Shared/Security: reject insecure deep links that use `ws://` non-loopback gateway URLs to prevent plaintext remote websocket configuration. (#21970) Thanks @mbelinky. +- macOS/Security: reject non-loopback `ws://` remote gateway URLs in macOS remote config to block insecure plaintext websocket endpoints. (#21971) Thanks @mbelinky. +- Browser/Security: block upload path symlink escapes so browser upload sources cannot traverse outside the allowed workspace via symlinked paths. (#21972) Thanks @mbelinky. +- Security/Dependencies: bump transitive `hono` usage to `4.11.10` to incorporate timing-safe authentication comparison hardening for `basicAuth`/`bearerAuth` (`GHSA-gq3j-xvxp-8hrf`). Thanks @vincentkoc. +- Security/Gateway: parse `X-Forwarded-For` with trust-preserving semantics when requests come from configured trusted proxies, preventing proxy-chain spoofing from influencing client IP classification and rate-limit identity. Thanks @AnthonyDiSanti and @vincentkoc. +- Security/Sandbox: remove default `--no-sandbox` for the browser container entrypoint, add explicit opt-in via `OPENCLAW_BROWSER_NO_SANDBOX` / `CLAWDBOT_BROWSER_NO_SANDBOX`, and add security-audit checks for stale/missing sandbox browser Docker hash labels. Thanks @TerminalsandCoffee and @vincentkoc. +- Security/Sandbox Browser: require VNC password auth for noVNC observer sessions in the sandbox browser entrypoint, plumb per-container noVNC passwords from runtime, and emit short-lived noVNC observer token URLs while keeping loopback-only host port publishing. Thanks @TerminalsandCoffee for reporting. +- Security/Sandbox Browser: default browser sandbox containers to a dedicated Docker network (`openclaw-sandbox-browser`), add optional CDP ingress source-range restrictions, auto-create missing dedicated networks, and warn in `openclaw security --audit` when browser sandboxing runs on bridge without source-range limits. Thanks @TerminalsandCoffee for reporting. + +## 2026.2.19 + +### Changes + +- iOS/Watch: add an Apple Watch companion MVP with watch inbox UI, watch notification relay handling, and gateway command surfaces for watch status/send flows. (#20054) Thanks @mbelinky. +- iOS/Gateway: wake disconnected iOS nodes via APNs before `nodes.invoke` and auto-reconnect gateway sessions on silent push wake to reduce invoke failures while the app is backgrounded. (#20332) Thanks @mbelinky. +- Gateway/CLI: add paired-device hygiene flows with `device.pair.remove`, plus `openclaw devices remove` and guarded `openclaw devices clear --yes [--pending]` commands for removing paired entries and optionally rejecting pending requests. (#20057) Thanks @mbelinky. +- iOS/APNs: add push registration and notification-signing configuration for node delivery. (#20308) Thanks @mbelinky. +- Gateway/APNs: add a push-test pipeline for APNs delivery validation in gateway flows. (#20307) Thanks @mbelinky. +- Security/Audit: add `gateway.http.no_auth` findings when `gateway.auth.mode="none"` leaves Gateway HTTP APIs reachable, with loopback warning and remote-exposure critical severity, plus regression coverage and docs updates. +- Skills: harden coding-agent skill guidance by removing shell-command examples that interpolate untrusted issue text directly into command strings. +- Dev tooling: align `oxfmt` local/CI formatting behavior. (#12579) Thanks @vincentkoc. + +### Fixes + +- Security: strip hidden text from `web_fetch` extracted content to prevent indirect prompt injection, covering CSS-hidden elements, class-based hiding (sr-only, d-none, etc.), invisible Unicode, color:transparent, offscreen transforms, and non-content tags. (#8027, #21074) Thanks @hydro13 for the fix and @LucasAIBuilder for reporting. +- Agents/Streaming: keep assistant partial streaming active during reasoning streams, handle native `thinking_*` stream events consistently, dedupe mixed reasoning-end signals, and clear stale mutating tool errors after same-target retry success. (#20635) Thanks @obviyus. +- iOS/Chat: use a dedicated iOS chat session key for ChatSheet routing to avoid cross-client session collisions with main-session traffic. (#21139) thanks @mbelinky. +- iOS/Chat: auto-resync chat history after reconnect sequence gaps, clear stale pending runs, and avoid dead-end manual refresh errors after transient disconnects. (#21135) thanks @mbelinky. +- UI/Usage: reload usage data immediately when timezone changes so Local/UTC toggles apply the selected date range without requiring a manual refresh. (#17774) +- iOS/Screen: move `WKWebView` lifecycle ownership into `ScreenWebView` coordinator and explicit attach/detach flow to reduce gesture/lifecycle crash risk (`__NSArrayM insertObject:atIndex:` paths) during screen tab updates. (#20366) Thanks @ngutman. +- iOS/Onboarding: prevent pairing-status flicker during auto-resume by keeping resumed state transitions stable. (#20310) Thanks @mbelinky. +- iOS/Onboarding: stabilize pairing and reconnect behavior by resetting stale pairing request state on manual retry, disconnecting both operator and node gateways on operator failure, and avoiding duplicate pairing loops from operator transport identity attachment. (#20056) Thanks @mbelinky. +- iOS/Signing: restore local auto-selected signing-team overrides during iOS project generation by wiring `.local-signing.xcconfig` into the active signing config and emitting `OPENCLAW_DEVELOPMENT_TEAM` in local signing setup. (#19993) Thanks @ngutman. +- Telegram: unify message-like inbound handling so `message` and `channel_post` share the same dedupe/access/media pipeline and remain behaviorally consistent. (#20591) Thanks @obviyus. +- Telegram: keep media-group processing resilient by skipping recoverable per-item download failures while still failing loud on non-recoverable media errors. (#20598) thanks @mcaxtr. +- Telegram/Agents: gate exec/bash tool-failure warnings behind verbose mode so default Telegram replies stay clean while verbose sessions still surface diagnostics. (#20560) Thanks @obviyus. +- Telegram/Cron/Heartbeat: honor explicit Telegram topic targets in cron and heartbeat delivery (`:topic:`) so scheduled sends land in the configured topic instead of the last active thread. (#19367) Thanks @Lukavyi. +- Telegram/DM routing: prevent DM inbound origin metadata from leaking into main-session `lastRoute` updates and normalize DM `lastRoute.to` to provider-prefixed `telegram:`. (#19491) thanks @guirguispierre. +- Gateway/Daemon: forward `TMPDIR` into installed service environments so macOS LaunchAgent gateway runs can open SQLite temp/journal files reliably instead of failing with `SQLITE_CANTOPEN`. (#20512) Thanks @Clawborn. +- Agents/Billing: include the active model that produced a billing error in user-facing billing messages (for example, `OpenAI (gpt-5.3)`) across payload, failover, and lifecycle error paths, so users can identify exactly which key needs credits. (#20510) Thanks @echoVic. +- Gateway/TUI: honor `agents.defaults.blockStreamingDefault` for `chat.send` by removing the hardcoded block-streaming disable override, so replies can use configured block-mode delivery. (#19693) Thanks @neipor. +- UI/Sessions: accept the canonical main session-key alias in Chat UI flows so main-session routing stays consistent. (#20311) Thanks @mbelinky. +- OpenClawKit/Protocol: preserve JSON boolean literals (`true`/`false`) when bridging through `AnyCodable` so Apple client RPC params no longer re-encode booleans as `1`/`0`. Thanks @mbelinky. +- Commands/Doctor: skip embedding-provider warnings when `memory.backend` is `qmd`, because QMD manages embeddings internally and does not require `memorySearch` providers. (#17263) Thanks @miloudbelarebia. +- Canvas/A2UI: improve bundled-asset resolution and empty-state handling so UI fallbacks render reliably. (#20312) Thanks @mbelinky. +- Commands/Doctor: avoid rewriting invalid configs with new `gateway.auth.token` defaults during repair and only write when real config changes are detected, preventing accidental token duplication and backup churn. +- Gateway/Auth: default unresolved gateway auth to token mode with startup auto-generation/persistence of `gateway.auth.token`, while allowing explicit `gateway.auth.mode: "none"` for intentional open loopback setups. (#20686) thanks @gumadeiras. +- Channels/Matrix: fix mention detection for `formatted_body` Matrix-to links by handling matrix.to mention formats consistently. (#16941) Thanks @zerone0x. +- Heartbeat/Cron: skip interval heartbeats when `HEARTBEAT.md` is missing or empty and no tagged cron events are queued, while preserving cron-event fallback for queued tagged reminders. (#20461) thanks @vikpos. +- Browser/Relay: reuse an already-running extension relay when the relay port is occupied by another OpenClaw process, while still failing on non-relay port collisions to avoid masking unrelated listeners. (#20035) Thanks @mbelinky. +- Scripts: update clawdock helper command support to include `docker-compose.extra.yml` where available. (#17094) Thanks @zerone0x. +- Lobster/Config: remove Lobster executable-path overrides (`lobsterPath`), require PATH-based execution, and add focused Windows wrapper-resolution tests to keep shell-free behavior stable. +- Gateway/WebChat: block `sessions.patch` and `sessions.delete` for WebChat clients so session-store mutations stay restricted to non-WebChat operator flows. Thanks @allsmog for reporting. +- Gateway: clarify launchctl GUI domain bootstrap failure on macOS. (#13795) Thanks @vincentkoc. +- Lobster/CI: fix flaky test Windows cmd shim script resolution. (#20833) Thanks @vincentkoc. +- Browser/Relay: require gateway-token auth on both `/extension` and `/cdp`, and align Chrome extension setup to use a single `gateway.auth.token` input for relay authentication. Thanks @tdjackey for reporting. +- Gateway/Hooks: run BOOT.md startup checks per configured agent scope, including per-agent session-key resolution, startup-hook regression coverage, and non-success boot outcome logging for diagnosability. (#20569) thanks @mcaxtr. +- Protocol/Apple: regenerate Swift gateway models for `push.test` so `pnpm protocol:check` stays green on main. Thanks @mbelinky. +- Sandbox/Registry: serialize container and browser registry writes with shared file locks and atomic replacement to prevent lost updates and delete rollback races from desyncing `sandbox list`, `prune`, and `recreate --all`. Thanks @kexinoh. +- OTEL/diagnostics-otel: complete OpenTelemetry v2 API migration. (#12897) Thanks @vincentkoc. +- Cron/Webhooks: protect cron webhook POST delivery with SSRF-guarded outbound fetch (`fetchWithSsrFGuard`) to block private/metadata destinations before request dispatch. Thanks @Adam55A-code. +- Security/Voice Call: harden `voice-call` telephony TTS override merging by blocking unsafe deep-merge keys (`__proto__`, `prototype`, `constructor`) and add regression coverage for top-level and nested prototype-pollution payloads. +- Security/Windows Daemon: harden Scheduled Task `gateway.cmd` generation by quoting cmd metacharacter arguments, escaping `%`/`!` expansions, and rejecting CR/LF in arguments, descriptions, and environment assignments (`set "KEY=VALUE"`), preventing command injection in Windows daemon startup scripts. Thanks @tdjackey for reporting. +- Security/Gateway/Canvas: replace shared-IP fallback auth with node-scoped session capability URLs for `/__openclaw__/canvas/*` and `/__openclaw__/a2ui/*`, fail closed when trusted-proxy requests omit forwarded client headers, and add IPv6/proxy-header regression coverage. Thanks @aether-ai-agent for reporting. +- Security/Net: enforce strict dotted-decimal IPv4 literals in SSRF checks and fail closed on unsupported legacy forms (octal/hex/short/packed, for example `0177.0.0.1`, `127.1`, `2130706433`) before DNS lookup. +- Security/Discord: enforce trusted-sender guild permission checks for moderation actions (`timeout`, `kick`, `ban`) and ignore untrusted `senderUserId` params to prevent privilege escalation in tool-driven flows. Thanks @aether-ai-agent for reporting. +- Security/ACP+Exec: add `openclaw acp --token-file/--password-file` secret-file support (with inline secret flag warnings), redact ACP working-directory prefixes to `~` home-relative paths, constrain exec script preflight file inspection to the effective `workdir` boundary, and add security-audit warnings when `tools.exec.host="sandbox"` is configured while sandbox mode is off. +- Security/Plugins/Hooks: enforce runtime/package path containment with realpath checks so `openclaw.extensions`, `openclaw.hooks`, and hook handler modules cannot escape their trusted roots via traversal or symlinks. +- Security/Discord: centralize trusted sender checks for moderation actions in message-action dispatch, share moderation command parsing across handlers, and clarify permission helpers with explicit any/all semantics. +- Security/ACP: harden ACP bridge session management with duplicate-session refresh, idle-session reaping, oldest-idle soft-cap eviction, and burst rate limiting on session creation to reduce local DoS risk without disrupting normal IDE usage. +- Security/ACP: bound ACP prompt text payloads to 2 MiB before gateway forwarding, account for join separator bytes during pre-concatenation size checks, and avoid stale active-run session state when oversized prompts are rejected. Thanks @aether-ai-agent for reporting. +- Security/Plugins/Hooks: add optional `--pin` for npm plugin/hook installs, persist resolved npm metadata (`name`, `version`, `spec`, integrity, shasum, timestamp), warn/confirm on integrity drift during updates, and extend `openclaw security audit` to flag unpinned specs, missing integrity metadata, and install-record version drift. +- Security/Plugins: harden plugin discovery by blocking unsafe candidates (root escapes, world-writable paths, suspicious ownership), add startup warnings when `plugins.allow` is empty with discoverable non-bundled plugins, and warn on loaded plugins without install/load-path provenance. +- Security/Gateway: rate-limit control-plane write RPCs (`config.apply`, `config.patch`, `update.run`) to 3 requests per minute per `deviceId+clientIp`, add restart single-flight coalescing plus a 30-second restart cooldown, and log actor/device/ip with changed-path audit details for config/update-triggered restarts. +- Security/Webhooks: harden Feishu and Zalo webhook ingress with webhook-mode token preconditions, loopback-default Feishu bind host, JSON content-type enforcement, per-path rate limiting, replay dedupe for Zalo events, constant-time Zalo secret comparison, and anomaly status counters. +- Security/Plugins: for the next npm release, clarify plugin trust boundary and keep `runtime.system.runCommandWithTimeout` available by default for trusted in-process plugins. Thanks @markmusson for reporting. +- Security/Skills: for the next npm release, reject symlinks during skill packaging to prevent external file inclusion in distributed `.skill` archives. Thanks @aether-ai-agent for reporting. +- Security/Gateway: fail startup when `hooks.token` matches `gateway.auth.token` so hooks and gateway token reuse is rejected at boot. (#20813) Thanks @coygeek. +- Security/Network: block plaintext `ws://` connections to non-loopback hosts and require secure websocket transport elsewhere. (#20803) Thanks @jscaldwell55. +- Security/Config: parse frontmatter YAML using the YAML 1.2 core schema to avoid implicit coercion of `on`/`off`-style values. (#20857) Thanks @davidrudduck. +- Security/Discord: escape backticks in exec-approval embed content to prevent markdown formatting injection via command text. (#20854) Thanks @davidrudduck. +- Security/Agents: replace shell-based `execSync` usage with `execFileSync` in command lookup helpers to eliminate shell argument interpolation risk. (#20655) Thanks @mahanandhi. +- Security/Media: use `crypto.randomBytes()` for temp file names and set owner-only permissions for TTS temp files. (#20654) Thanks @mahanandhi. +- Security/Gateway: set baseline security headers (`X-Content-Type-Options: nosniff`, `Referrer-Policy: no-referrer`) on gateway HTTP responses. (#10526) Thanks @abdelsfane. +- Security/iMessage: harden remote attachment SSH/SCP handling by requiring strict host-key verification, validating `channels.imessage.remoteHost` as `host`/`user@host`, and rejecting unsafe host tokens from config or auto-detection. Thanks @allsmog for reporting. +- Security/Feishu: prevent path traversal in Feishu inbound media temp-file writes by replacing key-derived temp filenames with UUID-based names. Thanks @allsmog for reporting. +- Security/Feishu: escape mention regex metacharacters in `stripBotMention` so crafted mention metadata cannot trigger regex injection or ReDoS during inbound message parsing. (#20916) Thanks @orlyjamie for the fix and @allsmog for reporting. +- LINE/Security: harden inbound media temp-file naming by using UUID-based temp paths for downloaded media instead of external message IDs. (#20792) Thanks @mbelinky. +- Security/Media: harden local media ingestion against TOCTOU/symlink swap attacks by pinning reads to a single file descriptor with symlink rejection and inode/device verification in `saveMediaSource`. Thanks @dorjoos for reporting. +- Security/Lobster (Windows): for the next npm release, remove shell-based fallback when launching Lobster wrappers (`.cmd`/`.bat`) and switch to explicit argv execution with wrapper entrypoint resolution, preventing command injection while preserving Windows wrapper compatibility. Thanks @allsmog for reporting. +- Security/Exec: require `tools.exec.safeBins` binaries to resolve from trusted bin directories (system defaults plus gateway startup `PATH`) so PATH-hijacked trojan binaries cannot bypass allowlist checks. Thanks @jackhax for reporting. +- Security/Exec: remove file-existence oracle behavior from `tools.exec.safeBins` by using deterministic argv-only stdin-safe validation and blocking file-oriented flags (for example `sort -o`, `jq -f`, `grep -f`) so allow/deny results no longer disclose host file presence. Thanks @nedlir for reporting. +- Security/Browser: route browser URL navigation through one SSRF-guarded validation path for tab-open/CDP-target/Playwright navigation flows and block private/metadata destinations by default (configurable via `browser.ssrfPolicy`). Thanks @dorjoos for reporting. +- Security/Exec: for the next npm release, harden safe-bin stdin-only enforcement by blocking output/recursive flags (`sort -o/--output`, grep recursion) and tightening default safe bins to remove `sort`/`grep`, preventing safe-bin allowlist bypass for file writes/recursive reads. Thanks @nedlir for reporting. +- Security/Exec: block grep safe-bin positional operand bypass by setting grep positional budget to zero, so `-e/--regexp` cannot smuggle bare filename reads (for example `.env`) via ambiguous positionals; safe-bin grep patterns must come from `-e/--regexp`. Thanks @athuljayaram for reporting. +- Security/Gateway/Agents: remove implicit admin scopes from agent tool gateway calls by classifying methods to least-privilege operator scopes, and enforce owner-only tooling (`cron`, `gateway`, `whatsapp_login`) through centralized tool-policy wrappers plus tool metadata to prevent non-owner DM privilege escalation. Ships in the next npm release. Thanks @Adam55A-code for reporting. +- Security/Gateway: centralize gateway method-scope authorization and default non-CLI gateway callers to least-privilege method scopes, with explicit CLI scope handling, full core-handler scope classification coverage, and regression guards to prevent scope drift. +- Security/Net: block SSRF bypass via NAT64 (`64:ff9b::/96`, `64:ff9b:1::/48`), 6to4 (`2002::/16`), and Teredo (`2001:0000::/32`) IPv6 transition addresses, and fail closed on IPv6 parse errors. Thanks @jackhax. +- Security/OTEL: sanitize OTLP endpoint URL resolution. (#13791) Thanks @vincentkoc. +- Security: patch Dependabot security issues in pnpm lock. (#20832) Thanks @vincentkoc. +- Security: migrate request dependencies to `@cypress/request`. (#20836) Thanks @vincentkoc. + +## 2026.2.17 + +### Changes + +- Agents/Anthropic: add opt-in 1M context beta header support for Opus/Sonnet via model `params.context1m: true` (maps to `anthropic-beta: context-1m-2025-08-07`). +- Agents/Models: support Anthropic Sonnet 4.6 (`anthropic/claude-sonnet-4-6`) across aliases/defaults with forward-compat fallback when upstream catalogs still only expose Sonnet 4.5. +- Commands/Subagents: add `/subagents spawn` for deterministic subagent activation from chat commands. (#18218) Thanks @JoshuaLelon. +- Agents/Subagents: add an accepted response note for `sessions_spawn` explaining polling subagents are disabled for one-off calls. Thanks @tyler6204. +- Agents/Subagents: prefix spawned subagent task messages with context to preserve source information in downstream handling. Thanks @tyler6204. +- iOS/Share: add an iOS share extension that forwards shared URL/text/image content directly to gateway `agent.request`, with delivery-route fallback and optional receipt acknowledgements. (#19424) Thanks @mbelinky. +- iOS/Talk: add a `Background Listening` toggle that keeps Talk Mode active while the app is backgrounded (off by default for battery safety). Thanks @zeulewan. +- iOS/Talk: add a `Voice Directive Hint` toggle for Talk Mode prompts so users can disable ElevenLabs voice-switching instructions to save tokens when not needed. (#18250) Thanks @zeulewan. +- iOS/Talk: harden barge-in behavior by disabling interrupt-on-speech when output route is built-in speaker/receiver, reducing false interruptions from local TTS bleed-through. Thanks @zeulewan. +- Slack: add native single-message text streaming with Slack `chat.startStream`/`appendStream`/`stopStream`; keep reply threading aligned with `replyToMode`, default streaming to enabled, and fall back to normal delivery when streaming fails. (#9972) Thanks @natedenh. +- Slack: add configurable streaming modes for draft previews. (#18555) Thanks @Solvely-Colin. +- Telegram/Agents: add inline button `style` support (`primary|success|danger`) across message tool schema, Telegram action parsing, send pipeline, and runtime prompt guidance. (#18241) Thanks @obviyus. +- Telegram: surface user message reactions as system events, with configurable `channels.telegram.reactionNotifications` scope. (#10075) Thanks @Glucksberg. +- iMessage: support `replyToId` on outbound text/media sends and normalize leading `[[reply_to:]]` tags so replies target the intended iMessage. Thanks @tyler6204. +- Tool Display/Web UI: add intent-first tool detail views and exec summaries. (#18592) Thanks @xdLawless2. +- Discord: expose native `/exec` command options (host/security/ask/node) so Discord slash commands get autocomplete and structured inputs. Thanks @thewilloftheshadow. +- Discord: allow reusable interactive components with `components.reusable=true` so buttons, selects, and forms can be used multiple times before expiring. Thanks @thewilloftheshadow. +- Discord: add per-button `allowedUsers` allowlist for interactive components to restrict who can click buttons. Thanks @thewilloftheshadow. +- Cron/Gateway: separate per-job webhook delivery (`delivery.mode = "webhook"`) from announce delivery, enforce valid HTTP(S) webhook URLs, and keep a temporary legacy `notify + cron.webhook` fallback for stored jobs. (#17901) Thanks @advaitpaliwal. +- Cron/CLI: add deterministic default stagger for recurring top-of-hour cron schedules (including 6-field seconds cron), auto-migrate existing jobs to persisted `schedule.staggerMs`, and add `openclaw cron add/edit --stagger ` plus `--exact` overrides for per-job timing control. +- Cron: log per-run model/provider usage telemetry in cron run logs/webhooks and add a local usage report script for aggregating token usage by job. (#18172) Thanks @HankAndTheCrew. +- Tools/Web: add URL allowlists for `web_search` and `web_fetch`. (#18584) Thanks @smartprogrammer93. +- Browser: add `extraArgs` config for custom Chrome launch arguments. (#18443) Thanks @JayMishra-source. +- Voice Call: pre-cache inbound greeting TTS for faster first playback. (#18447) Thanks @JayMishra-source. +- Skills: compact skill file `` paths in the system prompt by replacing home-directory prefixes with `~`, and add targeted compaction tests for prompt serialization behavior. (#14776) Thanks @bitfish3. +- Skills: refine skill-description routing boundaries with explicit "Use when"/"NOT for" guidance for coding-agent/github/weather, and clarify PTY/browser fallback wording. (#14577) Thanks @DylanWoodAkers. +- Auto-reply/Prompts: include trusted inbound `message_id` in conversation metadata payloads for downstream targeting workflows. Thanks @tyler6204. +- Auto-reply: include `sender_id` in trusted inbound metadata so moderation workflows can target the sender without relying on untrusted text. (#18303) Thanks @crimeacs. +- UI/Sessions: avoid duplicating typed session prefixes in display names (for example `Subagent Subagent ...`). Thanks @tyler6204. +- Agents/Z.AI: enable `tool_stream` by default for real-time tool call streaming, with opt-out via `params.tool_stream: false`. (#18173) Thanks @tianxiao1430-jpg. +- Plugins: add `before_agent_start` model/provider overrides before resolution. (#18568) Thanks @natefikru. +- Mattermost: add emoji reaction actions plus reaction event notifications, including an explicit boolean `remove` flag to avoid accidental removals. (#18608) Thanks @echo931. +- Memory/Search: add FTS fallback plus query expansion for memory search. (#18304) Thanks @irchelper. +- Agents/Models: support per-model `thinkingDefault` overrides in model config. (#18152) Thanks @wu-tian807. +- Agents: enable `llms.txt` discovery in default behavior. (#18158) Thanks @yolo-maxi. +- Extensions/Auth: add OpenAI Codex CLI auth provider integration. (#18009) Thanks @jiteshdhamaniya. +- Feishu: add Bitable create-app/create-field tools for automation workflows. (#17963) Thanks @gaowanqi08141999. +- Docker: add optional `OPENCLAW_INSTALL_BROWSER` build arg to preinstall Chromium + Xvfb in the Docker image, avoiding runtime Playwright installs. (#18449) + +### Fixes + +- Agents/Antigravity: preserve unsigned Claude thinking blocks as plain text instead of dropping them during transcript sanitization, preventing reasoning context loss while avoiding `thinking.signature` request rejections. +- Agents/Google: clean tool JSON Schemas for `google-antigravity` the same as `google-gemini-cli` before Cloud Code Assist requests, preventing Claude tool calls from failing with `patternProperties` 400 errors. (#19860) +- Tests/Telegram: add regression coverage for command-menu sync that asserts all `setMyCommands` entries are Telegram-safe and hyphen-normalized across native/custom/plugin command sources. (#19703) Thanks @obviyus. +- Agents/Image: collapse resize diagnostics to one line per image and include visible pixel/byte size details in the log message for faster triage. +- Auth/Cooldowns: clear all usage stats fields (`disabledUntil`, `disabledReason`, `failureCounts`) in `clearAuthProfileCooldown` so manual cooldown resets fully recover billing-disabled profiles without requiring direct file edits. (#19211) Thanks @nabbilkhan. +- Agents/Subagents: preemptively guard accumulated tool-result context before model calls by truncating oversized outputs and compacting oldest tool-result messages to avoid context-window overflow crashes. Thanks @tyler6204. +- Agents/Subagents/CLI: fail `sessions_spawn` when subagent model patching is rejected, allow subagent model patch defaults from `subagents.model`, and keep `sessions list`/`status` model reporting aligned to runtime model resolution. (#18660) Thanks @robbyczgw-cla. +- Agents/Subagents: add explicit subagent guidance to recover from `[compacted: tool output removed to free context]` / `[truncated: output exceeded context limit]` markers by re-reading with smaller chunks instead of full-file `cat`. Thanks @tyler6204. +- Agents/Tools: make `read` auto-page across chunks (when no explicit `limit` is provided) and scale its per-call output budget from model `contextWindow`, so larger contexts can read more before context guards kick in. Thanks @tyler6204. +- Agents/Tools: strip duplicated `read` truncation payloads from tool-result `details` and make pre-call context guarding account for heavy tool-result metadata, so repeated `read` calls no longer bypass compaction and overflow model context windows. Thanks @tyler6204. +- Reply threading: keep reply context sticky across streamed/split chunks and preserve `replyToId` on all chunk sends across shared and channel-specific delivery paths (including iMessage, BlueBubbles, Telegram, Discord, and Matrix), so follow-up bubbles stay attached to the same referenced message. Thanks @tyler6204. +- Gateway/Agent: defer transient lifecycle `error` snapshots with a short grace window so `agent.wait` does not resolve early during retry/failover. Thanks @tyler6204. +- Gateway/Presence: centralize presence snapshot broadcasts and unify runtime version precedence (`OPENCLAW_VERSION` > `OPENCLAW_SERVICE_VERSION` > `npm_package_version`) so self-presence and websocket `hello-ok` report consistent versions. +- Hooks/Automation: bridge outbound/inbound message lifecycle into internal hook events (`message:received`, `message:sent`) with session-key correlation guards, while keeping per-payload success/error reporting accurate for chunked and best-effort deliveries. (PR #9387) +- Media understanding: honor `agents.defaults.imageModel` during auto-discovery so implicit image analysis uses configured primary/fallback image models. (PR #7607) +- iOS/Onboarding: stop auth Step 3 retry-loop churn by pausing reconnect attempts on unauthorized/missing-token gateway errors and keeping auth/pairing issue state sticky during manual retry. (#19153) Thanks @mbelinky. +- Voice-call: auto-end calls when media streams disconnect to prevent stuck active calls. (#18435) Thanks @JayMishra-source. +- Voice call/Gateway: prevent overlapping closed-loop turn races with per-call turn locking, route transcript dedupe via source-aware fingerprints with strict cache eviction bounds, and harden `voicecall latency` stats for large logs without spread-operator stack overflow. (#19140) Thanks @mbelinky. +- iOS/Chat: route ChatSheet RPCs through the operator session instead of the node session to avoid node-role authorization failures for `chat.history`, `chat.send`, and `sessions.list`. (#19320) Thanks @mbelinky. +- macOS/Update: correct the Sparkle appcast version for 2026.2.15 so updates are offered again. (#18201) +- Gateway/Auth: clear stale device-auth tokens after device token mismatch errors so re-paired clients can re-auth. (#18201) +- Telegram: enable DM voice-note transcription with CLI fallback handling. (#18564) Thanks @thhuang. +- Telegram/Polls: restore Telegram poll action wiring in channel handlers. (#18122) Thanks @akyourowngames. +- WebChat: strip reply/audio directive tags from rendered chat output. (#18093) Thanks @aldoeliacim. +- Discord: honor configured HTTP proxy for app-id and allowlist REST resolution. (#17958) Thanks @k2009. +- BlueBubbles: add fallback path to recover outbound `message_id` from `fromMe` webhooks when platform message IDs are missing. Thanks @tyler6204. +- BlueBubbles: match outbound message-id fallback recovery by chat identifier as well as account context. Thanks @tyler6204. +- BlueBubbles: include sender identifier in untrusted conversation metadata for conversation info payloads. Thanks @tyler6204. +- Security/Exec: fix the OC-09 credential-theft path via environment-variable injection. (#18048) Thanks @aether-ai-agent. +- Security/Config: confine `$include` resolution to the top-level config directory, harden traversal/symlink checks with cross-platform-safe path containment, and add doctor hints for invalid escaped include paths. (#18652) Thanks @aether-ai-agent. +- Security/Net: block SSRF bypass via ISATAP embedded IPv4 transition addresses and centralize hostname/IP blocking checks across URL safety validators. Thanks @zpbrent for reporting. +- Providers: improve error messaging for unconfigured local `ollama`/`vllm` providers. (#18183) Thanks @arosstale. +- TTS: surface all provider errors instead of only the last error in aggregated failures. (#17964) Thanks @ikari-pl. +- CLI/Doctor/Configure: skip gateway auth checks for loopback-only setups. (#18407) Thanks @sggolakiya. +- CLI/Doctor: reconcile gateway service-token drift after re-pair flows. (#18525) Thanks @norunners. +- Process/Windows: disable detached spawn in exec runs to prevent empty command output. (#18067) Thanks @arosstale. +- Process: gracefully terminate process trees with SIGTERM before SIGKILL. (#18626) Thanks @sauerdaniel. +- Sessions/Windows: use atomic session-store writes to prevent context loss on Windows. (#18347) Thanks @twcwinston. +- Agents/Image: validate base64 image payloads before provider submission. (#18263) Thanks @sriram369. +- Models CLI: validate catalog entries in `openclaw models set`. (#18129) Thanks @carrotRakko. +- Usage: isolate last-turn totals in token usage reporting to avoid mixed-turn totals. (#18052) Thanks @arosstale. +- Cron: resolve `accountId` from agent bindings in isolated sessions. (#17996) Thanks @simonemacario. +- Gateway/HTTP: preserve unbracketed IPv6 `Host` headers when normalizing requests. (#18061) Thanks @Clawborn. +- Sandbox: fix workspace-directory orphaning during SHA-1 -> SHA-256 slug migration. (#18523) Thanks @yinghaosang. +- Ollama/Qwen: handle Qwen 3 reasoning field format in Ollama responses. (#18631) Thanks @mr-sk. +- OpenAI/Transcripts: always drop orphaned reasoning blocks from transcript repair. (#18632) Thanks @TySabs. +- Fix types in all tests. Typecheck the whole repository. +- Gateway/Channels: wire `gateway.channelHealthCheckMinutes` into strict config validation, treat implicit account status as managed for health checks, and harden channel auto-restart flow (preserve restart-attempt caps across crash loops, propagate enabled/configured runtime flags, and stop pending restart backoff after manual stop). Thanks @steipete. +- Gateway/WebChat: hard-cap `chat.history` oversized payloads by truncating high-cost fields and replacing over-budget entries with placeholders, so history fetches stay within configured byte limits and avoid chat UI freezes. (#18505) +- UI/Usage: replace lingering undefined `var(--text-muted)` usage with `var(--muted)` in usage date-range and chart styles to keep muted text visible across themes. (#17975) Thanks @jogelin. +- UI/Usage: preserve selected-range totals when timeline data is downsampled by bucket-aggregating timeseries points (instead of dropping intermediate points), so filtered tokens/cost stay accurate. (#17959) Thanks @jogelin. +- UI/Sessions: refresh the sessions table only after successful deletes and preserve delete errors on cancel/failure paths, so deleted sessions disappear automatically without masking delete failures. (#18507) +- Scripts/UI/Windows: fix `pnpm ui:*` spawn `EINVAL` failures by restoring shell-backed launch for `.cmd`/`.bat` runners, narrowing shell usage to launcher types that require it, and rejecting unsafe forwarded shell metacharacters in UI script args. (#18594) +- Hooks/Session-memory: recover `/new` conversation summaries when session pointers are reset-path or missing `sessionFile`, and consistently prefer the newest `.jsonl.reset.*` transcript candidate for fallback extraction. (#18088) +- Auto-reply/Sessions: prevent stale thread ID leakage into non-thread sessions so replies stay in the main DM after topic interactions. (#18528) Thanks @j2h4u. +- Slack: restrict forwarded-attachment ingestion to explicit shared-message attachments and skip non-Slack forwarded `image_url` fetches, preventing non-forward attachment unfurls from polluting inbound agent context while preserving forwarded message handling. +- Feishu: detect bot mentions in post messages with embedded docs when `message.mentions` is empty. (#18074) Thanks @popomore. +- Agents/Sessions: align session lock watchdog hold windows with run and compaction timeout budgets (plus grace), preventing valid long-running turns from being force-unlocked mid-run while still recovering hung lock owners. (#18060) +- Cron: preserve default model fallbacks for cron agent runs when only `model.primary` is overridden, so failover still follows configured fallbacks unless explicitly cleared with `fallbacks: []`. (#18210) Thanks @mahsumaktas. +- Cron: route text-only announce output through the main session announce flow via runSubagentAnnounceFlow so cron text-only output remains visible to the initiating session. Thanks @tyler6204. +- Cron: treat `timeoutSeconds: 0` as no-timeout (not clamped to 1), ensuring long-running cron runs are not prematurely terminated. Thanks @tyler6204. +- Cron announce injection now targets the session determined by delivery config (`to` + channel) instead of defaulting to the current session. Thanks @tyler6204. +- Cron/Heartbeat: canonicalize session-scoped reminder `sessionKey` routing and preserve explicit flat `sessionKey` cron tool inputs, preventing enqueue/wake namespace drift for session-targeted reminders. (#18637) Thanks @vignesh07. +- Cron/Webhooks: reuse existing session IDs for webhook/cron runs when the session key is stable and still fresh, preserving conversation history. (#18031) Thanks @Operative-001. +- Cron: prevent spin loops when cron jobs complete within the scheduled second by advancing the next run and enforcing a minimum refire gap. (#18073) Thanks @widingmarcus-cyber. +- OpenClawKit/iOS ChatUI: accept canonical session-key completion events for local pending runs and preserve message IDs across history refreshes, preventing stuck "thinking" state and message flicker after gateway replies. (#18165) Thanks @mbelinky. +- iOS/Onboarding: add QR-first onboarding wizard with setup-code deep link support, pairing/auth issue guidance, and device-pair QR generation improvements for Telegram/Web/TUI fallback flows. (#18162) Thanks @mbelinky and @Marvae. +- iOS/Gateway: stabilize connect/discovery state handling, add onboarding reset recovery in Settings, and fix iOS gateway-controller coverage for command-surface and last-connection persistence behavior. (#18164) Thanks @mbelinky. +- iOS/Talk: harden mobile talk config handling by ignoring redacted/env-placeholder API keys, support secure local keychain override, improve accessibility motion/contrast behavior in status UI, and tighten ATS to local-network allowance. (#18163) Thanks @mbelinky. +- iOS/Location: restore the significant location monitor implementation (service hooks + protocol surface + ATS key alignment) after merge drift so iOS builds compile again. (#18260) Thanks @ngutman. +- iOS/Signing: auto-select local Apple Development team during iOS project generation/build, prefer the canonical OpenClaw team when available, and support local per-machine signing overrides without committing team IDs. (#18421) Thanks @ngutman. +- Discord/Telegram: make per-account message action gates effective for both action listing and execution, and preserve top-level gate restrictions when account overrides only specify a subset of `actions` keys (account key -> base key -> default fallback). (#18494) +- Telegram: keep DM-topic replies and draft previews in the originating private-chat topic by preserving positive `message_thread_id` values for DM threads. (#18586) Thanks @sebslight. +- Telegram: preserve private-chat topic `message_thread_id` on outbound sends (message/sticker/poll), keep thread-not-found retry fallback, and avoid masking `chat not found` routing errors. (#18993) Thanks @obviyus. +- Discord: prevent duplicate media delivery when the model uses the `message send` tool with media, by skipping media extraction from messaging tool results since the tool already sent the message directly. (#18270) +- Discord: route `audioAsVoice` auto-replies through the voice message API so opt-in audio renders as voice messages. (#18041) Thanks @zerone0x. +- Discord: skip auto-thread creation in forum/media/voice/stage channels and keep group session last-route metadata fresh to avoid invalid thread API errors and lost follow-up sends. (#18098) Thanks @Clawborn. +- Discord/Commands: normalize `commands.allowFrom` entries with `user:`/`discord:`/`pk:` prefixes and `<@id>` mentions so command authorization matches Discord allowlist behavior. (#18042) +- Telegram: keep draft-stream preview replies attached to the user message for `replyToMode: "all"` in groups and DMs, preserving threaded reply context from preview through finalization. (#17880) Thanks @yinghaosang. +- Telegram: prevent streaming final replies from being overwritten by later final/error payloads, and suppress fallback tool-error warnings when a recovered assistant answer already exists after tool calls. (#17883) Thanks @Marvae and @obviyus. +- Telegram: debounce the first draft-stream preview update (30-char threshold) and finalize short responses by editing the stop-time preview message, improving first push notifications and avoiding duplicate final sends. (#18148) Thanks @Marvae. +- Telegram: disable block streaming when `channels.telegram.streamMode` is `off`, preventing newline/content-block replies from splitting into multiple messages. (#17679) Thanks @saivarunk. +- Telegram: keep `streamMode: "partial"` draft previews in a single message across assistant-message/reasoning boundaries, preventing duplicate preview bubbles during partial-mode tool-call turns. (#18956) Thanks @obviyus. +- Telegram: normalize native command names for Telegram menu registration (`-` -> `_`) to avoid `BOT_COMMAND_INVALID` command-menu wipeouts, and log failed command syncs instead of silently swallowing them. (#19257) Thanks @akramcodez. +- Telegram: route non-abort slash commands on the normal chat/topic sequential lane while keeping true abort requests (`/stop`, `stop`) on the control lane, preventing command/reply race conditions from control-lane bypass. (#17899) Thanks @obviyus. +- Telegram: ignore `` placeholder lines when extracting `MEDIA:` tool-result paths, preventing false local-file reads and dropped replies. (#18510) Thanks @yinghaosang. +- Telegram: skip retries when inbound media `getFile` fails with Telegram's 20MB limit and continue processing message text, avoiding dropped messages for oversized attachments. (#18531) Thanks @brandonwise. +- Telegram: clear stored polling offsets when bot tokens change or accounts are deleted, preventing stale offsets after token rotations. (#18233) +- Telegram: enable `autoSelectFamily` by default on Node.js 22+ so IPv4 fallback works on broken IPv6 networks. (#18272) Thanks @nacho9900. +- Auto-reply/TTS: keep tool-result media delivery enabled in group chats and native command sessions (while still suppressing tool summary text) so `NO_REPLY` follow-ups do not drop successful TTS audio. (#17991) Thanks @zerone0x. +- Agents/Tools: deliver tool-result media even when verbose tool output is off so media attachments are not dropped. (#16679) +- Discord: optimize reaction notification handling to skip unnecessary message fetches in `off`/`all`/`allowlist` modes, streamline reaction routing, and improve reaction emoji formatting. (#18248) Thanks @thewilloftheshadow and @victorGPT. +- CLI/Pairing: make `openclaw qr --remote` prefer `gateway.remote.url` over tailscale/public URL resolution and register the `openclaw clawbot qr` legacy alias path. (#18091) +- CLI/QR: restore fail-fast validation for `openclaw qr --remote` when neither `gateway.remote.url` nor tailscale `serve`/`funnel` is configured, preventing unusable remote pairing QR flows. (#18166) Thanks @mbelinky. +- CLI: fix parent/subcommand option collisions across gateway, daemon, update, ACP, and browser command flows, while preserving legacy `browser set headers --json ` compatibility. +- CLI/Doctor: ensure `openclaw doctor --fix --non-interactive --yes` exits promptly after completion so one-shot automation no longer hangs. (#18502) +- CLI/Doctor: auto-repair `dmPolicy="open"` configs missing wildcard allowlists and write channel-correct repair paths (including `channels.googlechat.dm.allowFrom`) so `openclaw doctor --fix` no longer leaves Google Chat configs invalid after attempted repair. (#18544) +- CLI/Doctor: detect gateway service token drift when the gateway token is only provided via environment variables, keeping service repairs aligned after token rotation. +- Gateway/Update: prevent restart crash loops after failed self-updates by restarting only on successful updates, stopping early on failed install/build steps, and running `openclaw doctor --fix` during updates to sanitize config. (#18131) Thanks @RamiNoodle733. +- Gateway/Update: preserve update.run restart delivery context so post-update status replies route back to the initiating channel/thread. (#18267) Thanks @yinghaosang. +- CLI/Update: run a standalone restart helper after updates, honoring service-name overrides and reporting restart initiation separately from confirmed restarts. (#18050) +- CLI/Daemon: warn when a gateway restart sees a stale service token so users can reinstall with `openclaw gateway install --force`, and skip drift warnings for non-gateway service restarts. (#18018) +- CLI/Daemon: prefer the active version-manager Node when installing daemons and include macOS version-manager bin directories in the service PATH so launchd services resolve user-managed runtimes. +- CLI/Status: fix `openclaw status --all` token summaries for bot-token-only channels so Mattermost/Zalo no longer show a bot+app warning. (#18527) Thanks @echo931. +- CLI/Configure: make the `/model picker` allowlist prompt searchable with tokenized matching in `openclaw configure` so users can filter huge model lists by typing terms like `gpt-5.2 openai/`. (#19010) Thanks @bjesuiter. +- CLI/Message: preserve `--components` JSON payloads in `openclaw message send` so Discord component payloads are no longer dropped. (#18222) Thanks @saurabhchopade. +- Voice Call: add an optional stale call reaper (`staleCallReaperSeconds`) to end stuck calls when enabled. (#18437) +- Auto-reply/Subagents: propagate group context (`groupId`, `groupChannel`, `space`) when spawning via `/subagents spawn`, matching tool-triggered subagent spawn behavior. +- Subagents: route nested announce results back to the parent session after the parent run ends, falling back only when the parent session is deleted. (#18043) Thanks @tyler6204. +- Subagents: cap announce retry loops with max attempts and expiry to prevent infinite retry spam after deferred announces. (#18444) +- Agents/Tools/exec: add a preflight guard that detects likely shell env var injection (e.g. `$DM_JSON`, `$TMPDIR`) in Python/Node scripts before execution, preventing recurring cron failures and wasted tokens when models emit mixed shell+language source. (#12836) +- Agents/Tools/exec: treat normal non-zero exit codes as completed and append the exit code to tool output to avoid false tool-failure warnings. (#18425) +- Agents/Tools: make loop detection progress-aware and phased by hard-blocking known `process(action=poll|log)` no-progress loops, warning on generic identical-call repeats, warning + no-progress-blocking ping-pong alternation loops (10/20), coalescing repeated warning spam into threshold buckets (including canonical ping-pong pairs), adding a global circuit breaker at 30 no-progress repeats, and emitting structured diagnostic `tool.loop` warning/error events for loop actions. (#16808) Thanks @akramcodez and @beca-oc. +- Agents/Hooks: preserve the `before_tool_call` wrapped-marker across abort-signal tool wrapping so the hook runs once per tool call in normal agent sessions. (#16852) Thanks @sreuter. +- Agents/Tests: add `before_message_write` persistence regression coverage for block/mutate behavior (including synthetic tool-result flushes) and thrown-hook fallback persistence. (#18197) Thanks @shakkernerd +- Agents/Tools: scope the `message` tool schema to the active channel so Telegram uses `buttons` and Discord uses `components`. (#18215) Thanks @obviyus. +- Agents/Image tool: replace Anthropic-incompatible union schema with explicit `image` (single) and `images` (multi) parameters, keeping tool schemas `anyOf`/`oneOf`/`allOf`-free while preserving multi-image analysis support. (#18551, #18566) Thanks @aldoeliacim. +- Agents/Models: probe the primary model when its auth-profile cooldown is near expiry (with per-provider throttling), so runs recover from temporary rate limits without staying on fallback models until restart. (#17478) Thanks @PlayerGhost. +- Agents/Failover: classify provider abort stop-reason errors (`Unhandled stop reason: abort`, `stop reason: abort`, `reason: abort`) as timeout-class failures so configured model fallback chains trigger instead of surfacing raw abort failures. (#18618) Thanks @sauerdaniel. +- Models/CLI: sync auth-profiles credentials into agent `auth.json` before registry availability checks so `openclaw models list --all` reports auth correctly for API-key/token providers, normalize provider-id aliases when bridging credentials, and skip expired token mirrors. (#18610, #18615) +- Agents/Context: raise default total bootstrap prompt cap from `24000` to `150000` chars (keeping `bootstrapMaxChars` at `20000`), include total-cap visibility in `/context`, and mark truncation from injected-vs-raw sizes so total-cap clipping is reflected accurately. +- Memory/QMD: scope managed collection names per agent and precreate glob-backed collection directories before registration, preventing cross-agent collection clobbering and startup ENOENT failures in fresh workspaces. (#17194) Thanks @jonathanadams96. +- Cron: preserve per-job schedule-error isolation in post-run maintenance recompute so malformed sibling jobs no longer abort persistence of successful runs. (#17852) Thanks @pierreeurope. +- Gateway/Config: prevent `config.patch` object-array merges from falling back to full-array replacement when some patch entries lack `id`, so partial `agents.list` updates no longer drop unrelated agents. (#17989) Thanks @stakeswky. +- Gateway/Auth: trim whitespace around trusted proxy entries before matching so configured proxies with stray spaces still authorize. (#18084) Thanks @Clawborn. +- Config/Discord: require string IDs in Discord allowlists, keep onboarding inputs string-only, and add doctor repair for numeric entries. (#18220) Thanks @thewilloftheshadow. +- Security/Sessions: create new session transcript JSONL files with user-only (`0o600`) permissions and extend `openclaw security audit --fix` to remediate existing transcript file permissions. +- Sessions/Maintenance: archive transcripts when pruning stale sessions, clean expired media in subdirectories, and purge `.deleted` transcript archives after the prune window to prevent disk leaks. (#18538) +- Infra/Fetch: ensure foreign abort-signal listener cleanup never masks original fetch successes/failures, while still preventing detached-finally unhandled rejection noise in `wrapFetchWithAbortSignal`. Thanks @Jackten. +- Heartbeat: allow suppressing tool error warning payloads during heartbeat runs via a new heartbeat config flag. (#18497) Thanks @thewilloftheshadow. +- Heartbeat: include sender metadata (From/To/Provider) in heartbeat prompts so model context matches the delivery target. (#18532) Thanks @dinakars777. +- Heartbeat/Telegram: strip configured `responsePrefix` before heartbeat ack detection (with boundary-safe matching) so prefixed `HEARTBEAT_OK` replies are correctly suppressed instead of leaking into DMs. (#18602) + +## 2026.2.15 ### Changes @@ -11,12 +814,15 @@ Docs: https://docs.openclaw.ai - Plugins: expose `llm_input` and `llm_output` hook payloads so extensions can observe prompt/input context and model output usage details. (#16724) Thanks @SecondThread. - Subagents: nested sub-agents (sub-sub-agents) with configurable depth. Set `agents.defaults.subagents.maxSpawnDepth: 2` to allow sub-agents to spawn their own children. Includes `maxChildrenPerAgent` limit (default 5), depth-aware tool policy, and proper announce chain routing. (#14447) Thanks @tyler6204. - Slack/Discord/Telegram: add per-channel ack reaction overrides (account/channel-level) to support platform-specific emoji formats. (#17092) Thanks @zerone0x. +- Telegram: add `channel_post` inbound support for channel-based bot-to-bot wake/trigger flows, with channel allowlist gating and message/media batching parity. - Cron/Gateway: add finished-run webhook delivery toggle (`notify`) and dedicated webhook auth token support (`cron.webhookToken`) for outbound cron webhook posts. (#14535) Thanks @advaitpaliwal. -- Cron/Gateway: separate per-job webhook delivery (`delivery.mode = "webhook"`) from announce delivery, enforce valid HTTP(S) webhook URLs, and keep a temporary legacy `notify + cron.webhook` fallback for stored jobs. (#17901) Thanks @advaitpaliwal. - Channels: deduplicate probe/token resolution base types across core + extensions while preserving per-channel error typing. (#16986) Thanks @iyoda and @thewilloftheshadow. +- Memory: add MMR (Maximal Marginal Relevance) re-ranking for hybrid search diversity. Configurable via `memorySearch.query.hybrid.mmr`. Thanks @rodrigouroz. +- Memory: add opt-in temporal decay for hybrid search scoring, with configurable half-life via `memorySearch.query.hybrid.temporalDecay`. Thanks @rodrigouroz. ### Fixes +- Discord: send initial content when creating non-forum threads so `thread-create` content is delivered. (#18117) Thanks @zerone0x. - Security: replace deprecated SHA-1 sandbox configuration hashing with SHA-256 for deterministic sandbox cache identity and recreation checks. Thanks @kexinoh. - Security/Logging: redact Telegram bot tokens from error messages and uncaught stack traces to prevent accidental secret leakage into logs. Thanks @aether-ai-agent. - Sandbox/Security: block dangerous sandbox Docker config (bind mounts, host networking, unconfined seccomp/apparmor) to prevent container escape via config injection. Thanks @aether-ai-agent. @@ -32,6 +838,7 @@ Docs: https://docs.openclaw.ai - Gateway/Agent: reject malformed `agent:`-prefixed session keys (for example, `agent:main`) in `agent` and `agent.identity.get` instead of silently resolving them to the default agent, preventing accidental cross-session routing. (#15707) Thanks @rodrigouroz. - Gateway/Chat: harden `chat.send` inbound message handling by rejecting null bytes, stripping unsafe control characters, and normalizing Unicode to NFC before dispatch. (#8593) Thanks @fr33d3m0n. - Gateway/Send: return an actionable error when `send` targets internal-only `webchat`, guiding callers to use `chat.send` or a deliverable channel. (#15703) Thanks @rodrigouroz. +- Gateway/Commands: keep webchat command authorization on the internal `webchat` context instead of inferring another provider from channel allowlists, fixing dropped `/new`/`/status` commands in Control UI when channel allowlists are configured. (#7189) Thanks @karlisbergmanis-lv. - Control UI: prevent stored XSS via assistant name/avatar by removing inline script injection, serving bootstrap config as JSON, and enforcing `script-src 'self'`. Thanks @Adam55A-code. - Agents/Security: sanitize workspace paths before embedding into LLM prompts (strip Unicode control/format chars) to prevent instruction injection via malicious directory names. Thanks @aether-ai-agent. - Agents/Sandbox: clarify system prompt path guidance so sandbox `bash/exec` uses container paths (for example `/workspace`) while file tools keep host-bridge mapping, avoiding first-attempt path misses from host-only absolute paths in sandbox command execution. (#17693) Thanks @app/juniordevbot. @@ -40,22 +847,24 @@ Docs: https://docs.openclaw.ai - Agents/OpenAI: force `store=true` for direct OpenAI Responses/Codex runs to preserve multi-turn server-side conversation state, while leaving proxy/non-OpenAI endpoints unchanged. (#16803) Thanks @mark9232 and @vignesh07. - Memory/FTS: make `buildFtsQuery` Unicode-aware so non-ASCII queries (including CJK) produce keyword tokens instead of falling back to vector-only search. (#17672) Thanks @KinGP5471. - Auto-reply/Compaction: resolve `memory/YYYY-MM-DD.md` placeholders with timezone-aware runtime dates and append a `Current time:` line to memory-flush turns, preventing wrong-year memory filenames without making the system prompt time-variant. (#17603, #17633) Thanks @nicholaspapadam-wq and @vignesh07. +- Auth/Cooldowns: auto-expire stale auth profile cooldowns when `cooldownUntil` or `disabledUntil` timestamps have passed, and reset `errorCount` so the next transient failure does not immediately escalate to a disproportionately long cooldown. Handles `cooldownUntil` and `disabledUntil` independently. (#3604) Thanks @nabbilkhan. - Agents: return an explicit timeout error reply when an embedded run times out before producing any payloads, preventing silent dropped turns during slow cache-refresh transitions. (#16659) Thanks @liaosvcaf and @vignesh07. - Group chats: always inject group chat context (name, participants, reply guidance) into the system prompt on every turn, not just the first. Prevents the model from losing awareness of which group it's in and incorrectly using the message tool to send to the same group. (#14447) Thanks @tyler6204. - Browser/Agents: when browser control service is unavailable, return explicit non-retry guidance (instead of "try again") so models do not loop on repeated browser tool calls until timeout. (#17673) Thanks @austenstone. - Subagents: use child-run-based deterministic announce idempotency keys across direct and queued delivery paths (with legacy queued-item fallback) to prevent duplicate announce retries without collapsing distinct same-millisecond announces. (#17150) Thanks @widingmarcus-cyber. - Subagents/Models: preserve `agents.defaults.model.fallbacks` when subagent sessions carry a model override, so subagent runs fail over to configured fallback models instead of retrying only the overridden primary model. +- Agents/Tools: scope the `message` tool schema to the active channel so Telegram uses `buttons` and Discord uses `components`. (#18215) Thanks @obviyus. - Telegram: omit `message_thread_id` for DM sends/draft previews and keep forum-topic handling (`id=1` general omitted, non-general kept), preventing DM failures with `400 Bad Request: message thread not found`. (#10942) Thanks @garnetlyx. - Telegram: replace inbound `` placeholder with successful preflight voice transcript in message body context, preventing placeholder-only prompt bodies for mention-gated voice messages. (#16789) Thanks @Limitless2023. - Telegram: retry inbound media `getFile` calls (3 attempts with backoff) and gracefully fall back to placeholder-only processing when retries fail, preventing dropped voice/media messages on transient Telegram network errors. (#16154) Thanks @yinghaosang. - Telegram: finalize streaming preview replies in place instead of sending a second final message, preventing duplicate Telegram assistant outputs at stream completion. (#17218) Thanks @obviyus. -- Telegram: disable block streaming when `channels.telegram.streamMode` is `off`, preventing newline/content-block replies from splitting into multiple messages. (#17679) Thanks @saivarunk. -- Telegram: route non-abort slash commands on the normal chat/topic sequential lane while keeping true abort requests (`/stop`, `stop`) on the control lane, preventing command/reply race conditions from control-lane bypass. (#17899) Thanks @obviyus. - Discord: preserve channel session continuity when runtime payloads omit `message.channelId` by falling back to event/raw `channel_id` values for routing/session keys, so same-channel messages keep history across turns/restarts. Also align diagnostics so active Discord runs no longer appear as `sessionKey=unknown`. (#17622) Thanks @shakkernerd. - Discord: dedupe native skill commands by skill name in multi-agent setups to prevent duplicated slash commands with `_2` suffixes. (#17365) Thanks @seewhyme. - Discord: ensure role allowlist matching uses raw role IDs for message routing authorization. Thanks @xinhuagu. +- Discord: skip text-based exec approval forwarding in favor of Discord's component-based approval UI. Thanks @thewilloftheshadow. - Web UI/Agents: hide `BOOTSTRAP.md` in the Agents Files list after onboarding is completed, avoiding confusing missing-file warnings for completed workspaces. (#17491) Thanks @gumadeiras. - Memory/QMD: scope managed collection names per agent and precreate glob-backed collection directories before registration, preventing cross-agent collection clobbering and startup ENOENT failures in fresh workspaces. (#17194) Thanks @jonathanadams96. +- Gateway/Memory: initialize QMD startup sync for every configured agent (not just the default agent), so `memory.qmd.update.onBoot` is effective across multi-agent setups. (#17663) Thanks @HenryLoenwind. - Auto-reply/WhatsApp/TUI/Web: when a final assistant message is `NO_REPLY` and a messaging tool send succeeded, mirror the delivered messaging-tool text into session-visible assistant output so TUI/Web no longer show `NO_REPLY` placeholders. (#7010) Thanks @Morrowind-Xie. - Cron: infer `payload.kind="agentTurn"` for model-only `cron.update` payload patches, so partial agent-turn updates do not fail validation when `kind` is omitted. (#15664) Thanks @rodrigouroz. - TUI: make searchable-select filtering and highlight rendering ANSI-aware so queries ignore hidden escape codes and no longer corrupt ANSI styling sequences during match highlighting. (#4519) Thanks @bee4come. @@ -77,6 +886,8 @@ Docs: https://docs.openclaw.ai ### Fixes +- CLI/Installation: fix Docker installation hangs on macOS. (#12972) Thanks @vincentkoc. +- Models: fix antigravity opus 4.6 availability follow-up. (#12845) Thanks @vincentkoc. - Security/Sessions/Telegram: restrict session tool targeting by default to the current session tree (`tools.sessions.visibility`, default `tree`) with sandbox clamping, and pass configured per-account Telegram webhook secrets in webhook mode when no explicit override is provided. Thanks @aether-ai-agent. - CLI/Plugins: ensure `openclaw message send` exits after successful delivery across plugin-backed channels so one-shot sends do not hang. (#16491) Thanks @yinghaosang. - CLI/Plugins: run registered plugin `gateway_stop` hooks before `openclaw message` exits (success and failure paths), so plugin-backed channels can clean up one-shot CLI resources. (#16580) Thanks @gumadeiras. @@ -98,7 +909,6 @@ Docs: https://docs.openclaw.ai - TUI/Gateway: resolve local gateway target URL from `gateway.bind` mode (tailnet/lan) instead of hardcoded localhost so `openclaw tui` connects when gateway is non-loopback. (#16299) Thanks @cortexuvula. - TUI: honor explicit `--session ` in `openclaw tui` even when `session.scope` is `global`, so named sessions no longer collapse into shared global history. (#16575) Thanks @cinqu. - TUI: use available terminal width for session name display in searchable select lists. (#16238) Thanks @robbyczgw-cla. -- TUI: refactor searchable select list description layout and add regression coverage for ANSI-highlight width bounds. - TUI: preserve in-flight streaming replies when a different run finalizes concurrently (avoid clearing active run or reloading history mid-stream). (#10704) Thanks @axschr73. - TUI: keep pre-tool streamed text visible when later tool-boundary deltas temporarily omit earlier text blocks. (#6958) Thanks @KrisKind75. - TUI: sanitize ANSI/control-heavy history text, redact binary-like lines, and split pathological long unbroken tokens before rendering to prevent startup crashes on binary attachment history. (#13007) Thanks @wilkinspoe. @@ -190,7 +1000,6 @@ Docs: https://docs.openclaw.ai - Security/Windows: avoid shell invocation when spawning child processes to prevent cmd.exe metacharacter injection via untrusted CLI arguments (e.g. agent prompt text). - Telegram: set webhook callback timeout handling to `onTimeout: "return"` (10s) so long-running update processing no longer emits webhook 500s and retry storms. (#16763) Thanks @chansearrington. - Signal: preserve case-sensitive `group:` target IDs during normalization so mixed-case group IDs no longer fail with `Group not found`. (#16748) Thanks @repfigit. -- Feishu/Security: harden media URL fetching against SSRF and local file disclosure. (#16285) Thanks @mbelinky. - Security/Agents: scope CLI process cleanup to owned child PIDs to avoid killing unrelated processes on shared hosts. Thanks @aether-ai-agent. - Security/Agents: enforce workspace-root path bounds for `apply_patch` in non-sandbox mode to block traversal and symlink escape writes. Thanks @p80n-sec. - Security/Agents: enforce symlink-escape checks for `apply_patch` delete hunks under `workspaceOnly`, while still allowing deleting the symlink itself. Thanks @p80n-sec. @@ -329,6 +1138,7 @@ Docs: https://docs.openclaw.ai - Tools/web_search: support `freshness` for the Perplexity provider by mapping `pd`/`pw`/`pm`/`py` to Perplexity `search_recency_filter` values and including freshness in the Perplexity cache key. (#15343) Thanks @echoVic. - Clawdock: avoid Zsh readonly variable collisions in helper scripts. (#15501) Thanks @nkelner. - Memory: switch default local embedding model to the QAT `embeddinggemma-300m-qat-Q8_0` variant for better quality at the same footprint. (#15429) Thanks @azade-c. +- Docs/Discord: expand quick setup and clarify guild workspace guidance. (#20088) Thanks @pejmanjohn, @thewilloftheshadow. - Docs/Mermaid: remove hardcoded Mermaid init theme blocks from four docs diagrams so dark mode inherits readable theme defaults. (#15157) Thanks @heytulsiprasad. - Security/Pairing: generate 256-bit base64url device and node pairing tokens and use byte-safe constant-time verification to avoid token-compare edge-case failures. (#16535) Thanks @FaizanKolega, @gumadeiras. @@ -398,6 +1208,7 @@ Docs: https://docs.openclaw.ai - Browser: add Chrome launch flag `--disable-blink-features=AutomationControlled` to reduce `navigator.webdriver` automation detection issues on reCAPTCHA-protected sites. (#10735) Thanks @Milofax. - Heartbeat: filter noise-only system events so scheduled reminder notifications do not fire when cron runs carry only heartbeat markers. (#13317) Thanks @pvtclawn. - Signal: render mention placeholders as `@uuid`/`@phone` so mention gating and Clawdbot targeting work. (#2013) Thanks @alexgleason. +- Agents/Reminders: guard reminder promises by appending a note when no `cron.add` succeeded in the turn, so users know nothing was scheduled. (#18588) Thanks @vignesh07. - Discord: omit empty content fields for media-only messages while preserving caption whitespace. (#9507) Thanks @leszekszpunar. - Onboarding/Providers: add Z.AI endpoint-specific auth choices (`zai-coding-global`, `zai-coding-cn`, `zai-global`, `zai-cn`) and expand default Z.AI model wiring. (#13456) Thanks @tomsun28. - Onboarding/Providers: update MiniMax API default/recommended models from M2.1 to M2.5, add M2.5/M2.5-Lightning model entries, and include `minimax-m2.5` in modern model filtering. (#14865) Thanks @adao-max. @@ -414,13 +1225,6 @@ Docs: https://docs.openclaw.ai - Media: strip `MEDIA:` lines with local paths instead of leaking as visible text. (#14399) Thanks @0xRaini. - Config/Cron: exclude `maxTokens` from config redaction and honor `deleteAfterRun` on skipped cron jobs. (#13342) Thanks @niceysam. - Config: ignore `meta` field changes in config file watcher. (#13460) Thanks @brandonwise. -- Cron: use requested `agentId` for isolated job auth resolution. (#13983) Thanks @0xRaini. -- Cron: pass `agentId` to `runHeartbeatOnce` for main-session jobs. (#14140) Thanks @ishikawa-pro. -- Cron: prevent cron jobs from skipping execution when `nextRunAtMs` advances. (#14068) Thanks @WalterSumbon. -- Cron: re-arm timers when `onTimer` fires while a job is still executing. (#14233) Thanks @tomron87. -- Cron: prevent duplicate fires when multiple jobs trigger simultaneously. (#14256) Thanks @xinhuagu. -- Cron: isolate scheduler errors so one bad job does not break all jobs. (#14385) Thanks @MarvinDontPanic. -- Cron: prevent one-shot `at` jobs from re-firing on restart after skipped/errored runs. (#13878) Thanks @lailoo. - Daemon: suppress `EPIPE` error when restarting LaunchAgent. (#14343) Thanks @0xRaini. - Antigravity: add opus 4.6 forward-compat model and bypass thinking signature sanitization. (#14218) Thanks @jg-noncelogic. - Agents: prevent file descriptor leaks in child process cleanup. (#13565) Thanks @KyleChen26. @@ -441,6 +1245,7 @@ Docs: https://docs.openclaw.ai - Commands: add `commands.allowFrom` config for separate command authorization, allowing operators to restrict slash commands to specific users while keeping chat open to others. (#12430) Thanks @thewilloftheshadow. - Docker: add ClawDock shell helpers for Docker workflows. (#12817) Thanks @Olshansk. +- Gateway: periodic channel health monitor auto-restarts stuck, crashed, or silently-stopped channels. Configurable via `gateway.channelHealthCheckMinutes` (default: 5, set to 0 to disable). (#7053, #4302) - iOS: alpha node app + setup-code onboarding. (#11756) Thanks @mbelinky. - Channels: comprehensive BlueBubbles and channel cleanup. (#11093) Thanks @tyler6204. - Channels: IRC first-class channel support. (#11482) Thanks @vignesh07. @@ -504,6 +1309,7 @@ Docs: https://docs.openclaw.ai - Thinking: allow xhigh for `github-copilot/gpt-5.2-codex` and `github-copilot/gpt-5.2`. (#11646) Thanks @LatencyTDH. - Thinking: honor `/think off` for reasoning-capable models. (#9564) Thanks @liuy. - Discord: support forum/media thread-create starter messages, wire `message thread create --message`, and harden routing. (#10062) Thanks @jarvis89757. +- Discord: download attachments from forwarded messages. (#17049) Thanks @pip-nomel, @thewilloftheshadow. - Paths: structurally resolve `OPENCLAW_HOME`-derived home paths and fix Windows drive-letter handling in tool meta shortening. (#12125) Thanks @mcaxtr. - Memory: set Voyage embeddings `input_type` for improved retrieval. (#10818) Thanks @mcinteerj. - Memory: disable async batch embeddings by default for memory indexing (opt-in via `agents.defaults.memorySearch.remote.batch.enabled`). (#13069) Thanks @mcinteerj. @@ -671,17 +1477,10 @@ Docs: https://docs.openclaw.ai - Discord: route autoThread replies to existing threads instead of the root channel. (#8302) Thanks @gavinbmoore, @thewilloftheshadow. - Media understanding: apply SSRF guardrails to provider fetches; allow private baseUrl overrides explicitly. - fix(voice-call): harden inbound allowlist; reject anonymous callers; require Telnyx publicKey for allowlist; token-gate Twilio media streams; cap webhook body size (thanks @simecek) -- fix(webchat): respect user scroll position during streaming and refresh (#7226) (thanks @marcomarandiz) -- Telegram: recover from grammY long-poll timed out errors. (#7466) Thanks @macmimi23. -- Agents: repair malformed tool calls and session transcripts. (#7473) Thanks @justinhuangcode. -- fix(agents): validate AbortSignal instances before calling AbortSignal.any() (#7277) (thanks @Elarwei001) -- Media understanding: skip binary media from file text extraction. (#7475) Thanks @AlexZhangji. - Onboarding: keep TUI flow exclusive (skip completion prompt + background Web UI seed); completion prompt now handled by install/update. -- TUI: block onboarding output while TUI is active and restore terminal state on exit. - CLI/Zsh completion: cache scripts in state dir and escape option descriptions to avoid invalid option errors. - fix(ui): resolve Control UI asset path correctly. - fix(ui): refresh agent files after external edits. -- Docs: finish renaming the QMD memory docs to reference the OpenClaw state dir. - Tests: stub SSRF DNS pinning in web auto-reply + Gemini video coverage. (#6619) Thanks @joshp123. ## 2026.2.1 @@ -1544,7 +2343,6 @@ Thanks @AlexMikhalev, @CoreyH, @John-Rood, @KrauseFx, @MaudeBot, @Nachx639, @Nic - Heartbeat: tighten prompt guidance + suppress duplicate alerts for 24h. (#980) — thanks @voidserf. - Repo: ignore local identity files to avoid accidental commits. (#1001) — thanks @gerardward2007. - Sessions/Security: add `session.dmScope` for multi-user DM isolation and audit warnings. (#948) — thanks @Alphonse-arianee. -- Plugins: add provider auth registry + `openclaw models auth login` for plugin-driven OAuth/API key flows. - Onboarding: switch channels setup to a single-select loop with per-channel actions and disabled hints in the picker. - TUI: show provider/model labels for the active session and default model. - Heartbeat: add per-agent heartbeat configuration and multi-agent docs example. @@ -2080,7 +2878,6 @@ Thanks @AlexMikhalev, @CoreyH, @John-Rood, @KrauseFx, @MaudeBot, @Nachx639, @Nic - Skills additions (Himalaya email, CodexBar, 1Password). - Dependency refreshes (pi-\* stack, Slack SDK, discord-api-types, file-type, zod, Biome, Vite). -- Refactors: centralized group allowlist/mention policy; lint/import cleanup; switch tsx → bun for TS execution. ## 2026.1.5 diff --git a/CLAUDE.md b/CLAUDE.md index c3170642553..47dc3e3d863 120000 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1 +1 @@ -AGENTS.md +AGENTS.md \ No newline at end of file diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 355fb5c6890..1386bc4881a 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -5,6 +5,7 @@ Welcome to the lobster tank! 🦞 ## Quick Links - **GitHub:** https://github.com/openclaw/openclaw +- **Vision:** [`VISION.md`](VISION.md) - **Discord:** https://discord.gg/qkhbAGHRBT - **X/Twitter:** [@steipete](https://x.com/steipete) / [@openclaw](https://x.com/openclaw) @@ -13,7 +14,7 @@ Welcome to the lobster tank! 🦞 - **Peter Steinberger** - Benevolent Dictator - GitHub: [@steipete](https://github.com/steipete) · X: [@steipete](https://x.com/steipete) -- **Shadow** - Discord subsystem, Discord admin +- **Shadow** - Discord subsystem, Discord admin, Clawhub, all community moderation - GitHub: [@thewilloftheshadow](https://github.com/thewilloftheshadow) · X: [@4shad0wed](https://x.com/4shad0wed) - **Vignesh** - Memory (QMD), formal modeling, TUI, IRC, and Lobster @@ -31,6 +32,12 @@ Welcome to the lobster tank! 🦞 - **Mariano Belinky** - iOS app, Security - GitHub: [@mbelinky](https://github.com/mbelinky) · X: [@belimad](https://x.com/belimad) +- **Vincent Koc** - Agents, Telemetry, Hooks, Security + - GitHub: [@vincentkoc](https://github.com/vincentkoc) · X: [@vincent_koc](https://x.com/vincent_koc) + +- **Val Alexander** - UI/UX, Docs, and Agent DevX + - GitHub: [@BunsDev](https://github.com/BunsDev) · X: [@BunsDev](https://x.com/BunsDev) + - **Seb Slight** - Docs, Agent Reliability, Runtime Hardening - GitHub: [@sebslight](https://github.com/sebslight) · X: [@sebslig](https://x.com/sebslig) @@ -40,18 +47,21 @@ Welcome to the lobster tank! 🦞 - **Gustavo Madeira Santana** - Multi-agents, CLI, web UI - GitHub: [@gumadeiras](https://github.com/gumadeiras) · X: [@gumadeiras](https://x.com/gumadeiras) +- **Onur Solmaz** - Agents, dev workflows, ACP integrations, MS Teams + - GitHub: [@onutc](https://github.com/onutc), [@osolmaz](https://github.com/osolmaz) · X: [@onusoz](https://x.com/onusoz) + ## How to Contribute 1. **Bugs & small fixes** → Open a PR! 2. **New features / architecture** → Start a [GitHub Discussion](https://github.com/openclaw/openclaw/discussions) or ask in Discord first -3. **Questions** → Discord #setup-help +3. **Questions** → Discord [#help](https://discord.com/channels/1456350064065904867/1459642797895319552) / [#users-helping-users](https://discord.com/channels/1456350064065904867/1459007081603403828) ## Before You PR - Test locally with your OpenClaw instance - Run tests: `pnpm build && pnpm check && pnpm test` - Ensure CI checks pass -- Keep PRs focused (one thing per PR) +- Keep PRs focused (one thing per PR; do not mix unrelated concerns) - Describe what & why ## Control UI Decorators @@ -93,6 +103,26 @@ We are currently prioritizing: Check the [GitHub Issues](https://github.com/openclaw/openclaw/issues) for "good first issue" labels! +## Maintainers + +We're selectively expanding the maintainer team. +If you're an experienced contributor who wants to help shape OpenClaw's direction — whether through code, docs, or community — we'd like to hear from you. + +Being a maintainer is a responsibility, not an honorary title. We expect active, consistent involvement — triaging issues, reviewing PRs, and helping move the project forward. + +Still interested? Email contributing@openclaw.ai with: + +- Links to your PRs on OpenClaw (if you don't have any, start there first) +- Links to open source projects you maintain or actively contribute to +- Your GitHub, Discord, and X/Twitter handles +- A brief intro: background, experience, and areas of interest +- Languages you speak and where you're based +- How much time you can realistically commit + +We welcome people across all skill sets — engineering, documentation, community management, and more. +We review every human-only-written application carefully and add maintainers slowly and deliberately. +Please allow a few weeks for a response. + ## Report a Vulnerability We take security reports seriously. Report vulnerabilities directly to the repository where the issue lives: diff --git a/Dockerfile b/Dockerfile index 716ab2099f7..255340cb02b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM node:22-bookworm +FROM node:22-bookworm@sha256:cd7bcd2e7a1e6f72052feb023c7f6b722205d3fcab7bbcbd2d1bfdab10b1e935 # Install Bun (required for build scripts) RUN curl -fsSL https://bun.sh/install | bash @@ -7,6 +7,7 @@ ENV PATH="/root/.bun/bin:${PATH}" RUN corepack enable WORKDIR /app +RUN chown node:node /app ARG OPENCLAW_DOCKER_APT_PACKAGES="" RUN if [ -n "$OPENCLAW_DOCKER_APT_PACKAGES" ]; then \ @@ -16,14 +17,33 @@ RUN if [ -n "$OPENCLAW_DOCKER_APT_PACKAGES" ]; then \ rm -rf /var/lib/apt/lists/* /var/cache/apt/archives/*; \ fi -COPY package.json pnpm-lock.yaml pnpm-workspace.yaml .npmrc ./ -COPY ui/package.json ./ui/package.json -COPY patches ./patches -COPY scripts ./scripts +COPY --chown=node:node package.json pnpm-lock.yaml pnpm-workspace.yaml .npmrc ./ +COPY --chown=node:node ui/package.json ./ui/package.json +COPY --chown=node:node patches ./patches +COPY --chown=node:node scripts ./scripts +USER node RUN pnpm install --frozen-lockfile -COPY . . +# Optionally install Chromium and Xvfb for browser automation. +# Build with: docker build --build-arg OPENCLAW_INSTALL_BROWSER=1 ... +# Adds ~300MB but eliminates the 60-90s Playwright install on every container start. +# Must run after pnpm install so playwright-core is available in node_modules. +USER root +ARG OPENCLAW_INSTALL_BROWSER="" +RUN if [ -n "$OPENCLAW_INSTALL_BROWSER" ]; then \ + apt-get update && \ + DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends xvfb && \ + mkdir -p /home/node/.cache/ms-playwright && \ + PLAYWRIGHT_BROWSERS_PATH=/home/node/.cache/ms-playwright \ + node /app/node_modules/playwright-core/cli.js install --with-deps chromium && \ + chown -R node:node /home/node/.cache/ms-playwright && \ + apt-get clean && \ + rm -rf /var/lib/apt/lists/* /var/cache/apt/archives/*; \ + fi + +USER node +COPY --chown=node:node . . RUN pnpm build # Force pnpm for UI build (Bun may fail on ARM/Synology architectures) ENV OPENCLAW_PREFER_PNPM=1 @@ -31,9 +51,6 @@ RUN pnpm ui:build ENV NODE_ENV=production -# Allow non-root user to write temp files during runtime/tests. -RUN chown -R node:node /app - # Security hardening: Run as non-root user # The node:22-bookworm image includes a 'node' user (uid 1000) # This reduces the attack surface by preventing container escape via root privileges diff --git a/Dockerfile.sandbox b/Dockerfile.sandbox index 21fd321a492..a463d4a1020 100644 --- a/Dockerfile.sandbox +++ b/Dockerfile.sandbox @@ -1,4 +1,4 @@ -FROM debian:bookworm-slim +FROM debian:bookworm-slim@sha256:98f4b71de414932439ac6ac690d7060df1f27161073c5036a7553723881bffbe ENV DEBIAN_FRONTEND=noninteractive diff --git a/Dockerfile.sandbox-browser b/Dockerfile.sandbox-browser index 4eccbc9a1ae..ec9faf71113 100644 --- a/Dockerfile.sandbox-browser +++ b/Dockerfile.sandbox-browser @@ -1,4 +1,4 @@ -FROM debian:bookworm-slim +FROM debian:bookworm-slim@sha256:98f4b71de414932439ac6ac690d7060df1f27161073c5036a7553723881bffbe ENV DEBIAN_FRONTEND=noninteractive diff --git a/PR_STATUS.md b/PR_STATUS.md new file mode 100644 index 00000000000..1887eca27d9 --- /dev/null +++ b/PR_STATUS.md @@ -0,0 +1,78 @@ +# OpenClaw PR Submission Status + +> Auto-maintained by agent team. Last updated: 2026-02-22 + +## PR Plan Overview + +All PRs target upstream `openclaw/openclaw` via fork `kevinWangSheng/openclaw`. +Each PR follows [CONTRIBUTING.md](./CONTRIBUTING.md) and uses the [PR template](./.github/PULL_REQUEST_TEMPLATE.md). + +## Duplicate Check + +Before submission, each PR was cross-referenced against: + +- 100+ open upstream PRs (as of 2026-02-22) +- 50 recently merged PRs +- 50+ open issues + +No overlap found with existing PRs. + +## PR Status Table + +| # | Branch | Title | Type | Status | PR URL | +| --- | -------------------------------------- | --------------------------------------------------------------------------- | -------- | --------------- | --------------------------------------------------------- | +| 1 | `security/redos-safe-regex` | fix(security): add ReDoS protection for user-controlled regex patterns | Security | CI Pass | [#23670](https://github.com/openclaw/openclaw/pull/23670) | +| 2 | `security/session-slug-crypto-random` | fix(security): use crypto.randomInt for session slug generation | Security | CI Pass | [#23671](https://github.com/openclaw/openclaw/pull/23671) | +| 3 | `fix/json-parse-crash-guard` | fix(resilience): guard JSON.parse of external process output with try-catch | Bug fix | CI Pass | [#23672](https://github.com/openclaw/openclaw/pull/23672) | +| 4 | `refactor/console-to-subsystem-logger` | refactor(logging): migrate remaining console calls to subsystem logger | Refactor | CI Pass | [#23669](https://github.com/openclaw/openclaw/pull/23669) | +| 5 | `fix/sanitize-rpc-error-messages` | fix(security): sanitize RPC error messages in signal and imessage clients | Security | CI Pass | [#23724](https://github.com/openclaw/openclaw/pull/23724) | +| 6 | `fix/download-stream-cleanup` | fix(resilience): destroy write streams on download errors | Bug fix | CI Pass | [#23726](https://github.com/openclaw/openclaw/pull/23726) | +| 7 | `fix/telegram-status-reaction-cleanup` | fix(telegram): clear done reaction when removeAckAfterReply is true | Bug fix | CI Pass | [#23728](https://github.com/openclaw/openclaw/pull/23728) | +| 8 | `fix/session-cache-eviction` | fix(memory): add max size eviction to session manager cache | Bug fix | CI Pass (17/17) | [#23744](https://github.com/openclaw/openclaw/pull/23744) | +| 9 | `fix/fetch-missing-timeout` | fix(resilience): add timeout to unguarded fetch calls in browser subsystem | Bug fix | CI Pass (18/18) | [#23745](https://github.com/openclaw/openclaw/pull/23745) | +| 10 | `fix/skills-download-partial-cleanup` | fix(resilience): clean up partial file on skill download failure | Bug fix | CI Pass (19/19) | [#24141](https://github.com/openclaw/openclaw/pull/24141) | +| 11 | `fix/extension-relay-stop-cleanup` | fix(browser): flush pending extension timers on relay stop | Bug fix | CI Pass (20/20) | [#24142](https://github.com/openclaw/openclaw/pull/24142) | + +## Isolation Rules + +- Each agent works on a separate git worktree branch +- No two agents modify the same file +- File ownership: + - PR 1: `src/infra/exec-approval-forwarder.ts`, `src/discord/monitor/exec-approvals.ts` + - PR 2: `src/agents/session-slug.ts` + - PR 3: `src/infra/bonjour-discovery.ts`, `src/infra/outbound/delivery-queue.ts` + - PR 4: `src/infra/tailscale.ts`, `src/node-host/runner.ts` + - PR 5: `src/signal/client.ts`, `src/imessage/client.ts` + - PR 6: `src/media/store.ts`, `src/commands/signal-install.ts` + - PR 7: `src/telegram/bot-message-dispatch.ts` + - PR 8: `src/agents/pi-embedded-runner/session-manager-cache.ts` + - PR 9: `src/cli/nodes-camera.ts`, `src/browser/pw-session.ts` + - PR 10: `src/agents/skills-install-download.ts` + - PR 11: `src/browser/extension-relay.ts` + +## Verification Results + +### Batch 1 (PRs 1-4) — All CI Green + +- PR 1: 17 tests pass, check/build/tests all green +- PR 2: 3 tests pass, check/build/tests all green +- PR 3: 45 tests pass (3 new), check/build/tests all green +- PR 4: 12 tests pass, check/build/tests all green + +### Batch 2 (PRs 5-7) — CI Running + +- PR 5: 3 signal tests pass, check pass, awaiting full test suite +- PR 6: 38 tests pass (20 media + 18 signal-install), check pass, awaiting full suite +- PR 7: 47 tests pass (3 new), check pass, awaiting full suite + +### Batch 3 (PRs 8-9) — All CI Green + +- PR 8 & 9: Initially failed due to pre-existing upstream TS errors + Windows flaky test. Fixed by rebasing onto latest upstream/main and removing `yieldMs: 10` from flaky sandbox test. +- PR 8: 17/17 pass, check/build/tests/windows all green +- PR 9: 18/18 pass, check/build/tests/windows all green + +### Batch 4 (PRs 10-11) — All CI Green + +- PR 10 & 11: Initially failed Windows flaky test (`yieldMs: 10` race). Fixed by removing `yieldMs: 10` from flaky sandbox test (same fix as PRs 8-9). +- PR 10: 19/19 pass, check/build/tests/windows all green +- PR 11: 20/20 pass, check/build/tests/windows all green diff --git a/README.md b/README.md index 40afade0f48..1dcad2b7e12 100644 --- a/README.md +++ b/README.md @@ -23,16 +23,21 @@ It answers you on the channels you already use (WhatsApp, Telegram, Slack, Disco If you want a personal, single-user assistant that feels local, fast, and always-on, this is it. -[Website](https://openclaw.ai) · [Docs](https://docs.openclaw.ai) · [DeepWiki](https://deepwiki.com/openclaw/openclaw) · [Getting Started](https://docs.openclaw.ai/start/getting-started) · [Updating](https://docs.openclaw.ai/install/updating) · [Showcase](https://docs.openclaw.ai/start/showcase) · [FAQ](https://docs.openclaw.ai/start/faq) · [Wizard](https://docs.openclaw.ai/start/wizard) · [Nix](https://github.com/openclaw/nix-openclaw) · [Docker](https://docs.openclaw.ai/install/docker) · [Discord](https://discord.gg/clawd) +[Website](https://openclaw.ai) · [Docs](https://docs.openclaw.ai) · [Vision](VISION.md) · [DeepWiki](https://deepwiki.com/openclaw/openclaw) · [Getting Started](https://docs.openclaw.ai/start/getting-started) · [Updating](https://docs.openclaw.ai/install/updating) · [Showcase](https://docs.openclaw.ai/start/showcase) · [FAQ](https://docs.openclaw.ai/help/faq) · [Wizard](https://docs.openclaw.ai/start/wizard) · [Nix](https://github.com/openclaw/nix-openclaw) · [Docker](https://docs.openclaw.ai/install/docker) · [Discord](https://discord.gg/clawd) Preferred setup: run the onboarding wizard (`openclaw onboard`) in your terminal. The wizard guides you step by step through setting up the gateway, workspace, channels, and skills. The CLI wizard is the recommended path and works on **macOS, Linux, and Windows (via WSL2; strongly recommended)**. Works with npm, pnpm, or bun. New install? Start here: [Getting started](https://docs.openclaw.ai/start/getting-started) +## Sponsors + +| OpenAI | Blacksmith | +| ----------------------------------------------------------------- | ---------------------------------------------------------------------------- | +| [![OpenAI](docs/assets/sponsors/openai.svg)](https://openai.com/) | [![Blacksmith](docs/assets/sponsors/blacksmith.svg)](https://blacksmith.sh/) | + **Subscriptions (OAuth):** -- **[Anthropic](https://www.anthropic.com/)** (Claude Pro/Max) - **[OpenAI](https://openai.com/)** (ChatGPT/Codex) Model note: while any model is supported, I strongly recommend **Anthropic Pro/Max (100/200) + Opus 4.6** for long‑context strength and better prompt‑injection resistance. See [Onboarding](https://docs.openclaw.ai/start/onboarding). @@ -140,13 +145,13 @@ Run `openclaw doctor` to surface risky/misconfigured DM policies. - [Gateway WS control plane](https://docs.openclaw.ai/gateway) with sessions, presence, config, cron, webhooks, [Control UI](https://docs.openclaw.ai/web), and [Canvas host](https://docs.openclaw.ai/platforms/mac/canvas#canvas-a2ui). - [CLI surface](https://docs.openclaw.ai/tools/agent-send): gateway, agent, send, [wizard](https://docs.openclaw.ai/start/wizard), and [doctor](https://docs.openclaw.ai/gateway/doctor). - [Pi agent runtime](https://docs.openclaw.ai/concepts/agent) in RPC mode with tool streaming and block streaming. -- [Session model](https://docs.openclaw.ai/concepts/session): `main` for direct chats, group isolation, activation modes, queue modes, reply-back. Group rules: [Groups](https://docs.openclaw.ai/concepts/groups). +- [Session model](https://docs.openclaw.ai/concepts/session): `main` for direct chats, group isolation, activation modes, queue modes, reply-back. Group rules: [Groups](https://docs.openclaw.ai/channels/groups). - [Media pipeline](https://docs.openclaw.ai/nodes/images): images/audio/video, transcription hooks, size caps, temp file lifecycle. Audio details: [Audio](https://docs.openclaw.ai/nodes/audio). ### Channels - [Channels](https://docs.openclaw.ai/channels): [WhatsApp](https://docs.openclaw.ai/channels/whatsapp) (Baileys), [Telegram](https://docs.openclaw.ai/channels/telegram) (grammY), [Slack](https://docs.openclaw.ai/channels/slack) (Bolt), [Discord](https://docs.openclaw.ai/channels/discord) (discord.js), [Google Chat](https://docs.openclaw.ai/channels/googlechat) (Chat API), [Signal](https://docs.openclaw.ai/channels/signal) (signal-cli), [BlueBubbles](https://docs.openclaw.ai/channels/bluebubbles) (iMessage, recommended), [iMessage](https://docs.openclaw.ai/channels/imessage) (legacy imsg), [Microsoft Teams](https://docs.openclaw.ai/channels/msteams) (extension), [Matrix](https://docs.openclaw.ai/channels/matrix) (extension), [Zalo](https://docs.openclaw.ai/channels/zalo) (extension), [Zalo Personal](https://docs.openclaw.ai/channels/zalouser) (extension), [WebChat](https://docs.openclaw.ai/web/webchat). -- [Group routing](https://docs.openclaw.ai/concepts/group-messages): mention gating, reply tags, per-channel chunking and routing. Channel rules: [Channels](https://docs.openclaw.ai/channels). +- [Group routing](https://docs.openclaw.ai/channels/group-messages): mention gating, reply tags, per-channel chunking and routing. Channel rules: [Channels](https://docs.openclaw.ai/channels). ### Apps + nodes @@ -165,7 +170,7 @@ Run `openclaw doctor` to surface risky/misconfigured DM policies. ### Runtime + safety -- [Channel routing](https://docs.openclaw.ai/concepts/channel-routing), [retry policy](https://docs.openclaw.ai/concepts/retry), and [streaming/chunking](https://docs.openclaw.ai/concepts/streaming). +- [Channel routing](https://docs.openclaw.ai/channels/channel-routing), [retry policy](https://docs.openclaw.ai/concepts/retry), and [streaming/chunking](https://docs.openclaw.ai/concepts/streaming). - [Presence](https://docs.openclaw.ai/concepts/presence), [typing indicators](https://docs.openclaw.ai/concepts/typing-indicators), and [usage tracking](https://docs.openclaw.ai/concepts/usage-tracking). - [Models](https://docs.openclaw.ai/concepts/models), [model failover](https://docs.openclaw.ai/concepts/model-failover), and [session pruning](https://docs.openclaw.ai/concepts/session-pruning). - [Security](https://docs.openclaw.ai/gateway/security) and [troubleshooting](https://docs.openclaw.ai/channels/troubleshooting). @@ -497,53 +502,54 @@ Special thanks to Adam Doppelt for lobster.bot. Thanks to all clawtributors:

- steipete joshp123 cpojer Mariano Belinky sebslight Takhoffman quotentiroler bohdanpodvirnyi tyler6204 iHildy - jaydenfyi gumadeiras joaohlisboa mneves75 MatthieuBizien Glucksberg MaudeBot rahthakor vrknetha vignesh07 - radek-paclt abdelsfane Tobias Bischoff christianklotz czekaj ethanpalm mukhtharcm maxsumrall rodrigouroz xadenryan - VACInc juanpablodlc conroywhitney hsrvc magimetal zerone0x advaitpaliwal meaningfool patelhiren NicholasSpisak - jonisjongithub abhisekbasu1 theonejvo jamesgroat BunsDev claude JustYannicc Hyaxia dantelex SocialNerd42069 - daveonkels Yida-Dev google-labs-jules[bot] riccardogiorato lc0rp adam91holt mousberg clawdinator[bot] hougangdev shakkernerd - coygeek mteam88 hirefrank M00N7682 joeynyc orlyjamie dbhurley Eng. Juan Combetto TSavo aerolalit - julianengel bradleypriest benithors lsh411 gut-puncture rohannagpal timolins f-trycua benostein elliotsecops - nachx639 pvoo sreekaransrinath gupsammy cristip73 stefangalescu nachoiacovino Vasanth Rao Naik Sabavat thewilloftheshadow petter-b - leszekszpunar scald pycckuu AnonO6 andranik-sahakyan davidguttman jarvis89757 sleontenko denysvitali TinyTb - sircrumpet peschee nicolasstanley davidiach nonggia.liang ironbyte-rgb dominicnunez lploc94 ratulsarna sfo2001 - lutr0 kiranjd danielz1z Iranb cdorsey AdeboyeDN obviyus Alg0rix papago2355 peetzweg/ - emanuelst evanotero KristijanJovanovski jlowin rdev rhuanssauro joshrad-dev osolmaz adityashaw2 shadril238 - CashWilliams sheeek ryan jasonsschin artuskg onutc pauloportella HirokiKobayashi-R ThanhNguyxn 18-RAJAT - kimitaka yuting0624 neooriginal manuelhettich unisone baccula manikv12 sbking travisirby fujiwara-tofu-shop - buddyh connorshea bjesuiter kyleok mcinteerj slonce70 calvin-hpnet gitpds ide-rea badlogic - grp06 dependabot[bot] amitbiswal007 John-Rood timkrase gerardward2007 roshanasingh4 tosh-hamburg azade-c dlauer - ezhikkk JonUleis shivamraut101 cheeeee jabezborja robbyczgw-cla YuriNachos Josh Phillips Wangnov kaizen403 - patrickshao Whoaa512 chriseidhof ngutman wangai-studio ysqander Yurii Chukhlib aj47 kennyklee superman32432432 - Hisleren antons austinm911 blacksmith-sh[bot] damoahdominic dan-dr doodlewind GHesericsu HeimdallStrategy imfing - jalehman jarvis-medmatic kkarimi Lukavyi mahmoudashraf93 pkrmf RandyVentures Ryan Lisse Yeom-JinHo dougvk - erikpr1994 fal3 Ghost hyf0-agent jonasjancarik Keith the Silly Goose L36 Server Marc mitschabaude-bot mkbehr - neist orenyomtov sibbl zats abhijeet117 chrisrodz Friederike Seiler gabriel-trigo hudson-rivera iamadig - itsjling Jonathan D. Rhyne (DJ-D) Joshua Mitchell kelvinCB Kit koala73 lailoo manmal mattqdev mcaxtr - mitsuhiko ogulcancelik petradonka rubyrunsstuff rybnikov siddhantjain suminhthanh svkozak wes-davis 24601 - ameno- bonald bravostation Chris Taylor damaozi dguido Django Navarro evalexpr henrino3 humanwritten - j2h4u larlyssa liuxiaopai-ai odysseus0 oswalpalash pcty-nextgen-service-account pi0 rmorse Roopak Nijhara Syhids - tmchow Ubuntu xiaose Aaron Konyer aaronveklabs akramcodez aldoeliacim andreabadesso Andrii BinaryMuse - bqcfjwhz85-arch cash-echo-bot Clawd ClawdFx danballance danielcadenhead Elarwei001 EnzeD erik-agens Evizero - fcatuhe gildo hclsys itsjaydesu ivancasco ivanrvpereira Jarvis jayhickey jeffersonwarrior jeffersonwarrior - jverdi longmaba Marco Marandiz MarvinCui mattezell mjrussell odnxe optimikelabs p6l-richard philipp-spiess - Pocket Clawd RayBB robaxelsen Sash Catanzarite Suksham-sharma T5-AndyML thejhinvirtuoso travisp VAC william arzt - yudshj zknicker 0oAstro Abdul535 abhaymundhara aduk059 aisling404 alejandro maza Alex-Alaniz alexanderatallah - alexstyl AlexZhangji andrewting19 anpoirier araa47 arthyn Asleep123 Ayush Ojha Ayush10 bguidolim - bolismauro caelum0x championswimmer chenyuan99 Chloe-VP Claude Code Clawdbot Maintainers conhecendoia dasilva333 David-Marsh-Photo - deepsoumya617 Developer Dimitrios Ploutarchos Drake Thomsen dvrshil dxd5001 dylanneve1 Felix Krause foeken frankekn - fredheir Fronut ganghyun kim grrowl gtsifrikas HassanFleyah HazAT hrdwdmrbl hugobarauna iamEvanYT - ichbinlucaskim Jamie Openshaw Jane Jarvis Deploy Jefferson Nunn jogi47 kentaro Kevin Lin kira-ariaki kitze - Kiwitwitter kossoy levifig liuy Lloyd loganaden longjos loukotal mac mimi markusbkoch - martinpucik Matt mini mertcicekci0 Miles minghinmatthewlam mrdbstn MSch mudrii Mustafa Tag Eldeen myfunc - mylukin nathanbosse ndraiman nexty5870 Noctivoro Omar-Khaleel ozgur-polat pasogott plum-dawg pookNast - ppamment prathamdby ptn1411 rafaelreis-r rafelbev reeltimeapps RLTCmpe robhparker rohansachinpatil Rony Kelner - ryancnelson Samrat Jha seans-openclawbot senoldogann Seredeep sergical shatner shiv19 shiyuanhai Shrinija17 - siraht snopoke spiceoogway stephenchen2025 succ985 Suvink techboss testingabc321 tewatia The Admiral - therealZpoint-bot thesash uos-status vcastellm Vibe Kanban vincentkoc void Vultr-Clawd Admin Wimmie wolfred - wstock wytheme YangHuang2280 yazinsai yevhen YiWang24 ymat19 Zach Knickerbocker zackerthescar zhixian - 0xJonHoldsCrypto aaronn Alphonse-arianee atalovesyou Azade carlulsoe ddyo Erik jiulingyun latitudeki5223 - Manuel Maly minghinmatthewlam Mourad Boustani odrobnik pcty-nextgen-ios-builder Quentin rafaelreis-r Randy Torres rhjoh Rolf Fredheim - ronak-guliani William Stock + steipete sktbrd cpojer joshp123 Mariano Belinky Takhoffman sebslight tyler6204 quotentiroler Verite Igiraneza + gumadeiras bohdanpodvirnyi vincentkoc iHildy jaydenfyi Glucksberg joaohlisboa rodrigouroz mneves75 BunsDev + MatthieuBizien MaudeBot vignesh07 smartprogrammer93 advaitpaliwal HenryLoenwind rahthakor vrknetha abdelsfane radek-paclt + joshavant christianklotz mudrii zerone0x ranausmanai Tobias Bischoff heyhudson czekaj ethanpalm yinghaosang + nabbilkhan mukhtharcm aether-ai-agent coygeek Mrseenz maxsumrall xadenryan VACInc juanpablodlc conroywhitney + Harald Buerbaumer akoscz Bridgerz hsrvc magimetal openclaw-bot meaningfool JustasM Phineas1500 ENCHIGO + Hiren Patel NicholasSpisak claude jonisjongithub theonejvo abhisekbasu1 Ryan Haines Blakeshannon jamesgroat Marvae + arosstale shakkernerd gejifeng divanoli ryan-crabbe nyanjou Sam Padilla dantelex SocialNerd42069 solstead + natefikru daveonkels LeftX Yida-Dev Masataka Shinohara Lewis riccardogiorato lc0rp adam91holt mousberg + BillChirico shadril238 CharlieGreenman hougangdev Mars orlyjamie McRolly NWANGWU LI SHANXIN Simone Macario durenzidu + JustYannicc Minidoracat magendary Jessy LANGE mteam88 brandonwise hirefrank M00N7682 dbhurley Eng. Juan Combetto + Harrington-bot TSavo Lalit Singh julianengel Jay Caldwell Kirill Shchetynin nachx639 bradleypriest TsekaLuk benithors + Shailesh thewilloftheshadow jackheuberger loiie45e El-Fitz benostein pvtclawn 0xRaini ruypang xinhuagu + Taylor Asplund adhitShet Paul van Oorschot sreekaransrinath buddyh gupsammy AI-Reviewer-QS Stefan Galescu WalterSumbon nachoiacovino + rodbland2021 Vasanth Rao Naik Sabavat fagemx petter-b omair445 dorukardahan leszekszpunar Clawborn davidrudduck scald + Igor Markelov rrenamed Parker Todd Brooks AnonO6 Tanwa Arpornthip andranik-sahakyan davidguttman sleontenko denysvitali Tom Ron + popomore Patrick Barletta shayan919293 不做了睡大觉 Luis Conde Harry Cui Kepler SidQin-cyber Lucky Michael Lee sircrumpet + peschee dakshaymehta davidiach nonggia.liang seheepeak obviyus danielwanwx osolmaz minupla misterdas + Shuai-DaiDai dominicnunez lploc94 sfo2001 lutr0 dirbalak cathrynlavery Joly0 kiranjd niceysam + danielz1z Iranb carrotRakko Oceanswave cdorsey AdeboyeDN j2h4u Alg0rix Skyler Miao peetzweg/ + TideFinder CornBrother0x DukeDeSouth emanuelst bsormagec Diaspar4u evanotero Nate OscarMinjarez webvijayi + garnetlyx miloudbelarebia Jeremiah Lowin liebertar Max rhuanssauro joshrad-dev adityashaw2 CashWilliams taw0002 + asklee-klawd h0tp-ftw constansino mcaxtr onutc ryan unisone artuskg Solvely-Colin pahdo + Kimitaka Watanabe Lilo Rajat Joshi Yuting Lin Neo wu-tian807 ngutman crimeacs manuelhettich mcinteerj + bjesuiter Manik Vahsith alexgleason Nicholas Stephen Brian King justinhuangcode mahanandhi andreesg connorshea dinakars777 + Flash-LHR JINNYEONG KIM Protocol Zero kyleok Limitless grp06 robbyczgw-cla slonce70 JayMishra-source ide-rea + lailoo badlogic echoVic amitbiswal007 azade-c John Rood dddabtc Jonathan Works roshanasingh4 tosh-hamburg + dlauer ezhikkk Shivam Kumar Raut Mykyta Bozhenko YuriNachos Josh Phillips ThomsenDrake Wangnov akramcodez jadilson12 + Whoaa512 clawdinator[bot] emonty kaizen403 chriseidhof Lukavyi wangai-studio ysqander aj47 google-labs-jules[bot] + hyf0-agent Jeremy Mumford Kenny Lee superman32432432 widingmarcus-cyber DylanWoodAkers antons austinm911 boris721 damoahdominic + dan-dr doodlewind GHesericsu HeimdallStrategy imfing jalehman jarvis-medmatic kkarimi mahmoudashraf93 pkrmf + Randy Torres sumleo Yeom-JinHo akyourowngames aldoeliacim Dithilli dougvk erikpr1994 fal3 jonasjancarik + koala73 mitschabaude-bot mkbehr Oren shtse8 sibbl thesomewhatyou zats chrisrodz frankekn + gabriel-trigo ghsmc iamadig ibrahimq21 irtiq7 jeann2013 jogelin Jonathan D. Rhyne (DJ-D) Justin Ling kelvinCB + manmal Matthew MattQ Milofax mitsuhiko neist pejmanjohn ProspectOre rmorse rubyrunsstuff + rybnikov santiagomed Steve (OpenClaw) suminhthanh svkozak wes-davis 24601 AkashKobal ameno- awkoy + battman21 BinHPdev bonald dashed dawondyifraw dguido Django Navarro evalexpr henrino3 humanwritten + hyojin joeykrug larlyssa liuy Mark Liu natedenh odysseus0 pcty-nextgen-service-account pi0 Syhids + tmchow uli-will-code aaronveklabs andreabadesso BinaryMuse cash-echo-bot CJWTRUST cordx56 danballance Elarwei001 + EnzeD erik-agens Evizero fcatuhe gildo Grynn huntharo hydro13 itsjaydesu ivanrvpereira + jverdi kentaro loeclos longmaba MarvinCui MisterGuy420 mjrussell odnxe optimikelabs oswalpalash + p6l-richard philipp-spiess RamiNoodle733 Raymond Berger Rob Axelsen sauerdaniel SleuthCo T5-AndyML TaKO8Ki thejhinvirtuoso + travisp yudshj zknicker 0oAstro 8BlT Abdul535 abhaymundhara aduk059 afurm aisling404 + akari-musubi Alex-Alaniz alexanderatallah alexstyl andrewting19 araa47 Asleep123 Ayush10 bennewton999 bguidolim + caelum0x championswimmer Chloe-VP dario-github DarwinsBuddy David-Marsh-Photo dcantu96 dndodson dvrshil dxd5001 + dylanneve1 EmberCF ephraimm ereid7 eternauta1337 foeken gtsifrikas HazAT iamEvanYT ikari-pl + kesor knocte MackDing nobrainer-tech Noctivoro Olshansk Pratham Dubey Raikan10 SecondThread Swader + testingabc321 0xJonHoldsCrypto aaronn Alphonse-arianee atalovesyou carlulsoe hrdwdmrbl hugobarauna jayhickey jiulingyun + kitze latitudeki5223 loukotal minghinmatthewlam MSch odrobnik rafaelreis-r ratulsarna reeltimeapps rhjoh + ronak-guliani snopoke thesash timkrase

diff --git a/SECURITY.md b/SECURITY.md index 63440837047..378eceaff91 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -13,7 +13,7 @@ Report vulnerabilities directly to the repository where the issue lives: - **ClawHub** — [openclaw/clawhub](https://github.com/openclaw/clawhub) - **Trust and threat model** — [openclaw/trust](https://github.com/openclaw/trust) -For issues that don't fit a specific repo, or if you're unsure, email **security@openclaw.ai** and we'll route it. +For issues that don't fit a specific repo, or if you're unsure, email **[security@openclaw.ai](mailto:security@openclaw.ai)** and we'll route it. For full reporting instructions see our [Trust page](https://trust.openclaw.ai). @@ -30,6 +30,40 @@ For full reporting instructions see our [Trust page](https://trust.openclaw.ai). Reports without reproduction steps, demonstrated impact, and remediation advice will be deprioritized. Given the volume of AI-generated scanner findings, we must ensure we're receiving vetted reports from researchers who understand the issues. +### Report Acceptance Gate (Triage Fast Path) + +For fastest triage, include all of the following: + +- Exact vulnerable path (`file`, function, and line range) on a current revision. +- Tested version details (OpenClaw version and/or commit SHA). +- Reproducible PoC against latest `main` or latest released version. +- Demonstrated impact tied to OpenClaw's documented trust boundaries. +- For exposed-secret reports: proof the credential is OpenClaw-owned (or grants access to OpenClaw-operated infrastructure/services). +- Explicit statement that the report does not rely on adversarial operators sharing one gateway host/config. +- Scope check explaining why the report is **not** covered by the Out of Scope section below. + +Reports that miss these requirements may be closed as `invalid` or `no-action`. + +### Common False-Positive Patterns + +These are frequently reported but are typically closed with no code change: + +- Prompt-injection-only chains without a boundary bypass (prompt injection is out of scope). +- Operator-intended local features (for example TUI local `!` shell) presented as remote injection. +- Authorized user-triggered local actions presented as privilege escalation. Example: an allowlisted/owner sender running `/export-session /absolute/path.html` to write on the host. In this trust model, authorized user actions are trusted host actions unless you demonstrate an auth/sandbox/boundary bypass. +- Reports that assume per-user multi-tenant authorization on a shared gateway host/config. +- ReDoS/DoS claims that require trusted operator configuration input (for example catastrophic regex in `sessionFilter` or `logging.redactPatterns`) without a trust-boundary bypass. +- Missing HSTS findings on default local/loopback deployments. +- Slack webhook signature findings when HTTP mode already uses signing-secret verification. +- Discord inbound webhook signature findings for paths not used by this repo's Discord integration. +- Scanner-only claims against stale/nonexistent paths, or claims without a working repro. + +### Duplicate Report Handling + +- Search existing advisories before filing. +- Include likely duplicate GHSA IDs in your report when applicable. +- Maintainers may close lower-quality/later duplicates in favor of the earliest high-quality canonical report. + ## Security & Trust **Jamieson O'Reilly** ([@theonejvo](https://twitter.com/theonejvo)) is Security & Trust at OpenClaw. Jamieson is the founder of [Dvuln](https://dvuln.com) and brings extensive experience in offensive security, penetration testing, and security program development. @@ -43,11 +77,87 @@ The best way to help the project right now is by sending PRs. When patching a GHSA via `gh api`, include `X-GitHub-Api-Version: 2022-11-28` (or newer). Without it, some fields (notably CVSS) may not persist even if the request returns 200. +## Operator Trust Model (Important) + +OpenClaw does **not** model one gateway as a multi-tenant, adversarial user boundary. + +- Authenticated Gateway callers are treated as trusted operators for that gateway instance. +- Session identifiers (`sessionKey`, session IDs, labels) are routing controls, not per-user authorization boundaries. +- If one operator can view data from another operator on the same gateway, that is expected in this trust model. +- OpenClaw can technically run multiple gateway instances on one machine, but recommended operations are clean separation by trust boundary. +- Recommended mode: one user per machine/host (or VPS), one gateway for that user, and one or more agents inside that gateway. +- If multiple users need OpenClaw, use one VPS (or host/OS user boundary) per user. +- For advanced setups, multiple gateways on one machine are possible, but only with strict isolation and are not the recommended default. +- Exec behavior is host-first by default: `agents.defaults.sandbox.mode` defaults to `off`. +- `tools.exec.host` defaults to `sandbox` as a routing preference, but if sandbox runtime is not active for the session, exec runs on the gateway host. +- Implicit exec calls (no explicit host in the tool call) follow the same behavior. +- This is expected in OpenClaw's one-user trusted-operator model. If you need isolation, enable sandbox mode (`non-main`/`all`) and keep strict tool policy. + ## Out of Scope - Public Internet Exposure - Using OpenClaw in ways that the docs recommend not to -- Prompt injection attacks +- Deployments where mutually untrusted/adversarial operators share one gateway host and config (for example, reports expecting per-operator isolation for `sessions.list`, `sessions.preview`, `chat.history`, or similar control-plane reads) +- Prompt-injection-only attacks (without a policy/auth/sandbox boundary bypass) +- Reports that require write access to trusted local state (`~/.openclaw`, workspace files like `MEMORY.md` / `memory/*.md`) +- Reports where the only demonstrated impact is an already-authorized sender intentionally invoking a local-action command (for example `/export-session` writing to an absolute host path) without bypassing auth, sandbox, or another documented boundary +- Any report whose only claim is that an operator-enabled `dangerous*`/`dangerously*` config option weakens defaults (these are explicit break-glass tradeoffs by design) +- Reports that depend on trusted operator-supplied configuration values to trigger availability impact (for example custom regex patterns). These may still be fixed as defense-in-depth hardening, but are not security-boundary bypasses. +- Exposed secrets that are third-party/user-controlled credentials (not OpenClaw-owned and not granting access to OpenClaw-operated infrastructure/services) without demonstrated OpenClaw impact +- Reports whose only claim is host-side exec when sandbox runtime is disabled/unavailable (documented default behavior in the trusted-operator model), without a boundary bypass. + +## Deployment Assumptions + +OpenClaw security guidance assumes: + +- The host where OpenClaw runs is within a trusted OS/admin boundary. +- Anyone who can modify `~/.openclaw` state/config (including `openclaw.json`) is effectively a trusted operator. +- A single Gateway shared by mutually untrusted people is **not a recommended setup**. Use separate gateways (or at minimum separate OS users/hosts) per trust boundary. +- Authenticated Gateway callers are treated as trusted operators. Session identifiers (for example `sessionKey`) are routing controls, not per-user authorization boundaries. +- Multiple gateway instances can run on one machine, but the recommended model is clean per-user isolation (prefer one host/VPS per user). + +## One-User Trust Model (Personal Assistant) + +OpenClaw's security model is "personal assistant" (one trusted operator, potentially many agents), not "shared multi-tenant bus." + +- If multiple people can message the same tool-enabled agent (for example a shared Slack workspace), they can all steer that agent within its granted permissions. +- Session or memory scoping reduces context bleed, but does **not** create per-user host authorization boundaries. +- For mixed-trust or adversarial users, isolate by OS user/host/gateway and use separate credentials per boundary. +- A company-shared agent can be a valid setup when users are in the same trust boundary and the agent is strictly business-only. +- For company-shared setups, use a dedicated machine/VM/container and dedicated accounts; avoid mixing personal data on that runtime. +- If that host/browser profile is logged into personal accounts (for example Apple/Google/personal password manager), you have collapsed the boundary and increased personal-data exposure risk. + +## Agent and Model Assumptions + +- The model/agent is **not** a trusted principal. Assume prompt/content injection can manipulate behavior. +- Security boundaries come from host/config trust, auth, tool policy, sandboxing, and exec approvals. +- Prompt injection by itself is not a vulnerability report unless it crosses one of those boundaries. + +## Gateway and Node trust concept + +OpenClaw separates routing from execution, but both remain inside the same operator trust boundary: + +- **Gateway** is the control plane. If a caller passes Gateway auth, they are treated as a trusted operator for that Gateway. +- **Node** is an execution extension of the Gateway. Pairing a node grants operator-level remote capability on that node. +- **Exec approvals** (allowlist/ask UI) are operator guardrails to reduce accidental command execution, not a multi-tenant authorization boundary. +- For untrusted-user isolation, split by trust boundary: separate gateways and separate OS users/hosts per boundary. + +## Workspace Memory Trust Boundary + +`MEMORY.md` and `memory/*.md` are plain workspace files and are treated as trusted local operator state. + +- If someone can edit workspace memory files, they already crossed the trusted operator boundary. +- Memory search indexing/recall over those files is expected behavior, not a sandbox/security boundary. +- Example report pattern considered out of scope: "attacker writes malicious content into `memory/*.md`, then `memory_search` returns it." +- If you need isolation between mutually untrusted users, split by OS user or host and run separate gateways. + +## Plugin Trust Boundary + +Plugins/extensions are loaded **in-process** with the Gateway and are treated as trusted code. + +- Plugins can execute with the same OS privileges as the OpenClaw process. +- Runtime helpers (for example `runtime.system.runCommandWithTimeout`) are convenience APIs, not a sandbox boundary. +- Only install plugins you trust, and prefer `plugins.allow` to pin explicit trusted plugin ids. ## Operational Guidance @@ -68,6 +178,14 @@ OpenClaw's web interface (Gateway Control UI + HTTP endpoints) is intended for * - Recommended: keep the Gateway **loopback-only** (`127.0.0.1` / `::1`). - Config: `gateway.bind="loopback"` (default). - CLI: `openclaw gateway run --bind loopback`. +- `gateway.controlUi.dangerouslyDisableDeviceAuth` is intended for localhost-only break-glass use. + - OpenClaw keeps deployment flexibility by design and does not hard-forbid non-local setups. + - Non-local and other risky configurations are surfaced by `openclaw security audit` as dangerous findings. + - This operator-selected tradeoff is by design and not, by itself, a security vulnerability. +- Canvas host note: network-visible canvas is **intentional** for trusted node scenarios (LAN/tailnet). + - Expected setup: non-loopback bind + Gateway auth (token/password/trusted-proxy) + firewall/tailnet controls. + - Expected routes: `/__openclaw__/canvas/`, `/__openclaw__/a2ui/`. + - This deployment model alone is not a security vulnerability. - Do **not** expose it to the public internet (no direct bind to `0.0.0.0`, no public reverse proxy). It is not hardened for public exposure. - If you need remote access, prefer an SSH tunnel or Tailscale serve/funnel (so the Gateway still binds to loopback), plus strong Gateway auth. - The Gateway HTTP surface includes the canvas host (`/__openclaw__/canvas/`, `/__openclaw__/a2ui/`). Treat canvas content as sensitive/untrusted and avoid exposing it beyond loopback unless you understand the risk. diff --git a/VISION.md b/VISION.md new file mode 100644 index 00000000000..4ff70189ab8 --- /dev/null +++ b/VISION.md @@ -0,0 +1,110 @@ +## OpenClaw Vision + +OpenClaw is the AI that actually does things. +It runs on your devices, in your channels, with your rules. + +This document explains the current state and direction of the project. +We are still early, so iteration is fast. +Project overview and developer docs: [`README.md`](README.md) +Contribution guide: [`CONTRIBUTING.md`](CONTRIBUTING.md) + +OpenClaw started as a personal playground to learn AI and build something genuinely useful: +an assistant that can run real tasks on a real computer. +It evolved through several names and shells: Warelay -> Clawdbot -> Moltbot -> OpenClaw. + +The goal: a personal assistant that is easy to use, supports a wide range of platforms, and respects privacy and security. + +The current focus is: + +Priority: + +- Security and safe defaults +- Bug fixes and stability +- Setup reliability and first-run UX + +Next priorities: + +- Supporting all major model providers +- Improving support for major messaging channels (and adding a few high-demand ones) +- Performance and test infrastructure +- Better computer-use and agent harness capabilities +- Ergonomics across CLI and web frontend +- Companion apps on macOS, iOS, Android, Windows, and Linux + +Contribution rules: + +- One PR = one issue/topic. Do not bundle multiple unrelated fixes/features. +- PRs over ~5,000 changed lines are reviewed only in exceptional circumstances. +- Do not open large batches of tiny PRs at once; each PR has review cost. +- For very small related fixes, grouping into one focused PR is encouraged. + +## Security + +Security in OpenClaw is a deliberate tradeoff: strong defaults without killing capability. +The goal is to stay powerful for real work while making risky paths explicit and operator-controlled. + +Canonical security policy and reporting: + +- [`SECURITY.md`](SECURITY.md) + +We prioritize secure defaults, but also expose clear knobs for trusted high-power workflows. + +## Plugins & Memory + +OpenClaw has an extensive plugin API. +Core stays lean; optional capability should usually ship as plugins. + +Preferred plugin path is npm package distribution plus local extension loading for development. +If you build a plugin, host and maintain it in your own repository. +The bar for adding optional plugins to core is intentionally high. +Plugin docs: [`docs/tools/plugin.md`](docs/tools/plugin.md) +Community plugin listing + PR bar: https://docs.openclaw.ai/plugins/community + +Memory is a special plugin slot where only one memory plugin can be active at a time. +Today we ship multiple memory options; over time we plan to converge on one recommended default path. + +### Skills + +We still ship some bundled skills for baseline UX. +New skills should be published to ClawHub first (`clawhub.ai`), not added to core by default. +Core skill additions should be rare and require a strong product or security reason. + +### MCP Support + +OpenClaw supports MCP through `mcporter`: https://github.com/steipete/mcporter + +This keeps MCP integration flexible and decoupled from core runtime: + +- add or change MCP servers without restarting the gateway +- keep core tool/context surface lean +- reduce MCP churn impact on core stability and security + +For now, we prefer this bridge model over building first-class MCP runtime into core. +If there is an MCP server or feature `mcporter` does not support yet, please open an issue there. + +### Setup + +OpenClaw is currently terminal-first by design. +This keeps setup explicit: users see docs, auth, permissions, and security posture up front. + +Long term, we want easier onboarding flows as hardening matures. +We do not want convenience wrappers that hide critical security decisions from users. + +### Why TypeScript? + +OpenClaw is primarily an orchestration system: prompts, tools, protocols, and integrations. +TypeScript was chosen to keep OpenClaw hackable by default. +It is widely known, fast to iterate in, and easy to read, modify, and extend. + +## What We Will Not Merge (For Now) + +- New core skills when they can live on ClawHub +- Full-doc translation sets for all docs (deferred; we plan AI-generated translations later) +- Commercial service integrations that do not clearly fit the model-provider category +- Wrapper channels around already supported channels without a clear capability or security gap +- First-class MCP runtime in core when `mcporter` already provides the integration path +- Agent-hierarchy frameworks (manager-of-managers / nested planner trees) as a default architecture +- Heavy orchestration layers that duplicate existing agent and tool infrastructure + +This list is a roadmap guardrail, not a law of physics. +Strong user demand and strong technical rationale can change it. diff --git a/appcast.xml b/appcast.xml index 2b3ed8fcc9a..0f8acfe3a3a 100644 --- a/appcast.xml +++ b/appcast.xml @@ -144,7 +144,7 @@ 2026.2.15 Mon, 16 Feb 2026 05:04:34 +0100 https://raw.githubusercontent.com/openclaw/openclaw/main/appcast.xml - 11213 + 202602150 2026.2.15 15.0 OpenClaw 2026.2.15 @@ -209,105 +209,251 @@ - 2026.2.13 - Sat, 14 Feb 2026 04:30:23 +0100 + 2026.2.22 + Mon, 23 Feb 2026 01:51:13 +0100 https://raw.githubusercontent.com/openclaw/openclaw/main/appcast.xml - 9846 - 2026.2.13 + 14126 + 2026.2.22 15.0 - OpenClaw 2026.2.13 + OpenClaw 2026.2.22

Changes

    -
  • Discord: send voice messages with waveform previews from local audio files (including silent delivery). (#7253) Thanks @nyanjou.
  • -
  • Discord: add configurable presence status/activity/type/url (custom status defaults to activity text). (#10855) Thanks @h0tp-ftw.
  • -
  • Slack/Plugins: add thread-ownership outbound gating via message_sending hooks, including @-mention bypass tracking and Slack outbound hook wiring for cancel/modify behavior. (#15775) Thanks @DarlingtonDeveloper.
  • -
  • Agents: add synthetic catalog support for hf:zai-org/GLM-5. (#15867) Thanks @battman21.
  • -
  • Skills: remove duplicate local-places Google Places skill/proxy and keep goplaces as the single supported Google Places path.
  • -
  • Agents: add pre-prompt context diagnostics (messages, systemPromptChars, promptChars, provider/model, session file) before embedded runner prompt calls to improve overflow debugging. (#8930) Thanks @Glucksberg.
  • +
  • Provider/Mistral: add support for the Mistral provider, including memory embeddings and voice support. (#23845) Thanks @vincentkoc.
  • +
  • Update/Core: add an optional built-in auto-updater for package installs (update.auto.*), default-off, with stable rollout delay+jitter and beta hourly cadence.
  • +
  • CLI/Update: add openclaw update --dry-run to preview channel/tag/target/restart actions without mutating config, installing, syncing plugins, or restarting.
  • +
  • Config/UI: add tag-aware settings filtering and broaden config labels/help copy so fields are easier to discover and understand in the dashboard config screen.
  • +
  • Channels/Synology Chat: add a native Synology Chat channel plugin with webhook ingress, direct-message routing, outbound send/media support, per-account config, and DM policy controls. (#23012)
  • +
  • iOS/Talk: prefetch TTS segments and suppress expected speech-cancellation errors for smoother talk playback. (#22833) Thanks @ngutman.
  • +
  • Memory/FTS: add Spanish and Portuguese stop-word filtering for query expansion in FTS-only search mode, improving conversational recall for both languages. Thanks @vincentkoc.
  • +
  • Memory/FTS: add Japanese-aware query expansion tokenization and stop-word filtering (including mixed-script terms like ASCII + katakana) for FTS-only search mode. Thanks @vincentkoc.
  • +
  • Memory/FTS: add Korean stop-word filtering and particle-aware keyword extraction (including mixed Korean/English stems) for query expansion in FTS-only search mode. (#18899) Thanks @ruypang.
  • +
  • Memory/FTS: add Arabic stop-word filtering for query expansion in FTS-only search mode to reduce conversational filler in Arabic memory searches. Thanks @vincentkoc.
  • +
  • Discord/Allowlist: canonicalize resolved Discord allowlist names to IDs and split resolution flow for clearer fail-closed behavior.
  • +
  • Channels/Config: unify channel preview streaming config handling with a shared resolver and canonical migration path.
  • +
  • Gateway/Auth: unify call/probe/status/auth credential-source precedence on shared resolver helpers, with table-driven parity coverage across gateway entrypoints.
  • +
  • Gateway/Auth: refactor gateway credential resolution and websocket auth handshake paths to use shared typed auth contexts, including explicit auth.deviceToken support in connect frames and tests.
  • +
  • Skills: remove bundled food-order skill from this repo; manage/install it from ClawHub instead.
  • +
  • Docs/Subagents: make thread-bound session guidance channel-first instead of Discord-specific, and list thread-supporting channels explicitly. (#23589) Thanks @osolmaz.
  • +
+

Breaking

+
    +
  • BREAKING: tool-failure replies now hide raw error details by default. OpenClaw still sends a failure summary, but detailed error suffixes (for example provider/runtime messages and local path fragments) now require /verbose on or /verbose full.
  • +
  • BREAKING: CLI local onboarding now sets session.dmScope to per-channel-peer by default for new/implicit DM scope configuration. If you depend on shared DM continuity across senders, explicitly set session.dmScope to main. (#23468) Thanks @bmendonca3.
  • +
  • BREAKING: unify channel preview-streaming config to channels..streaming with enum values off | partial | block | progress, and move Slack native stream toggle to channels.slack.nativeStreaming. Legacy keys (streamMode, Slack boolean streaming) are still read and migrated by openclaw doctor --fix, but canonical saved config/docs now use the unified names.
  • +
  • BREAKING: remove legacy Gateway device-auth signature v1. Device-auth clients must now sign v2 payloads with the per-connection connect.challenge nonce and send device.nonce; nonce-less connects are rejected.

Fixes

    -
  • Outbound: add a write-ahead delivery queue with crash-recovery retries to prevent lost outbound messages after gateway restarts. (#15636) Thanks @nabbilkhan, @thewilloftheshadow.
  • -
  • Auto-reply/Threading: auto-inject implicit reply threading so replyToMode works without requiring model-emitted [[reply_to_current]], while preserving replyToMode: "off" behavior for implicit Slack replies and keeping block-streaming chunk coalescing stable under replyToMode: "first". (#14976) Thanks @Diaspar4u.
  • -
  • Outbound/Threading: pass replyTo and threadId from message send tool actions through the core outbound send path to channel adapters, preserving thread/reply routing. (#14948) Thanks @mcaxtr.
  • -
  • Auto-reply/Media: allow image-only inbound messages (no caption) to reach the agent instead of short-circuiting as empty text, and preserve thread context in queued/followup prompt bodies for media-only runs. (#11916) Thanks @arosstale.
  • -
  • Discord: route autoThread replies to existing threads instead of the root channel. (#8302) Thanks @gavinbmoore, @thewilloftheshadow.
  • -
  • Web UI: add img to DOMPurify allowed tags and src/alt to allowed attributes so markdown images render in webchat instead of being stripped. (#15437) Thanks @lailoo.
  • -
  • Telegram/Matrix: treat MP3 and M4A (including audio/mp4) as voice-compatible for asVoice routing, and keep WAV/AAC falling back to regular audio sends. (#15438) Thanks @azade-c.
  • -
  • WhatsApp: preserve outbound document filenames for web-session document sends instead of always sending "file". (#15594) Thanks @TsekaLuk.
  • -
  • Telegram: cap bot menu registration to Telegram's 100-command limit with an overflow warning while keeping typed hidden commands available. (#15844) Thanks @battman21.
  • -
  • Telegram: scope skill commands to the resolved agent for default accounts so setMyCommands no longer triggers BOT_COMMANDS_TOO_MUCH when multiple agents are configured. (#15599)
  • -
  • Discord: avoid misrouting numeric guild allowlist entries to /channels/ by prefixing guild-only inputs with guild: during resolution. (#12326) Thanks @headswim.
  • -
  • MS Teams: preserve parsed mention entities/text when appending OneDrive fallback file links, and accept broader real-world Teams mention ID formats (29:..., 8:orgid:...) while still rejecting placeholder patterns. (#15436) Thanks @hyojin.
  • -
  • Media: classify text/* MIME types as documents in media-kind routing so text attachments are no longer treated as unknown. (#12237) Thanks @arosstale.
  • -
  • Inbound/Web UI: preserve literal \n sequences when normalizing inbound text so Windows paths like C:\\Work\\nxxx\\README.md are not corrupted. (#11547) Thanks @mcaxtr.
  • -
  • TUI/Streaming: preserve richer streamed assistant text when final payload drops pre-tool-call text blocks, while keeping non-empty final payload authoritative for plain-text updates. (#15452) Thanks @TsekaLuk.
  • -
  • Providers/MiniMax: switch implicit MiniMax API-key provider from openai-completions to anthropic-messages with the correct Anthropic-compatible base URL, fixing invalid role: developer (2013) errors on MiniMax M2.5. (#15275) Thanks @lailoo.
  • -
  • Ollama/Agents: use resolved model/provider base URLs for native /api/chat streaming (including aliased providers), normalize /v1 endpoints, and forward abort + maxTokens stream options for reliable cancellation and token caps. (#11853) Thanks @BrokenFinger98.
  • -
  • OpenAI Codex/Spark: implement end-to-end gpt-5.3-codex-spark support across fallback/thinking/model resolution and models list forward-compat visibility. (#14990, #15174) Thanks @L-U-C-K-Y, @loiie45e.
  • -
  • Agents/Codex: allow gpt-5.3-codex-spark in forward-compat fallback, live model filtering, and thinking presets, and fix model-picker recognition for spark. (#14990) Thanks @L-U-C-K-Y.
  • -
  • Models/Codex: resolve configured openai-codex/gpt-5.3-codex-spark through forward-compat fallback during models list, so it is not incorrectly tagged as missing when runtime resolution succeeds. (#15174) Thanks @loiie45e.
  • -
  • OpenAI Codex/Auth: bridge OpenClaw OAuth profiles into pi auth.json so model discovery and models-list registry resolution can use Codex OAuth credentials. (#15184) Thanks @loiie45e.
  • -
  • Auth/OpenAI Codex: share OAuth login handling across onboarding and models auth login --provider openai-codex, keep onboarding alive when OAuth fails, and surface a direct OAuth help note instead of terminating the wizard. (#15406, follow-up to #14552) Thanks @zhiluo20.
  • -
  • Onboarding/Providers: add vLLM as an onboarding provider with model discovery, auth profile wiring, and non-interactive auth-choice validation. (#12577) Thanks @gejifeng.
  • -
  • Onboarding/Providers: preserve Hugging Face auth intent in auth-choice remapping (tokenProvider=huggingface with authChoice=apiKey) and skip env-override prompts when an explicit token is provided. (#13472) Thanks @Josephrp.
  • -
  • Onboarding/CLI: restore terminal state without resuming paused stdin, so onboarding exits cleanly after choosing Web UI and the installer returns instead of appearing stuck.
  • -
  • Signal/Install: auto-install signal-cli via Homebrew on non-x64 Linux architectures, avoiding x86_64 native binary Exec format error failures on arm64/arm hosts. (#15443) Thanks @jogvan-k.
  • -
  • macOS Voice Wake: fix a crash in trigger trimming for CJK/Unicode transcripts by matching and slicing on original-string ranges instead of transformed-string indices. (#11052) Thanks @Flash-LHR.
  • -
  • Mattermost (plugin): retry websocket monitor connections with exponential backoff and abort-aware teardown so transient connect failures no longer permanently stop monitoring. (#14962) Thanks @mcaxtr.
  • -
  • Discord/Agents: apply channel/group historyLimit during embedded-runner history compaction to prevent long-running channel sessions from bypassing truncation and overflowing context windows. (#11224) Thanks @shadril238.
  • -
  • Outbound targets: fail closed for WhatsApp/Twitch/Google Chat fallback paths so invalid or missing targets are dropped instead of rerouted, and align resolver hints with strict target requirements. (#13578) Thanks @mcaxtr.
  • -
  • Gateway/Restart: clear stale command-queue and heartbeat wake runtime state after SIGUSR1 in-process restarts to prevent zombie gateway behavior where queued work stops draining. (#15195) Thanks @joeykrug.
  • -
  • Heartbeat: prevent scheduler silent-death races during runner reloads, preserve retry cooldown backoff under wake bursts, and prioritize user/action wake causes over interval/retry reasons when coalescing. (#15108) Thanks @joeykrug.
  • -
  • Heartbeat: allow explicit wake (wake) and hook wake (hook:*) reasons to run even when HEARTBEAT.md is effectively empty so queued system events are processed. (#14527) Thanks @arosstale.
  • -
  • Auto-reply/Heartbeat: strip sentence-ending HEARTBEAT_OK tokens even when followed by up to 4 punctuation characters, while preserving surrounding sentence punctuation. (#15847) Thanks @Spacefish.
  • -
  • Agents/Heartbeat: stop auto-creating HEARTBEAT.md during workspace bootstrap so missing files continue to run heartbeat as documented. (#11766) Thanks @shadril238.
  • -
  • Sessions/Agents: pass agentId when resolving existing transcript paths in reply runs so non-default agents and heartbeat/chat handlers no longer fail with Session file path must be within sessions directory. (#15141) Thanks @Goldenmonstew.
  • -
  • Sessions/Agents: pass agentId through status and usage transcript-resolution paths (auto-reply, gateway usage APIs, and session cost/log loaders) so non-default agents can resolve absolute session files without path-validation failures. (#15103) Thanks @jalehman.
  • -
  • Sessions: archive previous transcript files on /new and /reset session resets (including gateway sessions.reset) so stale transcripts do not accumulate on disk. (#14869) Thanks @mcaxtr.
  • -
  • Status/Sessions: stop clamping derived totalTokens to context-window size, keep prompt-token snapshots wired through session accounting, and surface context usage as unknown when fresh snapshot data is missing to avoid false 100% reports. (#15114) Thanks @echoVic.
  • -
  • CLI/Completion: route plugin-load logs to stderr and write generated completion scripts directly to stdout to avoid source <(openclaw completion ...) corruption. (#15481) Thanks @arosstale.
  • -
  • CLI: lazily load outbound provider dependencies and remove forced success-path exits so commands terminate naturally without killing intentional long-running foreground actions. (#12906) Thanks @DrCrinkle.
  • -
  • Security/Gateway + ACP: block high-risk tools (sessions_spawn, sessions_send, gateway, whatsapp_login) from HTTP /tools/invoke by default with gateway.tools.{allow,deny} overrides, and harden ACP permission selection to fail closed when tool identity/options are ambiguous while supporting allow_always/reject_always. (#15390) Thanks @aether-ai-agent.
  • -
  • Security/Gateway: breaking default-behavior change - canvas IP-based auth fallback now only accepts machine-scoped addresses (RFC1918, link-local, ULA IPv6, CGNAT); public-source IP matches now require bearer token auth. (#14661) Thanks @sumleo.
  • -
  • Security/Link understanding: block loopback/internal host patterns and private/mapped IPv6 addresses in extracted URL handling to close SSRF bypasses in link CLI flows. (#15604) Thanks @AI-Reviewer-QS.
  • -
  • Security/Browser: constrain POST /trace/stop, POST /wait/download, and POST /download output paths to OpenClaw temp roots and reject traversal/escape paths.
  • -
  • Security/Canvas: serve A2UI assets via the shared safe-open path (openFileWithinRoot) to close traversal/TOCTOU gaps, with traversal and symlink regression coverage. (#10525) Thanks @abdelsfane.
  • -
  • Security/WhatsApp: enforce 0o600 on creds.json and creds.json.bak on save/backup/restore paths to reduce credential file exposure. (#10529) Thanks @abdelsfane.
  • -
  • Security/Gateway: sanitize and truncate untrusted WebSocket header values in pre-handshake close logs to reduce log-poisoning risk. Thanks @thewilloftheshadow.
  • -
  • Security/Audit: add misconfiguration checks for sandbox Docker config with sandbox mode off, ineffective gateway.nodes.denyCommands entries, global minimal tool-profile overrides by agent profiles, and permissive extension-plugin tool reachability.
  • -
  • Security/Audit: distinguish external webhooks (hooks.enabled) from internal hooks (hooks.internal.enabled) in attack-surface summaries to avoid false exposure signals when only internal hooks are enabled. (#13474) Thanks @mcaxtr.
  • -
  • Security/Onboarding: clarify multi-user DM isolation remediation with explicit openclaw config set session.dmScope ... commands in security audit, doctor security, and channel onboarding guidance. (#13129) Thanks @VintLin.
  • -
  • Agents/Nodes: harden node exec approval decision handling in the nodes tool run path by failing closed on unexpected approval decisions, and add regression coverage for approval-required retry/deny/timeout flows. (#4726) Thanks @rmorse.
  • -
  • Android/Nodes: harden app.update by requiring HTTPS and gateway-host URL matching plus SHA-256 verification, stream URL camera downloads to disk with size guards to avoid memory spikes, and stop signing release builds with debug keys. (#13541) Thanks @smartprogrammer93.
  • -
  • Routing: enforce strict binding-scope matching across peer/guild/team/roles so peer-scoped Discord/Slack bindings no longer match unrelated guild/team contexts or fallback tiers. (#15274) Thanks @lailoo.
  • -
  • Exec/Allowlist: allow multiline heredoc bodies (<<, <<-) while keeping multiline non-heredoc shell commands blocked, so exec approval parsing permits heredoc input safely without allowing general newline command chaining. (#13811) Thanks @mcaxtr.
  • -
  • Config: preserve ${VAR} env references when writing config files so openclaw config set/apply/patch does not persist secrets to disk. Thanks @thewilloftheshadow.
  • -
  • Config: remove a cross-request env-snapshot race in config writes by carrying read-time env context into write calls per request, preserving ${VAR} refs safely under concurrent gateway config mutations. (#11560) Thanks @akoscz.
  • -
  • Config: log overwrite audit entries (path, backup target, and hash transition) whenever an existing config file is replaced, improving traceability for unexpected config clobbers.
  • -
  • Config: keep legacy audio transcription migration strict by rejecting non-string/unsafe command tokens while still migrating valid custom script executables. (#5042) Thanks @shayan919293.
  • -
  • Config: accept $schema key in config file so JSON Schema editor tooling works without validation errors. (#14998)
  • -
  • Gateway/Tools Invoke: sanitize /tools/invoke execution failures while preserving 400 for tool input errors and returning 500 for unexpected runtime failures, with regression coverage and docs updates. (#13185) Thanks @davidrudduck.
  • -
  • Gateway/Hooks: preserve 408 for hook request-body timeout responses while keeping bounded auth-failure cache eviction behavior, with timeout-status regression coverage. (#15848) Thanks @AI-Reviewer-QS.
  • -
  • Plugins/Hooks: fire before_tool_call hook exactly once per tool invocation in embedded runs by removing duplicate dispatch paths while preserving parameter mutation semantics. (#15635) Thanks @lailoo.
  • -
  • Agents/Transcript policy: sanitize OpenAI/Codex tool-call ids during transcript policy normalization to prevent invalid tool-call identifiers from propagating into session history. (#15279) Thanks @divisonofficer.
  • -
  • Agents/Image tool: cap image-analysis completion maxTokens by model capability (min(4096, model.maxTokens)) to avoid over-limit provider failures while still preventing truncation. (#11770) Thanks @detecti1.
  • -
  • Agents/Compaction: centralize exec default resolution in the shared tool factory so per-agent tools.exec overrides (host/security/ask/node and related defaults) persist across compaction retries. (#15833) Thanks @napetrov.
  • -
  • Gateway/Agents: stop injecting a phantom main agent into gateway agent listings when agents.list explicitly excludes it. (#11450) Thanks @arosstale.
  • -
  • Process/Exec: avoid shell execution for .exe commands on Windows so env overrides work reliably in runCommandWithTimeout. Thanks @thewilloftheshadow.
  • -
  • Daemon/Windows: preserve literal backslashes in gateway.cmd command parsing so drive and UNC paths are not corrupted in runtime checks and doctor entrypoint comparisons. (#15642) Thanks @arosstale.
  • -
  • Sandbox: pass configured sandbox.docker.env variables to sandbox containers at docker create time. (#15138) Thanks @stevebot-alive.
  • -
  • Voice Call: route webhook runtime event handling through shared manager event logic so rejected inbound hangups are idempotent in production, with regression tests for duplicate reject events and provider-call-ID remapping parity. (#15892) Thanks @dcantu96.
  • -
  • Cron: add regression coverage for announce-mode isolated jobs so runs that already report delivered: true do not enqueue duplicate main-session relays, including delivery configs where mode is omitted and defaults to announce. (#15737) Thanks @brandonwise.
  • -
  • Cron: honor deleteAfterRun in isolated announce delivery by mapping it to subagent announce cleanup mode, so cron run sessions configured for deletion are removed after completion. (#15368) Thanks @arosstale.
  • -
  • Web tools/web_fetch: prefer text/markdown responses for Cloudflare Markdown for Agents, add cf-markdown extraction for markdown bodies, and redact fetched URLs in x-markdown-tokens debug logs to avoid leaking raw paths/query params. (#15376) Thanks @Yaxuan42.
  • -
  • Clawdock: avoid Zsh readonly variable collisions in helper scripts. (#15501) Thanks @nkelner.
  • -
  • Memory: switch default local embedding model to the QAT embeddinggemma-300m-qat-Q8_0 variant for better quality at the same footprint. (#15429) Thanks @azade-c.
  • -
  • Docs/Mermaid: remove hardcoded Mermaid init theme blocks from four docs diagrams so dark mode inherits readable theme defaults. (#15157) Thanks @heytulsiprasad.
  • +
  • Security/CLI: redact sensitive values in openclaw config get output before printing config paths, preventing credential leakage to terminal output/history. (#13683) Thanks @SleuthCo.
  • +
  • Install/Discord Voice: make @discordjs/opus an optional dependency so openclaw install/update no longer hard-fails when native Opus builds fail, while keeping opusscript as the runtime fallback decoder for Discord voice flows. (#23737, #23733, #23703) Thanks @jeadland, @Sheetaa, and @Breakyman.
  • +
  • Docker/Setup: precreate $OPENCLAW_CONFIG_DIR/identity during docker-setup.sh so CLI commands that need device identity (for example devices list) avoid EACCES ... /home/node/.openclaw/identity failures on restrictive bind mounts. (#23948) Thanks @ackson-beep.
  • +
  • Exec/Background: stop applying the default exec timeout to background sessions (background: true or explicit yieldMs) when no explicit timeout is set, so long-running background jobs are no longer terminated at the default timeout boundary. (#23303)
  • +
  • Slack/Threading: sessions: keep parent-session forking and thread-history context active beyond first turn by removing first-turn-only gates in session init, thread-history fetch, and reply prompt context injection. (#23843, #23090) Thanks @vincentkoc and @Taskle.
  • +
  • Slack/Threading: respect replyToMode when Slack auto-populates top-level thread_ts, and ignore inline replyToId directive tags when replyToMode is off so thread forcing stays disabled unless explicitly configured. (#23839, #23320, #23513) Thanks @vincentkoc and @dorukardahan.
  • +
  • Slack/Extension: forward message read threadId to readMessages and use delivery-context threadId as outbound thread_ts fallback so extension replies/reads stay in the correct Slack thread. (#22216, #22485, #23836) Thanks @vincentkoc, @lan17 and @dorukardahan.
  • +
  • Slack/Upload: resolve bare user IDs (U-prefix) to DM channel IDs via conversations.open before calling files.uploadV2, which rejects non-channel IDs. chat.postMessage tolerates user IDs directly, but files.uploadV2completeUploadExternal validates channel_id against ^[CGDZ][A-Z0-9]{8,}$, causing invalid_arguments when agents reply with media to DM conversations.
  • +
  • Webchat/Chat: apply assistant final payload messages directly to chat state so sent turns render without waiting for a full history refresh cycle. (#14928) Thanks @BradGroux.
  • +
  • Webchat/Chat: for out-of-band final events (for example tool-call side runs), append provided final assistant payloads directly instead of forcing a transient history reset. (#11139) Thanks @AkshayNavle.
  • +
  • Webchat/Performance: reload chat.history after final events only when the final payload lacks a renderable assistant message, avoiding expensive full-history refreshes on normal turns. (#20588) Thanks @amzzzzzzz.
  • +
  • Webchat/Sessions: preserve external session routing metadata when internal chat.send turns run under webchat, so explicit channel-keyed sessions (for example Telegram) no longer get rewritten to webchat and misroute follow-up delivery. (#23258) Thanks @binary64.
  • +
  • Webchat/Sessions: preserve existing session label across /new and /reset rollovers so reset sessions remain discoverable in session history lists. (#23755) Thanks @ThunderStormer.
  • +
  • Gateway/Chat UI: strip inline reply/audio directive tags from non-streaming final webchat broadcasts (including chat.inject) while preserving empty-string message content when tags are the entire reply. (#23298) Thanks @SidQin-cyber.
  • +
  • Chat/UI: strip inline reply/audio directive tags ([[reply_to_current]], [[reply_to:]], [[audio_as_voice]]) from displayed chat history, live chat event output, and session preview snippets so control tags no longer leak into user-visible surfaces.
  • +
  • Telegram/Media: send a user-facing Telegram reply when media download fails (non-size errors) instead of silently dropping the message.
  • +
  • Telegram/Webhook: keep webhook monitors alive until gateway abort signals fire, preventing false channel exits and immediate webhook auto-restart loops.
  • +
  • Telegram/Polling: retry recoverable setup-time network failures in monitor startup and await runner teardown before retry to avoid overlapping polling sessions.
  • +
  • Telegram/Polling: clear Telegram webhooks (deleteWebhook) before starting long-poll getUpdates, including retry handling for transient cleanup failures.
  • +
  • Telegram/Webhook: add channels.telegram.webhookPort config support and pass it through plugin startup wiring to the monitor listener.
  • +
  • Browser/Extension Relay: refactor the MV3 worker to preserve debugger attachments across relay drops, auto-reconnect with bounded backoff+jitter, persist and rehydrate attached tab state via chrome.storage.session, recover from target_closed navigation detaches, guard stale socket handlers, enforce per-tab operation locks and per-request timeouts, and add lifecycle keepalive/badge refresh hooks (alarms, webNavigation). (#15099, #6175, #8468, #9807)
  • +
  • Browser/Relay: treat extension websocket as connected only when OPEN, allow reconnect when a stale CLOSING/CLOSED extension socket lingers, and guard stale socket message/close handlers so late events cannot clear active relay state; includes regression coverage for live-duplicate 409 rejection and immediate reconnect-after-close races. (#15099, #18698, #20688)
  • +
  • Browser/Remote CDP: extend stale-target recovery so ensureTabAvailable() now reuses the sole available tab for remote CDP profiles (same behavior as extension profiles) while preserving strict tab not found errors when multiple tabs exist; includes remote-profile regression tests. (#15989)
  • +
  • Gateway/Pairing: treat operator.admin as satisfying other operator.* scope checks during device-auth verification so local CLI/TUI sessions stop entering pairing-required loops for pairing/approval-scoped commands. (#22062, #22193, #21191) Thanks @Botaccess, @jhartshorn, and @ctbritt.
  • +
  • Gateway/Pairing: auto-approve loopback scope-upgrade pairing requests (including device-token reconnects) so local clients do not disconnect on pairing-required scope elevation. (#23708) Thanks @widingmarcus-cyber.
  • +
  • Gateway/Scopes: include operator.read and operator.write in default operator connect scope bundles across CLI, Control UI, and macOS clients so write-scoped announce/sub-agent follow-up calls no longer hit pairing required disconnects on loopback gateways. (#22582) thanks @YuzuruS.
  • +
  • Gateway/Pairing: treat operator.admin pairing tokens as satisfying operator.write requests so legacy devices stop looping through scope-upgrade prompts introduced in 2026.2.19. (#23125, #23006) Thanks @vignesh07.
  • +
  • Gateway/Restart: fix restart-loop edge cases by keeping openclaw.mjs -> dist/entry.js bootstrap detection explicit, reacquiring the gateway lock for in-process restart fallback paths, and tightening restart-loop regression coverage. (#23416) Thanks @jeffwnli.
  • +
  • Gateway/Lock: use optional gateway-port reachability as a primary stale-lock liveness signal (and wire gateway run-loop lock acquisition to the resolved port), reducing false "already running" lockouts after unclean exits. (#23760) Thanks @Operative-001.
  • +
  • Delivery/Queue: quarantine queue entries immediately on known permanent delivery errors (for example invalid recipients or missing conversation references) by moving them to failed/ instead of retrying on every restart. (#23794) Thanks @aldoeliacim.
  • +
  • Cron/Status: split execution outcome (lastRunStatus) from delivery outcome (lastDeliveryStatus) in persisted cron state, finished events, and run history so failed/unknown announcement delivery is visible without conflating it with run errors.
  • +
  • Cron/Delivery: route text-only announce jobs with explicit thread/topic targets through direct outbound delivery so forum/thread destinations do not get dropped by intermediary announce turns. (#23841) Thanks @AndrewArto.
  • +
  • Cron: honor cron.maxConcurrentRuns in the timer loop so due jobs can execute up to the configured parallelism instead of always running serially. (#11595) Thanks @Takhoffman.
  • +
  • Cron/Run: enforce the same per-job timeout guard for manual cron.run executions as timer-driven runs, including abort propagation for isolated agent jobs, so forced runs cannot wedge indefinitely. (#23704) Thanks @tkuehnl.
  • +
  • Cron/Run: persist the manual-run runningAtMs marker before releasing the cron lock so overlapping timer ticks cannot start the same job concurrently.
  • +
  • Cron/Startup: enforce per-job timeout guards for startup catch-up replay runs so missed isolated jobs cannot hang indefinitely during gateway boot recovery.
  • +
  • Cron/Main session: honor abort/timeout signals while retrying wakeMode=now heartbeat contention loops so main-target cron runs stop promptly instead of waiting through the full busy-retry window.
  • +
  • Cron/Schedule: for every jobs, prefer lastRunAtMs + everyMs when still in the future after restarts, then fall back to anchor scheduling for catch-up windows, so NEXT timing matches the last successful cadence. (#22895) Thanks @SidQin-cyber.
  • +
  • Cron/Service: execute manual cron.run jobs outside the cron lock (while still persisting started/finished state atomically) so cron.list and cron.status remain responsive during long forced runs. (#23628) Thanks @dsgraves.
  • +
  • Cron/Timer: keep a watchdog recheck timer armed while onTimer is actively executing so the scheduler continues polling even if a due-run tick stalls for an extended period. (#23628) Thanks @dsgraves.
  • +
  • Cron/Run log: clean up settled per-path run-log write queue entries so long-running cron uptime does not retain stale promise bookkeeping in memory.
  • +
  • Cron/Isolation: force fresh session IDs for isolated cron runs so sessionTarget="isolated" executions never reuse prior run context. (#23470) Thanks @echoVic.
  • +
  • Plugins/Install: strip workspace:* devDependency entries from copied plugin manifests before npm install --omit=dev, preventing EUNSUPPORTEDPROTOCOL install failures for npm-published channel plugins (including Feishu and MS Teams).
  • +
  • Feishu/Plugins: restore bundled Feishu SDK availability for global installs and strip openclaw: workspace:* from plugin devDependencies during plugin-version sync so npm-installed Feishu plugins do not fail dependency install. (#23611, #23645, #23603)
  • +
  • Config/Channels: auto-enable built-in channels by writing channels..enabled=true (not plugins.entries.), and stop adding built-ins to plugins.allow, preventing plugins.entries.telegram: plugin not found validation failures.
  • +
  • Config/Channels: when plugins.allow is active, auto-enable/enable flows now also allowlist configured built-in channels so channels..enabled=true cannot remain blocked by restrictive plugin allowlists.
  • +
  • Plugins/Discovery: ignore scanned extension backup/disabled directory patterns (for example .backup-*, .bak, .disabled*) and move updater backup directories under .openclaw-install-backups, preventing duplicate plugin-id collisions from archived copies.
  • +
  • Plugins/CLI: make openclaw plugins enable and plugin install/link flows update allowlists via shared plugin-enable policy so enabled plugins are not left disabled by allowlist mismatch. (#23190) Thanks @downwind7clawd-ctrl.
  • +
  • Security/Voice Call: harden media stream WebSocket handling against pre-auth idle-connection DoS by adding strict pre-start timeouts, pending/per-IP connection limits, and total connection caps for streaming endpoints. This ships in the next npm release. Thanks @jiseoung for reporting.
  • +
  • Security/Sessions: redact sensitive token patterns from sessions_history tool output and surface contentRedacted metadata when masking occurs. (#16928) Thanks @aether-ai-agent.
  • +
  • Security/Exec: stop trusting PATH-derived directories for safe-bin allowlist checks, add explicit tools.exec.safeBinTrustedDirs, and pin safe-bin shell execution to resolved absolute executable paths to prevent binary-shadowing approval bypasses. This ships in the next npm release. Thanks @tdjackey for reporting.
  • +
  • Security/Elevated: match tools.elevated.allowFrom against sender identities only (not recipient ctx.To), closing a recipient-token bypass for /elevated authorization. This ships in the next npm release. Thanks @jiseoung for reporting.
  • +
  • Security/Feishu: enforce ID-only allowlist matching for DM/group sender authorization, normalize Feishu ID prefixes during checks, and ignore mutable display names so display-name collisions cannot satisfy allowlist entries. This ships in the next npm release. Thanks @jiseoung for reporting.
  • +
  • Security/Group policy: harden channels.*.groups.*.toolsBySender matching by requiring explicit sender-key types (id:, e164:, username:, name:), preventing cross-identifier collisions across mutable/display-name fields while keeping legacy untyped keys on a deprecated ID-only path. This ships in the next npm release. Thanks @jiseoung for reporting.
  • +
  • Channels/Group policy: fail closed when groupPolicy: "allowlist" is set without explicit groups, honor account-level groupPolicy overrides, and enforce groupPolicy: "disabled" as a hard group block. (#22215) Thanks @etereo.
  • +
  • Telegram/Discord extensions: propagate trusted mediaLocalRoots through extension outbound sendMedia options so extension direct-send media paths honor agent-scoped local-media allowlists. (#20029, #21903, #23227)
  • +
  • Agents/Exec: honor explicit agent context when resolving tools.exec defaults for runs with opaque/non-agent session keys, so per-agent host/security/ask policies are applied consistently. (#11832)
  • +
  • Doctor/Security: add an explicit warning that approvals.exec.enabled=false disables forwarding only, while enforcement remains driven by host-local exec-approvals.json policy. (#15047)
  • +
  • Sandbox/Docker: default sandbox container user to the workspace owner uid:gid when agents.*.sandbox.docker.user is unset, fixing non-root gateway file-tool permissions under capability-dropped containers. (#20979)
  • +
  • Plugins/Media sandbox: propagate trusted mediaLocalRoots through plugin action dispatch (including Discord/Telegram action adapters) so plugin send paths enforce the same agent-scoped local-media sandbox roots as core outbound sends. (#20258, #22718)
  • +
  • Agents/Workspace guard: map sandbox container-workdir file-tool paths (for example /workspace/... and file:///workspace/...) to host workspace roots before workspace-only validation, preventing false Path escapes sandbox root rejections for sandbox file tools. (#9560)
  • +
  • Gateway/Exec approvals: expire approval requests immediately when no approval-capable gateway clients are connected and no forwarding targets are available, avoiding delayed approvals after restarts/offline approver windows. (#22144)
  • +
  • Security/Exec approvals: when approving wrapper commands with allow-always in allowlist mode, persist inner executable paths for known dispatch wrappers (env, nice, nohup, stdbuf, timeout) and fail closed (no persisted entry) when wrapper unwrapping is not safe, preventing wrapper-path approval bypasses. Thanks @tdjackey for reporting.
  • +
  • Node/macOS exec host: default headless macOS node system.run to local execution and only route through the companion app when OPENCLAW_NODE_EXEC_HOST=app is explicitly set, avoiding companion-app filesystem namespace mismatches during exec. (#23547)
  • +
  • Sandbox/Media: map container workspace paths (/workspace/... and file:///workspace/...) back to the host sandbox root for outbound media validation, preventing false deny errors for sandbox-generated local media. (#23083) Thanks @echo931.
  • +
  • Sandbox/Docker: apply custom bind mounts after workspace mounts and prioritize bind-source resolution on overlapping paths, so explicit workspace binds are no longer ignored. (#22669) Thanks @tasaankaeris.
  • +
  • Exec approvals/Forwarding: restore Discord text forwarding when component approvals are not configured, and carry request snapshots through resolve events so resolved notices still forward after cache misses/restarts. (#22988) Thanks @bubmiller.
  • +
  • Control UI/WebSocket: stop and clear the browser gateway client on UI teardown so remounts cannot leave orphan websocket clients that create duplicate active connections. (#23422) Thanks @floatinggball-design.
  • +
  • Control UI/WebSocket: send a stable per-tab instanceId in websocket connect frames so reconnect cycles keep a consistent client identity for diagnostics and presence tracking. (#23616) Thanks @zq58855371-ui.
  • +
  • Config/Memory: allow "mistral" in agents.defaults.memorySearch.provider and agents.defaults.memorySearch.fallback schema validation. (#14934) Thanks @ThomsenDrake.
  • +
  • Feishu/Commands: in group chats, command authorization now falls back to top-level channels.feishu.allowFrom when per-group allowFrom is not set, so /command no longer gets blocked by an unintended empty allowlist. (#23756)
  • +
  • Dev tooling: prevent CLAUDE.md symlink target regressions by excluding CLAUDE symlink sentinels from oxfmt and marking them -text in .gitattributes, so formatter/EOL normalization cannot reintroduce trailing-newline targets. Thanks @vincentkoc.
  • +
  • Agents/Compaction: restore embedded compaction safeguard/context-pruning extension loading in production by wiring bundled extension factories into the resource loader instead of runtime file-path resolution. (#22349) Thanks @Glucksberg.
  • +
  • Feishu/Media: for inbound video messages that include both file_key (video) and image_key (thumbnail), prefer file_key when downloading media so video attachments are saved instead of silently failing on thumbnail keys. (#23633)
  • +
  • Hooks/Loader: avoid redundant hook-module recompilation on gateway restart by skipping cache-busting for bundled hooks and using stable file metadata keys (mtime+size) for mutable workspace/managed/plugin hook imports. (#16953) Thanks @mudrii.
  • +
  • Hooks/Cron: suppress duplicate main-session events for delivered hook turns and mark SILENT_REPLY_TOKEN (NO_REPLY) early exits as delivered to prevent hook context pollution. (#20678) Thanks @JonathanWorks.
  • +
  • Providers/OpenRouter: inject cache_control on system prompts for OpenRouter Anthropic models to improve prompt-cache reuse. (#17473) Thanks @rrenamed.
  • +
  • Installer/Smoke tests: remove legacy OPENCLAW_USE_GUM overrides from docker install-smoke runs so tests exercise installer auto TTY detection behavior directly.
  • +
  • Providers/OpenRouter: allow pass-through OpenRouter and Opencode model IDs in live model filtering so custom routed model IDs are treated as modern refs. (#14312) Thanks @Joly0.
  • +
  • Providers/OpenRouter: default reasoning to enabled when the selected model advertises reasoning: true and no session/directive override is set. (#22513) Thanks @zwffff.
  • +
  • Providers/OpenRouter: map /think levels to reasoning.effort in embedded runs while preserving explicit reasoning.max_tokens payloads. (#17236) Thanks @robbyczgw-cla.
  • +
  • Providers/OpenRouter: preserve stored session provider when model IDs are vendor-prefixed (for example, anthropic/...) so follow-up turns do not incorrectly route to direct provider APIs. (#22753) Thanks @dndodson.
  • +
  • Providers/OpenRouter: preserve the required openrouter/ prefix for OpenRouter-native model IDs during model-ref normalization. (#12942) Thanks @omair445.
  • +
  • Providers/OpenRouter: pass through provider routing parameters from model params.provider to OpenRouter request payloads for provider selection controls. (#17148) Thanks @carrotRakko.
  • +
  • Providers/OpenRouter: preserve model allowlist entries containing OpenRouter preset paths (for example openrouter/@preset/...) by treating /model ...@profile auth-profile parsing as a suffix-only override. (#14120) Thanks @NotMainstream.
  • +
  • Cron/Auth: propagate auth-profile resolution to isolated cron sessions so provider API keys are resolved the same way as main sessions, fixing 401 errors when using providers configured via auth-profiles. (#20689) Thanks @lailoo.
  • +
  • Cron/Follow-up: pass resolved agentDir through isolated cron and queued follow-up embedded runs so auth/profile lookups stay scoped to the correct agent directory. (#22845) Thanks @seilk.
  • +
  • Agents/Media: route tool-result MEDIA: extraction through shared parser validation so malformed prose like MEDIA:-prefixed ... is no longer treated as a local file path (prevents Telegram ENOENT tool-error overrides). (#18780) Thanks @HOYALIM.
  • +
  • Logging: cap single log-file size with logging.maxFileBytes (default 500 MB) and suppress additional writes after cap hit to prevent disk exhaustion from repeated error storms.
  • +
  • Memory/Remote HTTP: centralize remote memory HTTP calls behind a shared guarded helper (withRemoteHttpResponse) so embeddings and batch flows use one request/release path.
  • +
  • Memory/Embeddings: apply configured remote-base host pinning (allowedHostnames) across OpenAI/Voyage/Gemini embedding requests to keep private/self-hosted endpoints working without cross-host drift. (#18198) Thanks @ianpcook.
  • +
  • Memory/Batch: route OpenAI/Voyage/Gemini batch upload/create/status/download requests through the same guarded HTTP path for consistent SSRF policy enforcement.
  • +
  • Memory/Index: detect memory source-set changes (for example enabling sessions after an existing memory-only index) and trigger a full reindex so existing session transcripts are indexed without requiring --force. (#17576) Thanks @TarsAI-Agent.
  • +
  • Memory/Embeddings: enforce a per-input 8k safety cap before embedding batching and apply a conservative 2k fallback limit for local providers without declared input limits, preventing oversized session/memory chunks from triggering provider context-size failures during sync/indexing. (#6016) Thanks @batumilove.
  • +
  • Memory/QMD: on Windows, resolve bare qmd/mcporter command names to npm shim executables (.cmd) before spawning, so qmd boot updates and mcporter-backed searches no longer fail with spawn ... ENOENT on default npm installs. (#23899) Thanks @arcbuilder-ai.
  • +
  • Memory/QMD: parse plain-text qmd collection list --json output when older qmd builds ignore JSON mode, and retry memory searches once after re-ensuring managed collections when qmd returns Collection not found .... (#23613) Thanks @leozhucn.
  • +
  • Signal/RPC: guard malformed Signal RPC JSON responses with a clear status-scoped error and add regression coverage for invalid JSON responses. (#22995) Thanks @adhitShet.
  • +
  • Gateway/Subagents: guard gateway and subagent session-key/message trim paths against undefined inputs to prevent early Cannot read properties of undefined (reading 'trim') crashes during subagent spawn and wait flows.
  • +
  • Agents/Workspace: guard resolveUserPath against undefined/null input to prevent Cannot read properties of undefined (reading 'trim') crashes when workspace paths are missing in embedded runner flows.
  • +
  • Auth/Profiles: keep active cooldownUntil/disabledUntil windows immutable across retries so mid-window failures cannot extend recovery indefinitely; only recompute a backoff window after the previous deadline has expired. This resolves cron/inbound retry loops that could trap gateways until manual usageStats cleanup. (#23516, #23536) Thanks @arosstale.
  • +
  • Channels/Security: fail closed on missing provider group policy config by defaulting runtime group policy to allowlist (instead of inheriting channels.defaults.groupPolicy) when channels. is absent across message channels, and align runtime + security warnings/docs to the same fallback behavior (Slack, Discord, iMessage, Telegram, WhatsApp, Signal, LINE, Matrix, Mattermost, Google Chat, IRC, Nextcloud Talk, Feishu, and Zalo user flows; plus Discord message/native-command paths). (#23367) Thanks @bmendonca3.
  • +
  • Gateway/Onboarding: harden remote gateway onboarding defaults and guidance by defaulting discovered direct URLs to wss://, rejecting insecure non-loopback ws:// targets in onboarding validation, and expanding remote-security remediation messaging across gateway client/call/doctor flows. (#23476) Thanks @bmendonca3.
  • +
  • CLI/Sessions: pass the configured sessions directory when resolving transcript paths in agentCommand, so custom session.store locations resume sessions reliably. Thanks @davidrudduck.
  • +
  • Signal/Monitor: treat user-initiated abort shutdowns as clean exits when auto-started signal-cli is terminated, while still surfacing unexpected daemon exits as startup/runtime failures. (#23379) Thanks @frankekn.
  • +
  • Channels/Dedupe: centralize plugin dedupe primitives in plugin SDK (memory + persistent), move Feishu inbound dedupe to a namespace-scoped persistent store, and reuse shared dedupe cache logic for Zalo webhook replay + Tlon processed-message tracking to reduce duplicate handling during reconnect/replay paths. (#23377) Thanks @SidQin-cyber.
  • +
  • Channels/Delivery: remove hardcoded WhatsApp delivery fallbacks; require explicit/session channel context or auto-pick the sole configured channel when unambiguous. (#23357) Thanks @lbo728.
  • +
  • ACP/Gateway: wait for gateway hello before opening ACP requests, and fail fast on pre-hello connect failures to avoid startup hangs and early gateway not connected request races. (#23390) Thanks @janckerchen.
  • +
  • Gateway/Auth: preserve OPENCLAW_GATEWAY_PASSWORD env override precedence for remote gateway call credentials after shared resolver refactors, preventing stale configured remote passwords from overriding runtime secret rotation.
  • +
  • Gateway/Auth: preserve shared-token gateway token mismatch auth errors when auth.token fallback device-token checks fail, and reserve device token mismatch guidance for explicit auth.deviceToken failures.
  • +
  • Gateway/Tools: when agent tools pass an allowlisted gatewayUrl override, resolve local override tokens from env/config fallback but keep remote overrides strict to gateway.remote.token, preventing local token leakage to remote targets.
  • +
  • Gateway/Client: keep cached device-auth tokens on device token mismatch closes when the client used explicit shared token/password credentials, avoiding accidental pairing-token churn during explicit-auth failures.
  • +
  • Node host/Exec: keep strict Windows allowlist behavior for cmd.exe /c shell-wrapper runs, and return explicit approval guidance when blocked (SYSTEM_RUN_DENIED: allowlist miss).
  • +
  • Control UI: show pairing-required guidance (commands + mobile tokenized URL reminder) when the dashboard disconnects with 1008 pairing required.
  • +
  • Security/Audit: add openclaw security audit detection for open group policies that expose runtime/filesystem tools without sandbox/workspace guards (security.exposure.open_groups_with_runtime_or_fs).
  • +
  • Security/Audit: make gateway.real_ip_fallback_enabled severity conditional for loopback trusted-proxy setups (warn for loopback-only trustedProxies, critical when non-loopback proxies are trusted). (#23428) Thanks @bmendonca3.
  • +
  • Security/Exec env: block request-scoped HOME and ZDOTDIR overrides in host exec env sanitizers (Node + macOS), preventing shell startup-file execution before allowlist-evaluated command bodies. This ships in the next npm release. Thanks @tdjackey for reporting.
  • +
  • Security/Exec env: block SHELLOPTS/PS4 in host exec env sanitizers and restrict shell-wrapper (bash|sh|zsh ... -c/-lc) request env overrides to a small explicit allowlist (TERM, LANG, LC_*, COLORTERM, NO_COLOR, FORCE_COLOR) on both node host and macOS companion paths, preventing xtrace prompt command-substitution allowlist bypasses. This ships in the next npm release. Thanks @tdjackey for reporting.
  • +
  • WhatsApp/Security: enforce allowFrom for direct-message outbound targets in all send modes (including mode: "explicit"), preventing sends to non-allowlisted numbers. (#20108) Thanks @zahlmann.
  • +
  • Security/Exec approvals: fail closed on shell line continuations (\\\n/\\\r\n) and treat shell-wrapper execution as approval-required in allowlist mode, preventing $\\ newline command-substitution bypasses. This ships in the next npm release. Thanks @tdjackey for reporting.
  • +
  • Security/Gateway: emit a startup security warning when insecure/dangerous config flags are enabled (including gateway.controlUi.dangerouslyDisableDeviceAuth=true) and point operators to openclaw security audit.
  • +
  • Security/Hooks auth: normalize hook auth rate-limit client IP keys so IPv4 and IPv4-mapped IPv6 addresses share one throttle bucket, preventing dual-form auth-attempt budget bypasses. This ships in the next npm release. Thanks @aether-ai-agent for reporting.
  • +
  • Security/Exec approvals: treat env and shell-dispatch wrappers as transparent during allowlist analysis on node-host and macOS companion paths so policy checks match the effective executable/inline shell payload instead of the wrapper binary, blocking wrapper-smuggled allowlist bypasses. This ships in the next npm release. Thanks @tdjackey for reporting.
  • +
  • Security/Exec approvals: require explicit safe-bin profiles for tools.exec.safeBins entries in allowlist mode (remove generic safe-bin profile fallback), and add tools.exec.safeBinProfiles for safe custom binaries so unprofiled interpreter-style entries cannot be treated as stdin-safe. This ships in the next npm release. Thanks @tdjackey for reporting.
  • +
  • Security/Channels: harden Slack external menu token handling by switching to CSPRNG tokens, validating token shape, requiring user identity for external option lookups, and avoiding fabricated timestamp trigger_id fallbacks; also switch Tlon Urbit channel IDs to CSPRNG UUIDs, centralize secure ID/token generation via shared infra helpers, and add a guardrail test to block new runtime Date.now()+Math.random() token/id patterns.
  • +
  • Security/Hooks transforms: enforce symlink-safe containment for webhook transform module paths (including hooks.transformsDir and hooks.mappings[].transform.module) by resolving existing-path ancestors via realpath before import, while preserving in-root symlink support; add regression coverage for both escape and allow cases. This ships in the next npm release. Thanks @aether-ai-agent for reporting.
  • +
  • Telegram/WSL2: disable autoSelectFamily by default on WSL2 and memoize WSL2 detection in Telegram network decision logic to avoid repeated sync /proc/version probes on fetch/send paths. (#21916) Thanks @MizukiMachine.
  • +
  • Telegram/Network: default Node 22+ DNS result ordering to ipv4first for Telegram fetch paths and add OPENCLAW_TELEGRAM_DNS_RESULT_ORDER/channels.telegram.network.dnsResultOrder overrides to reduce IPv6-path fetch failures. (#5405) Thanks @Glucksberg.
  • +
  • Telegram/Forward bursts: coalesce forwarded text+media updates through a dedicated forward lane debounce window that works with default inbound debounce config, while keeping forwarded control commands immediate. (#19476) thanks @napetrov.
  • +
  • Telegram/Streaming: preserve archived draft preview mapping after flush and clean superseded reasoning preview bubbles so multi-message preview finals no longer cross-edit or orphan stale messages under send/rotation races. (#23202) Thanks @obviyus.
  • +
  • Telegram/Replies: scope messaging-tool text/media dedupe to same-target sends only, so cross-target tool sends can no longer silently suppress Telegram final replies.
  • +
  • Telegram/Replies: normalize file:// and local-path media variants during messaging dedupe so equivalent media paths do not produce duplicate Telegram replies.
  • +
  • Telegram/Replies: extract forwarded-origin context from unified reply targets (reply_to_message and external_reply) so forward+comment metadata is preserved across partial reply shapes. (#9720) thanks @mcaxtr.
  • +
  • Telegram/Polling: persist a safe update-offset watermark bounded by pending updates so crash/restart cannot skip queued lower update_id updates after out-of-order completion. (#23284) thanks @frankekn.
  • +
  • Telegram/Polling: force-restart stuck runner instances when recoverable unhandled network rejections escape the polling task path, so polling resumes instead of silently stalling. (#19721) Thanks @jg-noncelogic.
  • +
  • Slack/Slash commands: preserve the Bolt app receiver when registering external select options handlers so monitor startup does not crash on runtimes that require bound app.options calls. (#23209) Thanks @0xgaia.
  • +
  • Slack/Telegram slash sessions: await session metadata persistence before dispatch so first-turn native slash runs do not race session-origin metadata updates. (#23065) thanks @hydro13.
  • +
  • Slack/Queue routing: preserve string thread_ts values through collect-mode queue drain and DM deliveryContext updates so threaded follow-ups do not leak to the main channel when Slack thread IDs are strings. (#11934) Thanks @sandieman2 and @vincentkoc.
  • +
  • Telegram/Native commands: set ctx.Provider="telegram" for native slash-command context so elevated gate checks resolve provider correctly (fixes provider (ctx.Provider) failures in /elevated flows). (#23748) Thanks @serhii12.
  • +
  • Agents/Ollama: preserve unsafe integer tool-call arguments as exact strings during NDJSON parsing, preventing large numeric IDs from being rounded before tool execution. (#23170) Thanks @BestJoester.
  • +
  • Cron/Gateway: keep cron.list and cron.status responsive during startup catch-up by avoiding a long-held cron lock while missed jobs execute. (#23106) Thanks @jayleekr.
  • +
  • Gateway/Config reload: compare array-valued config paths structurally during diffing so unchanged memory.qmd.paths and memory.qmd.scope.rules no longer trigger false restart-required reloads. (#23185) Thanks @rex05ai.
  • +
  • Gateway/Config reload: retry short-lived missing config snapshots during reload before skipping, preventing atomic-write unlink windows from triggering restart loops. (#23343) Thanks @lbo728.
  • +
  • Cron/Scheduling: validate runtime cron expressions before schedule/stagger evaluation so malformed persisted jobs report a clear invalid cron schedule: expr is required error instead of crashing with undefined.trim failures and auto-disable churn. (#23223) Thanks @asimons81.
  • +
  • Memory/QMD: migrate legacy unscoped collection bindings (for example memory-root) to per-agent scoped names (for example memory-root-main) during startup when safe, so QMD-backed memory_search no longer fails with Collection not found after upgrades. (#23228, #20727) Thanks @JLDynamics and @AaronFaby.
  • +
  • Memory/QMD: normalize Han-script BM25 search queries before invoking qmd search so mixed CJK+Latin prompts no longer return empty results due to tokenizer mismatch. (#23426) Thanks @LunaLee0130.
  • +
  • TUI/Input: enable multiline-paste burst coalescing on macOS Terminal.app and iTerm so pasted blocks no longer submit line-by-line as separate messages. (#18809) Thanks @fwends.
  • +
  • TUI/RTL: isolate right-to-left script lines (Arabic/Hebrew ranges) with Unicode bidi isolation marks in TUI text sanitization so RTL assistant output no longer renders in reversed visual order in terminal chat panes. (#21936) Thanks @Asm3r96.
  • +
  • TUI/Status: request immediate renders after setting sending/waiting activity states so in-flight runs always show visible progress indicators instead of appearing idle until completion. (#21549) Thanks @13Guinness.
  • +
  • TUI/Input: arm Ctrl+C exit timing when clearing non-empty composer text and add a SIGINT fallback path so double Ctrl+C exits remain responsive during active runs instead of requiring an extra press or appearing stuck. (#23407) Thanks @tinybluedev.
  • +
  • Agents/Fallbacks: treat JSON payloads with type: "api_error" + "Internal server error" as transient failover errors so Anthropic 500-style failures trigger model fallback. (#23193) Thanks @jarvis-lane.
  • +
  • Agents/Google: sanitize non-base64 thought_signature/thoughtSignature values from assistant replay transcripts for native Google Gemini requests while preserving valid signatures and tool-call order. (#23457) Thanks @echoVic.
  • +
  • Agents/Transcripts: validate assistant tool-call names (syntax/length + registered tool allowlist) before transcript persistence and during replay sanitization so malformed failover tool names no longer poison sessions with repeated provider HTTP 400 errors. (#23324) Thanks @johnsantry.
  • +
  • Agents/Mistral: sanitize tool-call IDs in the embedded agent loop and generate strict provider-safe pending tool-call IDs, preventing Mistral strict9 HTTP 400 failures on tool continuations. (#23698) Thanks @echoVic.
  • +
  • Agents/Compaction: strip stale assistant usage snapshots from pre-compaction turns when replaying history after a compaction summary so context-token estimation no longer reuses pre-compaction totals and immediately re-triggers destructive follow-up compactions. (#19127) Thanks @tedwatson.
  • +
  • Agents/Replies: emit a default completion acknowledgement (✅ Done.) only for direct/private tool-only completions with no final assistant text, while suppressing synthetic acknowledgements for channel/group sessions and runs that already delivered output via messaging tools. (#22834) Thanks @Oldshue.
  • +
  • Agents/Subagents: honor tools.subagents.tools.alsoAllow and explicit subagent allow entries when resolving built-in subagent deny defaults, so explicitly granted tools (for example sessions_send) are no longer blocked unless re-denied in tools.subagents.tools.deny. (#23359) Thanks @goren-beehero.
  • +
  • Agents/Subagents: make announce call timeouts configurable via agents.defaults.subagents.announceTimeoutMs and restore a 60s default to prevent false timeout failures on slower announce paths. (#22719) Thanks @Valadon.
  • +
  • Agents/Diagnostics: include resolved lifecycle error text in embedded run agent end warnings so UI/TUI “Connection error” runs expose actionable provider failure reasons in gateway logs. (#23054) Thanks @Raize.
  • +
  • Agents/Auth profiles: skip auth-profile cooldown writes for timeout failures in embedded runner rotation so model/network timeouts do not poison same-provider fallback model selection while still allowing in-turn account rotation. (#22622) Thanks @vageeshkumar.
  • +
  • Plugins/Hooks: run legacy before_agent_start once per agent turn and reuse that result across model-resolve and prompt-build compatibility paths, preventing duplicate hook side effects (for example duplicate external API calls). (#23289) Thanks @ksato8710.
  • +
  • Models/Config: default missing Anthropic provider/model api fields to anthropic-messages during config validation so custom relay model entries are preserved instead of being dropped by runtime model registry validation. (#23332) Thanks @bigbigmonkey123.
  • +
  • Gateway/Pairing: preserve existing approved token scopes when processing repair pairings that omit scopes, preventing empty-scope token regressions on reconnecting clients. (#21906) Thanks @paki81.
  • +
  • Memory/QMD: add optional memory.qmd.mcporter search routing so QMD query/search/vsearch can run through mcporter keep-alive flows (including multi-collection paths) to reduce cold starts, while keeping searches on agent-scoped QMD state for consistent recall. (#19617) Thanks @nicole-luxe and @vignesh07.
  • +
  • Infra/Network: classify undici TypeError: fetch failed as transient in unhandled-rejection detection even when nested causes are unclassified, preventing avoidable gateway crash loops on flaky networks. (#14345) Thanks @Unayung.
  • +
  • Telegram/Retry: classify undici TypeError: fetch failed as recoverable in both polling and send retry paths so transient fetch failures no longer fail fast. (#16699) thanks @Glucksberg.
  • +
  • Docs/Telegram: correct Node 22+ network defaults (autoSelectFamily, dnsResultOrder) and clarify Telegram setup does not use positional openclaw channels login telegram. (#23609) Thanks @ryanbastic.
  • +
  • BlueBubbles/DM history: restore DM backfill context with account-scoped rolling history, bounded backfill retries, and safer history payload limits. (#20302) Thanks @Ryan-Haines.
  • +
  • BlueBubbles/Private API cache: treat unknown (null) private-API cache status as disabled for send/attachment/reply flows to avoid stale-cache 500s, and log a warning when reply/effect features are requested while capability is unknown. (#23459) Thanks @echoVic.
  • +
  • BlueBubbles/Webhooks: accept inbound/reaction webhook payloads when BlueBubbles omits handle but provides DM chatGuid, and harden payload extraction for array/string-wrapped message bodies so valid webhook events no longer get rejected as unparseable. (#23275) Thanks @toph31.
  • +
  • Security/Audit: add openclaw security audit finding gateway.nodes.allow_commands_dangerous for risky gateway.nodes.allowCommands overrides, with severity upgraded to critical on remote gateway exposure.
  • +
  • Gateway/Control plane: reduce cross-client write limiter contention by adding connId fallback keying when device ID and client IP are both unavailable.
  • +
  • Security/Config: block prototype-key traversal during config merge patch and legacy migration merge helpers (__proto__, constructor, prototype) to prevent prototype pollution during config mutation flows. (#22968) Thanks @Clawborn.
  • +
  • Security/Shell env: validate login-shell executable paths for shell-env fallback (/etc/shells + trusted prefixes), block SHELL/HOME/ZDOTDIR in config env ingestion before fallback execution, and sanitize fallback shell exec env to pin HOME to the real user home while dropping ZDOTDIR and other dangerous startup vars. This ships in the next npm release. Thanks @tdjackey for reporting.
  • +
  • Network/SSRF: enable autoSelectFamily on pinned undici dispatchers (with attempt timeout) so IPv6-unreachable environments can quickly fall back to IPv4 for guarded fetch paths. (#19950) Thanks @ENAwareness.
  • +
  • Security/Config: make parsed chat allowlist checks fail closed when allowFrom is empty, restoring expected DM/pairing gating.
  • +
  • Security/Exec: in non-default setups that manually add sort to tools.exec.safeBins, block sort --compress-program so allowlist-mode safe-bin checks cannot bypass approval. Thanks @tdjackey for reporting.
  • +
  • Security/Exec approvals: when users choose allow-always for shell-wrapper commands (for example /bin/zsh -lc ...), persist allowlist patterns for the inner executable(s) instead of the wrapper shell binary, preventing accidental broad shell allowlisting in moderate mode. (#23276) Thanks @xrom2863.
  • +
  • Security/Exec: fail closed when tools.exec.host=sandbox is configured/requested but sandbox runtime is unavailable. (#23398) Thanks @bmendonca3.
  • +
  • Security/macOS app beta: enforce path-only system.run allowlist matching (drop basename matches like echo), migrate legacy basename entries to last resolved paths when available, and harden shell-chain handling to fail closed on unsafe parse/control syntax (including quoted command substitution/backticks). This is an optional allowlist-mode feature; default installs remain deny-by-default. This ships in the next npm release. Thanks @tdjackey for reporting.
  • +
  • Security/Agents: auto-generate and persist a dedicated commands.ownerDisplaySecret when commands.ownerDisplay=hash, remove gateway token fallback from owner-ID prompt hashing across CLI and embedded agent runners, and centralize owner-display secret resolution in one shared helper. This ships in the next npm release. Thanks @aether-ai-agent for reporting.
  • +
  • Security/SSRF: expand IPv4 fetch guard blocking to include RFC special-use/non-global ranges (including benchmarking, TEST-NET, multicast, and reserved/broadcast blocks), centralize range checks into a single CIDR policy table, and reuse one shared host/IP classifier across literal + DNS checks to reduce classifier drift. This ships in the next npm release. Thanks @princeeismond-dot for reporting.
  • +
  • Security/SSRF: block RFC2544 benchmarking range (198.18.0.0/15) across direct and embedded-IP paths, and normalize IPv6 dotted-quad transition literals (for example ::127.0.0.1, 64:ff9b::8.8.8.8) in shared IP parsing/classification.
  • +
  • Security/Archive: block zip symlink escapes during archive extraction.
  • +
  • Security/Media sandbox: keep tmp media allowance for absolute tmp paths only and enforce symlink-escape checks before sandbox-validated reads, preventing tmp symlink exfiltration and relative ../ sandbox escapes when sandboxes live under tmp. (#17892) Thanks @dashed.
  • +
  • Browser/Upload: accept canonical in-root upload paths when the configured uploads directory is a symlink alias (for example /tmp -> /private/tmp on macOS), so browser upload validation no longer rejects valid files during client->server revalidation. (#23300, #23222, #22848) Thanks @bgaither4, @parkerati, and @Nabsku.
  • +
  • Security/Discord: add openclaw security audit warnings for name/tag-based Discord allowlist entries (DM allowlists, guild/channel users, and pairing-store entries), highlighting slug-collision risk while keeping name-based matching supported, and canonicalize resolved Discord allowlist names to IDs at runtime without rewriting config files. Thanks @tdjackey for reporting.
  • +
  • Security/Gateway: block node-role connections when device identity metadata is missing.
  • +
  • Security/Media: enforce inbound media byte limits during download/read across Discord, Telegram, Zalo, Microsoft Teams, and BlueBubbles to prevent oversized payload memory spikes before rejection. This ships in the next npm release. Thanks @tdjackey for reporting.
  • +
  • Media/Understanding: preserve application/pdf MIME classification during text-like file heuristics so PDF uploads use PDF extraction paths instead of being inlined as raw text. (#23191) Thanks @claudeplay2026-byte.
  • +
  • Security/Control UI: block symlink-based out-of-root static file reads by enforcing realpath containment and file-identity checks when serving Control UI assets and SPA fallback index.html. This ships in the next npm release. Thanks @tdjackey for reporting.
  • +
  • Security/Gateway avatars: block symlink traversal during local avatar data: URL resolution by enforcing realpath containment and file-identity checks before reads. This ships in the next npm release. Thanks @tdjackey for reporting.
  • +
  • Security/Control UI: centralize avatar URL/path validation across gateway/config helpers and enforce a 2 MB max size for local agent avatar files before /avatar resolution, reducing oversized-avatar memory risk without changing supported avatar formats.
  • +
  • Security/Control UI avatars: harden /avatar/:agentId local avatar serving by rejecting symlink paths and requiring fd-level file identity + size checks before reads. This ships in the next npm release. Thanks @tdjackey for reporting.
  • +
  • Security/MSTeams media: enforce allowlist checks for SharePoint reference attachment URLs and redirect targets during Graph-backed media fetches so redirect chains cannot escape configured media host boundaries. This ships in the next npm release. Thanks @tdjackey for reporting.
  • +
  • Security/MSTeams media: route attachment auth-retry and Graph SharePoint download redirects through shared safeFetch so each hop is validated with allowlist + DNS/IP checks across the full redirect chain. (#23598) Thanks @Asm3r96 and @lewiswigmore.
  • +
  • Security/macOS discovery: fail closed for unresolved discovery endpoints by clearing stale remote selection values, use resolved service host only for SSH target derivation, and keep remote URL config aligned with resolved endpoint availability. (#21618) Thanks @bmendonca3.
  • +
  • Chat/Usage/TUI: strip synthetic inbound metadata blocks (including Conversation info and trailing Untrusted context channel metadata wrappers) from displayed conversation history so internal prompt context no longer leaks into user-visible logs.
  • +
  • CI/Tests: fix TypeScript case-table typing and lint assertion regressions so pnpm check passes again after Synology Chat landing. (#23012) Thanks @druide67.
  • +
  • Security/Browser relay: harden extension relay auth token handling for /extension and /cdp pathways.
  • +
  • Cron: persist delivered state in cron job records so delivery failures remain visible in status and logs. (#19174) Thanks @simonemacario.
  • +
  • Config/Doctor: only repair the OAuth credentials directory when affected channels are configured, avoiding fresh-install noise.
  • +
  • Config/Channels: whitelist channels.modelByChannel in config validation and exclude it from plugin auto-enable channel detection so model overrides no longer trigger unknown channel id validation errors or bogus modelByChannel plugin enables. (#23412) Thanks @ProspectOre.
  • +
  • Config/Bindings: allow optional bindings[].comment in strict config validation so annotated binding entries no longer fail load. (#23458) Thanks @echoVic.
  • +
  • Usage/Pricing: correct MiniMax M2.5 pricing defaults to fix inflated cost reporting. (#22755) Thanks @miloudbelarebia.
  • +
  • Gateway/Daemon: verify gateway health after daemon restart.
  • +
  • Agents/UI text: stop rewriting normal assistant billing/payment language outside explicit error contexts. (#17834) Thanks @niceysam.

View full changelog

]]>
- +
\ No newline at end of file diff --git a/apps/android/app/build.gradle.kts b/apps/android/app/build.gradle.kts index 148b2e58a75..52e1014e7ba 100644 --- a/apps/android/app/build.gradle.kts +++ b/apps/android/app/build.gradle.kts @@ -21,8 +21,8 @@ android { applicationId = "ai.openclaw.android" minSdk = 31 targetSdk = 36 - versionCode = 202602160 - versionName = "2026.2.16" + versionCode = 202602230 + versionName = "2026.2.23" ndk { // Support all major ABIs — native libs are tiny (~47 KB per ABI) abiFilters += listOf("armeabi-v7a", "arm64-v8a", "x86", "x86_64") diff --git a/apps/android/app/src/main/java/ai/openclaw/android/gateway/GatewaySession.kt b/apps/android/app/src/main/java/ai/openclaw/android/gateway/GatewaySession.kt index 091e735530d..0f49541daff 100644 --- a/apps/android/app/src/main/java/ai/openclaw/android/gateway/GatewaySession.kt +++ b/apps/android/app/src/main/java/ai/openclaw/android/gateway/GatewaySession.kt @@ -178,7 +178,7 @@ class GatewaySession( private val connectDeferred = CompletableDeferred() private val closedDeferred = CompletableDeferred() private val isClosed = AtomicBoolean(false) - private val connectNonceDeferred = CompletableDeferred() + private val connectNonceDeferred = CompletableDeferred() private val client: OkHttpClient = buildClient() private var socket: WebSocket? = null private val loggerTag = "OpenClawGateway" @@ -296,7 +296,7 @@ class GatewaySession( } } - private suspend fun sendConnect(connectNonce: String?) { + private suspend fun sendConnect(connectNonce: String) { val identity = identityStore.loadOrCreate() val storedToken = deviceAuthStore.loadToken(identity.deviceId, options.role) val trimmedToken = token?.trim().orEmpty() @@ -332,7 +332,7 @@ class GatewaySession( private fun buildConnectParams( identity: DeviceIdentity, - connectNonce: String?, + connectNonce: String, authToken: String, authPassword: String?, ): JsonObject { @@ -385,9 +385,7 @@ class GatewaySession( put("publicKey", JsonPrimitive(publicKey)) put("signature", JsonPrimitive(signature)) put("signedAt", JsonPrimitive(signedAtMs)) - if (!connectNonce.isNullOrBlank()) { - put("nonce", JsonPrimitive(connectNonce)) - } + put("nonce", JsonPrimitive(connectNonce)) } } else { null @@ -447,8 +445,8 @@ class GatewaySession( frame["payload"]?.let { it.toString() } ?: frame["payloadJSON"].asStringOrNull() if (event == "connect.challenge") { val nonce = extractConnectNonce(payloadJson) - if (!connectNonceDeferred.isCompleted) { - connectNonceDeferred.complete(nonce) + if (!connectNonceDeferred.isCompleted && !nonce.isNullOrBlank()) { + connectNonceDeferred.complete(nonce.trim()) } return } @@ -459,12 +457,11 @@ class GatewaySession( onEvent(event, payloadJson) } - private suspend fun awaitConnectNonce(): String? { - if (isLoopbackHost(endpoint.host)) return null + private suspend fun awaitConnectNonce(): String { return try { withTimeout(2_000) { connectNonceDeferred.await() } - } catch (_: Throwable) { - null + } catch (err: Throwable) { + throw IllegalStateException("connect challenge timeout", err) } } @@ -595,14 +592,13 @@ class GatewaySession( scopes: List, signedAtMs: Long, token: String?, - nonce: String?, + nonce: String, ): String { val scopeString = scopes.joinToString(",") val authToken = token.orEmpty() - val version = if (nonce.isNullOrBlank()) "v1" else "v2" val parts = mutableListOf( - version, + "v2", deviceId, clientId, clientMode, @@ -610,10 +606,8 @@ class GatewaySession( scopeString, signedAtMs.toString(), authToken, + nonce, ) - if (!nonce.isNullOrBlank()) { - parts.add(nonce) - } return parts.joinToString("|") } diff --git a/apps/ios/.swiftlint.yml b/apps/ios/.swiftlint.yml index fc8509c8385..23db4515968 100644 --- a/apps/ios/.swiftlint.yml +++ b/apps/ios/.swiftlint.yml @@ -3,3 +3,7 @@ parent_config: ../../.swiftlint.yml included: - Sources - ../shared/ClawdisNodeKit/Sources + +type_body_length: + warning: 900 + error: 1300 diff --git a/apps/ios/Config/Signing.xcconfig b/apps/ios/Config/Signing.xcconfig new file mode 100644 index 00000000000..e0afd46aa7e --- /dev/null +++ b/apps/ios/Config/Signing.xcconfig @@ -0,0 +1,18 @@ +// Shared iOS signing defaults for local development + CI. +OPENCLAW_IOS_DEFAULT_TEAM = Y5PE65HELJ +OPENCLAW_IOS_SELECTED_TEAM = $(OPENCLAW_IOS_DEFAULT_TEAM) +OPENCLAW_APP_BUNDLE_ID = ai.openclaw.ios +OPENCLAW_WATCH_APP_BUNDLE_ID = ai.openclaw.ios.watchkitapp +OPENCLAW_WATCH_EXTENSION_BUNDLE_ID = ai.openclaw.ios.watchkitapp.extension + +// Local contributors can override this by running scripts/ios-configure-signing.sh. +// Keep include after defaults: xcconfig is evaluated top-to-bottom. +#include? "../.local-signing.xcconfig" +#include? "../LocalSigning.xcconfig" + +CODE_SIGN_STYLE = Automatic +CODE_SIGN_IDENTITY = Apple Development +DEVELOPMENT_TEAM = $(OPENCLAW_IOS_SELECTED_TEAM) + +// Let Xcode manage provisioning for the selected local team. +PROVISIONING_PROFILE_SPECIFIER = diff --git a/apps/ios/LocalSigning.xcconfig.example b/apps/ios/LocalSigning.xcconfig.example new file mode 100644 index 00000000000..bfa610fb350 --- /dev/null +++ b/apps/ios/LocalSigning.xcconfig.example @@ -0,0 +1,14 @@ +// Copy to LocalSigning.xcconfig for personal local signing overrides. +// This file is only an example and should stay committed. + +OPENCLAW_CODE_SIGN_STYLE = Automatic +OPENCLAW_DEVELOPMENT_TEAM = P5Z8X89DJL + +OPENCLAW_APP_BUNDLE_ID = ai.openclaw.ios.test.mariano +OPENCLAW_SHARE_BUNDLE_ID = ai.openclaw.ios.test.mariano.share +OPENCLAW_WATCH_APP_BUNDLE_ID = ai.openclaw.ios.test.mariano.watchkitapp +OPENCLAW_WATCH_EXTENSION_BUNDLE_ID = ai.openclaw.ios.test.mariano.watchkitapp.extension + +// Leave empty with automatic signing. +OPENCLAW_APP_PROFILE = +OPENCLAW_SHARE_PROFILE = diff --git a/apps/ios/README.md b/apps/ios/README.md index 2e426c18d70..c7c501fcbff 100644 --- a/apps/ios/README.md +++ b/apps/ios/README.md @@ -1,66 +1,141 @@ -# OpenClaw (iOS) +# OpenClaw iOS (Super Alpha) -This is an **alpha** iOS app that connects to an OpenClaw Gateway as a `role: node`. +NO TEST FLIGHT AVAILABLE AT THIS POINT -Expect rough edges: +This iPhone app is super-alpha and internal-use only. It connects to an OpenClaw Gateway as a `role: node`. -- UI and onboarding are changing quickly. -- Background behavior is not stable yet (foreground app is the supported mode right now). -- Permissions are opt-in and the app should be treated as sensitive while we harden it. +## Distribution Status -## What It Does +NO TEST FLIGHT AVAILABLE AT THIS POINT -- Connects to a Gateway over `ws://` / `wss://` -- Pairs a new device (approved from your bot) -- Exposes phone services as node commands (camera, location, photos, calendar, reminders, etc; gated by iOS permissions) -- Provides Talk + Chat surfaces (alpha) +- Current distribution: local/manual deploy from source via Xcode. +- App Store flow is not part of the current internal development path. -## Pairing (Recommended Flow) +## Super-Alpha Disclaimer -If your Gateway has the `device-pair` plugin installed: +- Breaking changes are expected. +- UI and onboarding flows can change without migration guarantees. +- Foreground use is the only reliable mode right now. +- Treat this build as sensitive while permissions and background behavior are still being hardened. -1. In Telegram, message your bot: `/pair` -2. Copy the **setup code** message -3. On iOS: OpenClaw → Settings → Gateway → paste setup code → Connect -4. Back in Telegram: `/pair approve` +## Exact Xcode Manual Deploy Flow -## Build And Run - -Prereqs: - -- Xcode (current stable) -- `pnpm` -- `xcodegen` - -From the repo root: +1. Prereqs: + - Xcode 16+ + - `pnpm` + - `xcodegen` + - Apple Development signing set up in Xcode +2. From repo root: ```bash pnpm install +./scripts/ios-configure-signing.sh +cd apps/ios +xcodegen generate +open OpenClaw.xcodeproj +``` + +3. In Xcode: + - Scheme: `OpenClaw` + - Destination: connected iPhone (recommended for real behavior) + - Build configuration: `Debug` + - Run (`Product` -> `Run`) +4. If signing fails on a personal team: + - Use unique local bundle IDs via `apps/ios/LocalSigning.xcconfig`. + - Start from `apps/ios/LocalSigning.xcconfig.example`. + +Shortcut command (same flow + open project): + +```bash pnpm ios:open ``` -Then in Xcode: +## APNs Expectations For Local/Manual Builds -1. Select the `OpenClaw` scheme -2. Select a simulator or a connected device -3. Run +- The app calls `registerForRemoteNotifications()` at launch. +- `apps/ios/Sources/OpenClaw.entitlements` sets `aps-environment` to `development`. +- APNs token registration to gateway happens only after gateway connection (`push.apns.register`). +- Your selected team/profile must support Push Notifications for the app bundle ID you are signing. +- If push capability or provisioning is wrong, APNs registration fails at runtime (check Xcode logs for `APNs registration failed`). +- Debug builds register as APNs sandbox; Release builds use production. -If you're using a personal Apple Development team, you may need to change the bundle identifier in Xcode to a unique value so signing succeeds. +## What Works Now (Concrete) -## Build From CLI +- Pairing via setup code flow (`/pair` then `/pair approve` in Telegram). +- Gateway connection via discovery or manual host/port with TLS fingerprint trust prompt. +- Chat + Talk surfaces through the operator gateway session. +- iPhone node commands in foreground: camera snap/clip, canvas present/navigate/eval/snapshot, screen record, location, contacts, calendar, reminders, photos, motion, local notifications. +- Share extension deep-link forwarding into the connected gateway session. -```bash -pnpm ios:build -``` +## Location Automation Use Case (Testing) -## Tests +Use this for automation signals ("I moved", "I arrived", "I left"), not as a keep-awake mechanism. -```bash -cd apps/ios -xcodegen generate -xcodebuild test -project OpenClaw.xcodeproj -scheme OpenClaw -destination "platform=iOS Simulator,name=iPhone 17" -``` +- Product intent: + - movement-aware automations driven by iOS location events + - example: arrival/exit geofence, significant movement, visit detection +- Non-goal: + - continuous GPS polling just to keep the app alive -## Shared Code +Test path to include in QA runs: -- `apps/shared/OpenClawKit` contains the shared transport/types used by the iOS app. +1. Enable location permission in app: + - set `Always` permission + - verify background location capability is enabled in the build profile +2. Background the app and trigger movement: + - walk/drive enough for a significant location update, or cross a configured geofence +3. Validate gateway side effects: + - node reconnect/wake if needed + - expected location/movement event arrives at gateway + - automation trigger executes once (no duplicate storm) +4. Validate resource impact: + - no sustained high thermal state + - no excessive background battery drain over a short observation window + +Pass criteria: + +- movement events are delivered reliably enough for automation UX +- no location-driven reconnect spam loops +- app remains stable after repeated background/foreground transitions + +## Known Issues / Limitations / Problems + +- Foreground-first: iOS can suspend sockets in background; reconnect recovery is still being tuned. +- Background command limits are strict: `canvas.*`, `camera.*`, `screen.*`, and `talk.*` are blocked when backgrounded. +- Background location requires `Always` location permission. +- Pairing/auth errors intentionally pause reconnect loops until a human fixes auth/pairing state. +- Voice Wake and Talk contend for the same microphone; Talk suppresses wake capture while active. +- APNs reliability depends on local signing/provisioning/topic alignment. +- Expect rough UX edges and occasional reconnect churn during active development. + +## Current In-Progress Workstream + +Automatic wake/reconnect hardening: + +- improve wake/resume behavior across scene transitions +- reduce dead-socket states after background -> foreground +- tighten node/operator session reconnect coordination +- reduce manual recovery steps after transient network failures + +## Debugging Checklist + +1. Confirm build/signing baseline: + - regenerate project (`xcodegen generate`) + - verify selected team + bundle IDs +2. In app `Settings -> Gateway`: + - confirm status text, server, and remote address + - verify whether status shows pairing/auth gating +3. If pairing is required: + - run `/pair approve` from Telegram, then reconnect +4. If discovery is flaky: + - enable `Discovery Debug Logs` + - inspect `Settings -> Gateway -> Discovery Logs` +5. If network path is unclear: + - switch to manual host/port + TLS in Gateway Advanced settings +6. In Xcode console, filter for subsystem/category signals: + - `ai.openclaw.ios` + - `GatewayDiag` + - `APNs registration failed` +7. Validate background expectations: + - repro in foreground first + - then test background transitions and confirm reconnect on return diff --git a/apps/ios/ShareExtension/Info.plist b/apps/ios/ShareExtension/Info.plist new file mode 100644 index 00000000000..aedea62a5e1 --- /dev/null +++ b/apps/ios/ShareExtension/Info.plist @@ -0,0 +1,45 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + OpenClaw Share + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + XPC! + CFBundleShortVersionString + 2026.2.23 + CFBundleVersion + 20260223 + NSExtension + + NSExtensionAttributes + + NSExtensionActivationRule + + NSExtensionActivationSupportsImageWithMaxCount + 10 + NSExtensionActivationSupportsMovieWithMaxCount + 1 + NSExtensionActivationSupportsText + + NSExtensionActivationSupportsWebURLWithMaxCount + 1 + + + NSExtensionPointIdentifier + com.apple.share-services + NSExtensionPrincipalClass + $(PRODUCT_MODULE_NAME).ShareViewController + + + diff --git a/apps/ios/ShareExtension/ShareViewController.swift b/apps/ios/ShareExtension/ShareViewController.swift new file mode 100644 index 00000000000..1181641e330 --- /dev/null +++ b/apps/ios/ShareExtension/ShareViewController.swift @@ -0,0 +1,548 @@ +import Foundation +import OpenClawKit +import os +import UIKit +import UniformTypeIdentifiers + +final class ShareViewController: UIViewController { + private struct ShareAttachment: Codable { + var type: String + var mimeType: String + var fileName: String + var content: String + } + + private struct ExtractedShareContent { + var payload: SharedContentPayload + var attachments: [ShareAttachment] + } + + private let logger = Logger(subsystem: "ai.openclaw.ios", category: "ShareExtension") + private var statusLabel: UILabel? + private let draftTextView = UITextView() + private let sendButton = UIButton(type: .system) + private let cancelButton = UIButton(type: .system) + private var didPrepareDraft = false + private var isSending = false + private var pendingAttachments: [ShareAttachment] = [] + + override func viewDidLoad() { + super.viewDidLoad() + self.preferredContentSize = CGSize(width: UIScreen.main.bounds.width, height: 420) + self.setupUI() + } + + override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + guard !self.didPrepareDraft else { return } + self.didPrepareDraft = true + Task { await self.prepareDraft() } + } + + private func setupUI() { + self.view.backgroundColor = .systemBackground + + self.draftTextView.translatesAutoresizingMaskIntoConstraints = false + self.draftTextView.font = .preferredFont(forTextStyle: .body) + self.draftTextView.backgroundColor = UIColor.secondarySystemBackground + self.draftTextView.layer.cornerRadius = 10 + self.draftTextView.textContainerInset = UIEdgeInsets(top: 12, left: 10, bottom: 12, right: 10) + + self.sendButton.translatesAutoresizingMaskIntoConstraints = false + self.sendButton.setTitle("Send to OpenClaw", for: .normal) + self.sendButton.titleLabel?.font = .preferredFont(forTextStyle: .headline) + self.sendButton.addTarget(self, action: #selector(self.handleSendTap), for: .touchUpInside) + self.sendButton.isEnabled = false + + self.cancelButton.translatesAutoresizingMaskIntoConstraints = false + self.cancelButton.setTitle("Cancel", for: .normal) + self.cancelButton.addTarget(self, action: #selector(self.handleCancelTap), for: .touchUpInside) + + let buttons = UIStackView(arrangedSubviews: [self.cancelButton, self.sendButton]) + buttons.translatesAutoresizingMaskIntoConstraints = false + buttons.axis = .horizontal + buttons.alignment = .fill + buttons.distribution = .fillEqually + buttons.spacing = 12 + + self.view.addSubview(self.draftTextView) + self.view.addSubview(buttons) + + NSLayoutConstraint.activate([ + self.draftTextView.topAnchor.constraint(equalTo: self.view.safeAreaLayoutGuide.topAnchor, constant: 14), + self.draftTextView.leadingAnchor.constraint(equalTo: self.view.leadingAnchor, constant: 14), + self.draftTextView.trailingAnchor.constraint(equalTo: self.view.trailingAnchor, constant: -14), + self.draftTextView.bottomAnchor.constraint(equalTo: buttons.topAnchor, constant: -12), + + buttons.leadingAnchor.constraint(equalTo: self.view.leadingAnchor, constant: 14), + buttons.trailingAnchor.constraint(equalTo: self.view.trailingAnchor, constant: -14), + buttons.bottomAnchor.constraint(equalTo: self.view.keyboardLayoutGuide.topAnchor, constant: -8), + buttons.heightAnchor.constraint(equalToConstant: 44), + ]) + } + + private func prepareDraft() async { + let traceId = UUID().uuidString + ShareGatewayRelaySettings.saveLastEvent("Share opened.") + self.showStatus("Preparing share…") + self.logger.info("share begin trace=\(traceId, privacy: .public)") + let extracted = await self.extractSharedContent() + let payload = extracted.payload + self.pendingAttachments = extracted.attachments + self.logger.info( + "share payload trace=\(traceId, privacy: .public) titleChars=\(payload.title?.count ?? 0) textChars=\(payload.text?.count ?? 0) hasURL=\(payload.url != nil) imageAttachments=\(self.pendingAttachments.count)" + ) + let message = self.composeDraft(from: payload) + await MainActor.run { + self.draftTextView.text = message + self.sendButton.isEnabled = true + self.draftTextView.becomeFirstResponder() + } + if message.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + ShareGatewayRelaySettings.saveLastEvent("Share ready: waiting for message input.") + self.showStatus("Add a message, then tap Send.") + } else { + ShareGatewayRelaySettings.saveLastEvent("Share ready: draft prepared.") + self.showStatus("Edit text, then tap Send.") + } + } + + @objc + private func handleSendTap() { + guard !self.isSending else { return } + Task { await self.sendCurrentDraft() } + } + + @objc + private func handleCancelTap() { + self.extensionContext?.completeRequest(returningItems: nil) + } + + private func sendCurrentDraft() async { + let message = await MainActor.run { self.draftTextView.text ?? "" } + let trimmed = message.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { + ShareGatewayRelaySettings.saveLastEvent("Share blocked: message is empty.") + self.showStatus("Message is empty.") + return + } + + await MainActor.run { + self.isSending = true + self.sendButton.isEnabled = false + self.cancelButton.isEnabled = false + } + self.showStatus("Sending to OpenClaw gateway…") + ShareGatewayRelaySettings.saveLastEvent("Sending to gateway…") + do { + try await self.sendMessageToGateway(trimmed, attachments: self.pendingAttachments) + ShareGatewayRelaySettings.saveLastEvent( + "Sent to gateway (\(trimmed.count) chars, \(self.pendingAttachments.count) attachment(s)).") + self.showStatus("Sent to OpenClaw.") + DispatchQueue.main.asyncAfter(deadline: .now() + 0.45) { + self.extensionContext?.completeRequest(returningItems: nil) + } + } catch { + self.logger.error("share send failed reason=\(error.localizedDescription, privacy: .public)") + ShareGatewayRelaySettings.saveLastEvent("Send failed: \(error.localizedDescription)") + self.showStatus("Send failed: \(error.localizedDescription)") + await MainActor.run { + self.isSending = false + self.sendButton.isEnabled = true + self.cancelButton.isEnabled = true + } + } + } + + private func sendMessageToGateway(_ message: String, attachments: [ShareAttachment]) async throws { + guard let config = ShareGatewayRelaySettings.loadConfig() else { + throw NSError( + domain: "OpenClawShare", + code: 10, + userInfo: [NSLocalizedDescriptionKey: "OpenClaw is not connected to a gateway yet."]) + } + guard let url = URL(string: config.gatewayURLString) else { + throw NSError( + domain: "OpenClawShare", + code: 11, + userInfo: [NSLocalizedDescriptionKey: "Invalid saved gateway URL."]) + } + + let gateway = GatewayNodeSession() + defer { + Task { await gateway.disconnect() } + } + let makeOptions: (String) -> GatewayConnectOptions = { clientId in + GatewayConnectOptions( + role: "node", + scopes: [], + caps: [], + commands: [], + permissions: [:], + clientId: clientId, + clientMode: "node", + clientDisplayName: "OpenClaw Share", + includeDeviceIdentity: false) + } + + do { + try await gateway.connect( + url: url, + token: config.token, + password: config.password, + connectOptions: makeOptions("openclaw-ios"), + sessionBox: nil, + onConnected: {}, + onDisconnected: { _ in }, + onInvoke: { req in + BridgeInvokeResponse( + id: req.id, + ok: false, + error: OpenClawNodeError( + code: .invalidRequest, + message: "share extension does not support node invoke")) + }) + } catch { + let expectsLegacyClientId = self.shouldRetryWithLegacyClientId(error) + guard expectsLegacyClientId else { throw error } + try await gateway.connect( + url: url, + token: config.token, + password: config.password, + connectOptions: makeOptions("moltbot-ios"), + sessionBox: nil, + onConnected: {}, + onDisconnected: { _ in }, + onInvoke: { req in + BridgeInvokeResponse( + id: req.id, + ok: false, + error: OpenClawNodeError( + code: .invalidRequest, + message: "share extension does not support node invoke")) + }) + } + + struct AgentRequestPayload: Codable { + var message: String + var sessionKey: String? + var thinking: String + var deliver: Bool + var attachments: [ShareAttachment]? + var receipt: Bool + var receiptText: String? + var to: String? + var channel: String? + var timeoutSeconds: Int? + var key: String? + } + + let deliveryChannel = config.deliveryChannel?.trimmingCharacters(in: .whitespacesAndNewlines) + let deliveryTo = config.deliveryTo?.trimmingCharacters(in: .whitespacesAndNewlines) + let canDeliverToRoute = (deliveryChannel?.isEmpty == false) && (deliveryTo?.isEmpty == false) + + let params = AgentRequestPayload( + message: message, + sessionKey: config.sessionKey, + thinking: "low", + deliver: canDeliverToRoute, + attachments: attachments.isEmpty ? nil : attachments, + receipt: canDeliverToRoute, + receiptText: canDeliverToRoute ? "Just received your iOS share + request, working on it." : nil, + to: canDeliverToRoute ? deliveryTo : nil, + channel: canDeliverToRoute ? deliveryChannel : nil, + timeoutSeconds: nil, + key: UUID().uuidString) + let data = try JSONEncoder().encode(params) + guard let json = String(data: data, encoding: .utf8) else { + throw NSError( + domain: "OpenClawShare", + code: 12, + userInfo: [NSLocalizedDescriptionKey: "Failed to encode chat payload."]) + } + struct NodeEventParams: Codable { + var event: String + var payloadJSON: String + } + let eventData = try JSONEncoder().encode(NodeEventParams(event: "agent.request", payloadJSON: json)) + guard let nodeEventParams = String(data: eventData, encoding: .utf8) else { + throw NSError( + domain: "OpenClawShare", + code: 13, + userInfo: [NSLocalizedDescriptionKey: "Failed to encode node event payload."]) + } + _ = try await gateway.request(method: "node.event", paramsJSON: nodeEventParams, timeoutSeconds: 25) + } + + private func shouldRetryWithLegacyClientId(_ error: Error) -> Bool { + if let gatewayError = error as? GatewayResponseError { + let code = gatewayError.code.lowercased() + let message = gatewayError.message.lowercased() + let pathValue = (gatewayError.details["path"]?.value as? String)?.lowercased() ?? "" + let mentionsClientIdPath = + message.contains("/client/id") || message.contains("client id") + || pathValue.contains("/client/id") + let isInvalidConnectParams = + (code.contains("invalid") && code.contains("connect")) + || message.contains("invalid connect params") + if isInvalidConnectParams && mentionsClientIdPath { + return true + } + } + + let text = error.localizedDescription.lowercased() + return text.contains("invalid connect params") + && (text.contains("/client/id") || text.contains("client id")) + } + + private func showStatus(_ text: String) { + DispatchQueue.main.async { + let label: UILabel + if let existing = self.statusLabel { + label = existing + } else { + let newLabel = UILabel() + newLabel.translatesAutoresizingMaskIntoConstraints = false + newLabel.numberOfLines = 0 + newLabel.textAlignment = .center + newLabel.font = .preferredFont(forTextStyle: .body) + newLabel.textColor = .label + newLabel.backgroundColor = UIColor.systemBackground.withAlphaComponent(0.92) + newLabel.layer.cornerRadius = 12 + newLabel.clipsToBounds = true + newLabel.layoutMargins = UIEdgeInsets(top: 12, left: 14, bottom: 12, right: 14) + self.view.addSubview(newLabel) + NSLayoutConstraint.activate([ + newLabel.leadingAnchor.constraint(equalTo: self.view.leadingAnchor, constant: 18), + newLabel.trailingAnchor.constraint(equalTo: self.view.trailingAnchor, constant: -18), + newLabel.bottomAnchor.constraint(equalTo: self.sendButton.topAnchor, constant: -10), + ]) + self.statusLabel = newLabel + label = newLabel + } + label.text = " \(text) " + } + } + + private func composeDraft(from payload: SharedContentPayload) -> String { + var lines: [String] = [] + let title = self.sanitizeDraftFragment(payload.title) + let text = self.sanitizeDraftFragment(payload.text) + let url = payload.url?.absoluteString.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + + if let title, !title.isEmpty { lines.append(title) } + if let text, !text.isEmpty { lines.append(text) } + if !url.isEmpty { lines.append(url) } + + return lines.joined(separator: "\n\n") + } + + private func sanitizeDraftFragment(_ raw: String?) -> String? { + guard let raw else { return nil } + let banned = [ + "shared from ios.", + "text:", + "shared attachment(s):", + "please help me with this.", + "please help me with this.w", + ] + let cleanedLines = raw + .components(separatedBy: .newlines) + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + .filter { line in + guard !line.isEmpty else { return false } + let lowered = line.lowercased() + return !banned.contains { lowered == $0 || lowered.hasPrefix($0) } + } + let cleaned = cleanedLines.joined(separator: "\n").trimmingCharacters(in: .whitespacesAndNewlines) + return cleaned.isEmpty ? nil : cleaned + } + + private func extractSharedContent() async -> ExtractedShareContent { + guard let items = self.extensionContext?.inputItems as? [NSExtensionItem] else { + return ExtractedShareContent( + payload: SharedContentPayload(title: nil, url: nil, text: nil), + attachments: []) + } + + var title: String? + var sharedURL: URL? + var sharedText: String? + var imageCount = 0 + var videoCount = 0 + var fileCount = 0 + var unknownCount = 0 + var attachments: [ShareAttachment] = [] + let maxImageAttachments = 3 + + for item in items { + if title == nil { + title = item.attributedTitle?.string ?? item.attributedContentText?.string + } + + for provider in item.attachments ?? [] { + if sharedURL == nil { + sharedURL = await self.loadURL(from: provider) + } + + if sharedText == nil { + sharedText = await self.loadText(from: provider) + } + + if provider.hasItemConformingToTypeIdentifier(UTType.image.identifier) { + imageCount += 1 + if attachments.count < maxImageAttachments, + let attachment = await self.loadImageAttachment(from: provider, index: attachments.count) + { + attachments.append(attachment) + } + } else if provider.hasItemConformingToTypeIdentifier(UTType.movie.identifier) { + videoCount += 1 + } else if provider.hasItemConformingToTypeIdentifier(UTType.fileURL.identifier) { + fileCount += 1 + } else { + unknownCount += 1 + } + + } + } + + _ = imageCount + _ = videoCount + _ = fileCount + _ = unknownCount + + return ExtractedShareContent( + payload: SharedContentPayload(title: title, url: sharedURL, text: sharedText), + attachments: attachments) + } + + private func loadImageAttachment(from provider: NSItemProvider, index: Int) async -> ShareAttachment? { + let imageUTI = self.preferredImageTypeIdentifier(from: provider) ?? UTType.image.identifier + guard let rawData = await self.loadDataValue(from: provider, typeIdentifier: imageUTI) else { + return nil + } + + let maxBytes = 5_000_000 + guard let image = UIImage(data: rawData), + let data = self.normalizedJPEGData(from: image, maxBytes: maxBytes) + else { + return nil + } + + return ShareAttachment( + type: "image", + mimeType: "image/jpeg", + fileName: "shared-image-\(index + 1).jpg", + content: data.base64EncodedString()) + } + + private func preferredImageTypeIdentifier(from provider: NSItemProvider) -> String? { + for identifier in provider.registeredTypeIdentifiers { + guard let utType = UTType(identifier) else { continue } + if utType.conforms(to: .image) { + return identifier + } + } + return nil + } + + private func normalizedJPEGData(from image: UIImage, maxBytes: Int) -> Data? { + var quality: CGFloat = 0.9 + while quality >= 0.4 { + if let data = image.jpegData(compressionQuality: quality), data.count <= maxBytes { + return data + } + quality -= 0.1 + } + guard let fallback = image.jpegData(compressionQuality: 0.35) else { return nil } + if fallback.count <= maxBytes { return fallback } + return nil + } + + private func loadURL(from provider: NSItemProvider) async -> URL? { + if provider.hasItemConformingToTypeIdentifier(UTType.url.identifier) { + if let url = await self.loadURLValue( + from: provider, + typeIdentifier: UTType.url.identifier) + { + return url + } + } + + if provider.hasItemConformingToTypeIdentifier(UTType.text.identifier) { + if let text = await self.loadTextValue(from: provider, typeIdentifier: UTType.text.identifier), + let url = URL(string: text.trimmingCharacters(in: .whitespacesAndNewlines)), + url.scheme != nil + { + return url + } + } + + return nil + } + + private func loadText(from provider: NSItemProvider) async -> String? { + if provider.hasItemConformingToTypeIdentifier(UTType.plainText.identifier) { + if let text = await self.loadTextValue(from: provider, typeIdentifier: UTType.plainText.identifier) { + return text + } + } + + if provider.hasItemConformingToTypeIdentifier(UTType.url.identifier) { + if let url = await self.loadURLValue(from: provider, typeIdentifier: UTType.url.identifier) { + return url.absoluteString + } + } + + return nil + } + + private func loadURLValue(from provider: NSItemProvider, typeIdentifier: String) async -> URL? { + await withCheckedContinuation { continuation in + provider.loadItem(forTypeIdentifier: typeIdentifier, options: nil) { item, _ in + if let url = item as? URL { + continuation.resume(returning: url) + return + } + if let str = item as? String, let url = URL(string: str) { + continuation.resume(returning: url) + return + } + if let ns = item as? NSString, let url = URL(string: ns as String) { + continuation.resume(returning: url) + return + } + continuation.resume(returning: nil) + } + } + } + + private func loadTextValue(from provider: NSItemProvider, typeIdentifier: String) async -> String? { + await withCheckedContinuation { continuation in + provider.loadItem(forTypeIdentifier: typeIdentifier, options: nil) { item, _ in + if let text = item as? String { + continuation.resume(returning: text) + return + } + if let text = item as? NSString { + continuation.resume(returning: text as String) + return + } + if let text = item as? NSAttributedString { + continuation.resume(returning: text.string) + return + } + continuation.resume(returning: nil) + } + } + } + + private func loadDataValue(from provider: NSItemProvider, typeIdentifier: String) async -> Data? { + await withCheckedContinuation { continuation in + provider.loadDataRepresentation(forTypeIdentifier: typeIdentifier) { data, _ in + continuation.resume(returning: data) + } + } + } +} diff --git a/apps/ios/Signing.xcconfig b/apps/ios/Signing.xcconfig new file mode 100644 index 00000000000..f942fc0224f --- /dev/null +++ b/apps/ios/Signing.xcconfig @@ -0,0 +1,17 @@ +// Default signing values for shared/repo builds. +// Auto-selected local team overrides live in .local-signing.xcconfig (git-ignored). +// Manual local overrides can go in LocalSigning.xcconfig (git-ignored). + +OPENCLAW_CODE_SIGN_STYLE = Manual +OPENCLAW_DEVELOPMENT_TEAM = Y5PE65HELJ + +OPENCLAW_APP_BUNDLE_ID = ai.openclaw.ios +OPENCLAW_SHARE_BUNDLE_ID = ai.openclaw.ios.share + +OPENCLAW_APP_PROFILE = ai.openclaw.ios Development +OPENCLAW_SHARE_PROFILE = ai.openclaw.ios.share Development + +// Keep local includes after defaults: xcconfig is evaluated top-to-bottom, +// so later assignments in local files override the defaults above. +#include? ".local-signing.xcconfig" +#include? "LocalSigning.xcconfig" diff --git a/apps/ios/Sources/Assets.xcassets/AppIcon.appiconset/100.png b/apps/ios/Sources/Assets.xcassets/AppIcon.appiconset/100.png new file mode 100644 index 00000000000..22a04c9f22a Binary files /dev/null and b/apps/ios/Sources/Assets.xcassets/AppIcon.appiconset/100.png differ diff --git a/apps/ios/Sources/Assets.xcassets/AppIcon.appiconset/102.png b/apps/ios/Sources/Assets.xcassets/AppIcon.appiconset/102.png new file mode 100644 index 00000000000..ff8397de297 Binary files /dev/null and b/apps/ios/Sources/Assets.xcassets/AppIcon.appiconset/102.png differ diff --git a/apps/ios/Sources/Assets.xcassets/AppIcon.appiconset/1024.png b/apps/ios/Sources/Assets.xcassets/AppIcon.appiconset/1024.png new file mode 100644 index 00000000000..ecea78807d8 Binary files /dev/null and b/apps/ios/Sources/Assets.xcassets/AppIcon.appiconset/1024.png differ diff --git a/apps/ios/Sources/Assets.xcassets/AppIcon.appiconset/108.png b/apps/ios/Sources/Assets.xcassets/AppIcon.appiconset/108.png new file mode 100644 index 00000000000..a6888456dfa Binary files /dev/null and b/apps/ios/Sources/Assets.xcassets/AppIcon.appiconset/108.png differ diff --git a/apps/ios/Sources/Assets.xcassets/AppIcon.appiconset/114.png b/apps/ios/Sources/Assets.xcassets/AppIcon.appiconset/114.png new file mode 100644 index 00000000000..20e9ea1a557 Binary files /dev/null and b/apps/ios/Sources/Assets.xcassets/AppIcon.appiconset/114.png differ diff --git a/apps/ios/Sources/Assets.xcassets/AppIcon.appiconset/120.png b/apps/ios/Sources/Assets.xcassets/AppIcon.appiconset/120.png new file mode 100644 index 00000000000..154836b43a2 Binary files /dev/null and b/apps/ios/Sources/Assets.xcassets/AppIcon.appiconset/120.png differ diff --git a/apps/ios/Sources/Assets.xcassets/AppIcon.appiconset/172.png b/apps/ios/Sources/Assets.xcassets/AppIcon.appiconset/172.png new file mode 100644 index 00000000000..a66c0132393 Binary files /dev/null and b/apps/ios/Sources/Assets.xcassets/AppIcon.appiconset/172.png differ diff --git a/apps/ios/Sources/Assets.xcassets/AppIcon.appiconset/180.png b/apps/ios/Sources/Assets.xcassets/AppIcon.appiconset/180.png new file mode 100644 index 00000000000..d01e83d8ccc Binary files /dev/null and b/apps/ios/Sources/Assets.xcassets/AppIcon.appiconset/180.png differ diff --git a/apps/ios/Sources/Assets.xcassets/AppIcon.appiconset/196.png b/apps/ios/Sources/Assets.xcassets/AppIcon.appiconset/196.png new file mode 100644 index 00000000000..b7989e43d84 Binary files /dev/null and b/apps/ios/Sources/Assets.xcassets/AppIcon.appiconset/196.png differ diff --git a/apps/ios/Sources/Assets.xcassets/AppIcon.appiconset/216.png b/apps/ios/Sources/Assets.xcassets/AppIcon.appiconset/216.png new file mode 100644 index 00000000000..4dfb94abefb Binary files /dev/null and b/apps/ios/Sources/Assets.xcassets/AppIcon.appiconset/216.png differ diff --git a/apps/ios/Sources/Assets.xcassets/AppIcon.appiconset/234.png b/apps/ios/Sources/Assets.xcassets/AppIcon.appiconset/234.png new file mode 100644 index 00000000000..c0da9ae922c Binary files /dev/null and b/apps/ios/Sources/Assets.xcassets/AppIcon.appiconset/234.png differ diff --git a/apps/ios/Sources/Assets.xcassets/AppIcon.appiconset/258.png b/apps/ios/Sources/Assets.xcassets/AppIcon.appiconset/258.png new file mode 100644 index 00000000000..dbfb75050bd Binary files /dev/null and b/apps/ios/Sources/Assets.xcassets/AppIcon.appiconset/258.png differ diff --git a/apps/ios/Sources/Assets.xcassets/AppIcon.appiconset/29.png b/apps/ios/Sources/Assets.xcassets/AppIcon.appiconset/29.png new file mode 100644 index 00000000000..f4d57311481 Binary files /dev/null and b/apps/ios/Sources/Assets.xcassets/AppIcon.appiconset/29.png differ diff --git a/apps/ios/Sources/Assets.xcassets/AppIcon.appiconset/40.png b/apps/ios/Sources/Assets.xcassets/AppIcon.appiconset/40.png new file mode 100644 index 00000000000..87a14602e3c Binary files /dev/null and b/apps/ios/Sources/Assets.xcassets/AppIcon.appiconset/40.png differ diff --git a/apps/ios/Sources/Assets.xcassets/AppIcon.appiconset/48.png b/apps/ios/Sources/Assets.xcassets/AppIcon.appiconset/48.png new file mode 100644 index 00000000000..f66c2ded344 Binary files /dev/null and b/apps/ios/Sources/Assets.xcassets/AppIcon.appiconset/48.png differ diff --git a/apps/ios/Sources/Assets.xcassets/AppIcon.appiconset/55.png b/apps/ios/Sources/Assets.xcassets/AppIcon.appiconset/55.png new file mode 100644 index 00000000000..0730736fca0 Binary files /dev/null and b/apps/ios/Sources/Assets.xcassets/AppIcon.appiconset/55.png differ diff --git a/apps/ios/Sources/Assets.xcassets/AppIcon.appiconset/57.png b/apps/ios/Sources/Assets.xcassets/AppIcon.appiconset/57.png new file mode 100644 index 00000000000..f8946de39b3 Binary files /dev/null and b/apps/ios/Sources/Assets.xcassets/AppIcon.appiconset/57.png differ diff --git a/apps/ios/Sources/Assets.xcassets/AppIcon.appiconset/58.png b/apps/ios/Sources/Assets.xcassets/AppIcon.appiconset/58.png new file mode 100644 index 00000000000..92ae2f999d9 Binary files /dev/null and b/apps/ios/Sources/Assets.xcassets/AppIcon.appiconset/58.png differ diff --git a/apps/ios/Sources/Assets.xcassets/AppIcon.appiconset/60.png b/apps/ios/Sources/Assets.xcassets/AppIcon.appiconset/60.png new file mode 100644 index 00000000000..03231a71d18 Binary files /dev/null and b/apps/ios/Sources/Assets.xcassets/AppIcon.appiconset/60.png differ diff --git a/apps/ios/Sources/Assets.xcassets/AppIcon.appiconset/66.png b/apps/ios/Sources/Assets.xcassets/AppIcon.appiconset/66.png new file mode 100644 index 00000000000..834c6b0987f Binary files /dev/null and b/apps/ios/Sources/Assets.xcassets/AppIcon.appiconset/66.png differ diff --git a/apps/ios/Sources/Assets.xcassets/AppIcon.appiconset/80.png b/apps/ios/Sources/Assets.xcassets/AppIcon.appiconset/80.png new file mode 100644 index 00000000000..485a1aae7bd Binary files /dev/null and b/apps/ios/Sources/Assets.xcassets/AppIcon.appiconset/80.png differ diff --git a/apps/ios/Sources/Assets.xcassets/AppIcon.appiconset/87.png b/apps/ios/Sources/Assets.xcassets/AppIcon.appiconset/87.png new file mode 100644 index 00000000000..61da8b5fd79 Binary files /dev/null and b/apps/ios/Sources/Assets.xcassets/AppIcon.appiconset/87.png differ diff --git a/apps/ios/Sources/Assets.xcassets/AppIcon.appiconset/88.png b/apps/ios/Sources/Assets.xcassets/AppIcon.appiconset/88.png new file mode 100644 index 00000000000..f47fb37b5fc Binary files /dev/null and b/apps/ios/Sources/Assets.xcassets/AppIcon.appiconset/88.png differ diff --git a/apps/ios/Sources/Assets.xcassets/AppIcon.appiconset/92.png b/apps/ios/Sources/Assets.xcassets/AppIcon.appiconset/92.png new file mode 100644 index 00000000000..67a10a48458 Binary files /dev/null and b/apps/ios/Sources/Assets.xcassets/AppIcon.appiconset/92.png differ diff --git a/apps/ios/Sources/Assets.xcassets/AppIcon.appiconset/Contents.json b/apps/ios/Sources/Assets.xcassets/AppIcon.appiconset/Contents.json index 13847b5b5bf..922e8c6d731 100644 --- a/apps/ios/Sources/Assets.xcassets/AppIcon.appiconset/Contents.json +++ b/apps/ios/Sources/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -1,31 +1 @@ -{ - "images" : [ - { "filename" : "icon-20@1x.png", "idiom" : "ipad", "scale" : "1x", "size" : "20x20" }, - { "filename" : "icon-20@2x.png", "idiom" : "ipad", "scale" : "2x", "size" : "20x20" }, - { "filename" : "icon-20@2x.png", "idiom" : "iphone","scale" : "2x", "size" : "20x20" }, - { "filename" : "icon-20@3x.png", "idiom" : "iphone","scale" : "3x", "size" : "20x20" }, - - { "filename" : "icon-29@1x.png", "idiom" : "ipad", "scale" : "1x", "size" : "29x29" }, - { "filename" : "icon-29@2x.png", "idiom" : "ipad", "scale" : "2x", "size" : "29x29" }, - { "filename" : "icon-29@2x.png", "idiom" : "iphone","scale" : "2x", "size" : "29x29" }, - { "filename" : "icon-29@3x.png", "idiom" : "iphone","scale" : "3x", "size" : "29x29" }, - - { "filename" : "icon-40@1x.png", "idiom" : "ipad", "scale" : "1x", "size" : "40x40" }, - { "filename" : "icon-40@2x.png", "idiom" : "ipad", "scale" : "2x", "size" : "40x40" }, - { "filename" : "icon-40@2x.png", "idiom" : "iphone","scale" : "2x", "size" : "40x40" }, - { "filename" : "icon-40@3x.png", "idiom" : "iphone","scale" : "3x", "size" : "40x40" }, - - { "filename" : "icon-60@2x.png", "idiom" : "iphone","scale" : "2x", "size" : "60x60" }, - { "filename" : "icon-60@3x.png", "idiom" : "iphone","scale" : "3x", "size" : "60x60" }, - - { "filename" : "icon-76@2x.png", "idiom" : "ipad", "scale" : "2x", "size" : "76x76" }, - - { "filename" : "icon-83.5@2x.png", "idiom" : "ipad", "scale" : "2x", "size" : "83.5x83.5" }, - - { "filename" : "icon-1024.png", "idiom" : "ios-marketing", "scale" : "1x", "size" : "1024x1024" } - ], - "info" : { - "author" : "xcode", - "version" : 1 - } -} +{"images":[{"size":"60x60","expected-size":"180","filename":"180.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"iphone","scale":"3x"},{"size":"40x40","expected-size":"80","filename":"80.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"iphone","scale":"2x"},{"size":"40x40","expected-size":"120","filename":"120.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"iphone","scale":"3x"},{"size":"60x60","expected-size":"120","filename":"120.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"iphone","scale":"2x"},{"size":"57x57","expected-size":"57","filename":"57.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"iphone","scale":"1x"},{"size":"29x29","expected-size":"58","filename":"58.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"iphone","scale":"2x"},{"size":"29x29","expected-size":"29","filename":"29.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"iphone","scale":"1x"},{"size":"29x29","expected-size":"87","filename":"87.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"iphone","scale":"3x"},{"size":"57x57","expected-size":"114","filename":"114.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"iphone","scale":"2x"},{"size":"20x20","expected-size":"40","filename":"40.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"iphone","scale":"2x"},{"size":"20x20","expected-size":"60","filename":"60.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"iphone","scale":"3x"},{"size":"1024x1024","filename":"1024.png","expected-size":"1024","idiom":"ios-marketing","folder":"Assets.xcassets/AppIcon.appiconset/","scale":"1x"},{"idiom":"watch","filename":"172.png","folder":"Assets.xcassets/AppIcon.appiconset/","subtype":"38mm","scale":"2x","size":"86x86","expected-size":"172","role":"quickLook"},{"idiom":"watch","filename":"80.png","folder":"Assets.xcassets/AppIcon.appiconset/","subtype":"38mm","scale":"2x","size":"40x40","expected-size":"80","role":"appLauncher"},{"idiom":"watch","filename":"88.png","folder":"Assets.xcassets/AppIcon.appiconset/","subtype":"40mm","scale":"2x","size":"44x44","expected-size":"88","role":"appLauncher"},{"idiom":"watch","filename":"102.png","folder":"Assets.xcassets/AppIcon.appiconset/","subtype":"45mm","scale":"2x","size":"51x51","expected-size":"102","role":"appLauncher"},{"idiom":"watch","filename":"108.png","folder":"Assets.xcassets/AppIcon.appiconset/","subtype":"49mm","scale":"2x","size":"54x54","expected-size":"108","role":"appLauncher"},{"idiom":"watch","filename":"92.png","folder":"Assets.xcassets/AppIcon.appiconset/","subtype":"41mm","scale":"2x","size":"46x46","expected-size":"92","role":"appLauncher"},{"idiom":"watch","filename":"100.png","folder":"Assets.xcassets/AppIcon.appiconset/","subtype":"44mm","scale":"2x","size":"50x50","expected-size":"100","role":"appLauncher"},{"idiom":"watch","filename":"196.png","folder":"Assets.xcassets/AppIcon.appiconset/","subtype":"42mm","scale":"2x","size":"98x98","expected-size":"196","role":"quickLook"},{"idiom":"watch","filename":"216.png","folder":"Assets.xcassets/AppIcon.appiconset/","subtype":"44mm","scale":"2x","size":"108x108","expected-size":"216","role":"quickLook"},{"idiom":"watch","filename":"234.png","folder":"Assets.xcassets/AppIcon.appiconset/","subtype":"45mm","scale":"2x","size":"117x117","expected-size":"234","role":"quickLook"},{"idiom":"watch","filename":"258.png","folder":"Assets.xcassets/AppIcon.appiconset/","subtype":"49mm","scale":"2x","size":"129x129","expected-size":"258","role":"quickLook"},{"idiom":"watch","filename":"48.png","folder":"Assets.xcassets/AppIcon.appiconset/","subtype":"38mm","scale":"2x","size":"24x24","expected-size":"48","role":"notificationCenter"},{"idiom":"watch","filename":"55.png","folder":"Assets.xcassets/AppIcon.appiconset/","subtype":"42mm","scale":"2x","size":"27.5x27.5","expected-size":"55","role":"notificationCenter"},{"idiom":"watch","filename":"66.png","folder":"Assets.xcassets/AppIcon.appiconset/","subtype":"45mm","scale":"2x","size":"33x33","expected-size":"66","role":"notificationCenter"},{"size":"29x29","expected-size":"87","filename":"87.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"watch","role":"companionSettings","scale":"3x"},{"size":"29x29","expected-size":"58","filename":"58.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"watch","role":"companionSettings","scale":"2x"},{"size":"1024x1024","expected-size":"1024","filename":"1024.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"watch-marketing","scale":"1x"}]} \ No newline at end of file diff --git a/apps/ios/Sources/Assets.xcassets/AppIcon.appiconset/icon-1024.png b/apps/ios/Sources/Assets.xcassets/AppIcon.appiconset/icon-1024.png deleted file mode 100644 index 1ebd257d93f..00000000000 Binary files a/apps/ios/Sources/Assets.xcassets/AppIcon.appiconset/icon-1024.png and /dev/null differ diff --git a/apps/ios/Sources/Assets.xcassets/AppIcon.appiconset/icon-20@1x.png b/apps/ios/Sources/Assets.xcassets/AppIcon.appiconset/icon-20@1x.png deleted file mode 100644 index 0aa1506a095..00000000000 Binary files a/apps/ios/Sources/Assets.xcassets/AppIcon.appiconset/icon-20@1x.png and /dev/null differ diff --git a/apps/ios/Sources/Assets.xcassets/AppIcon.appiconset/icon-20@2x.png b/apps/ios/Sources/Assets.xcassets/AppIcon.appiconset/icon-20@2x.png deleted file mode 100644 index dd8a14724eb..00000000000 Binary files a/apps/ios/Sources/Assets.xcassets/AppIcon.appiconset/icon-20@2x.png and /dev/null differ diff --git a/apps/ios/Sources/Assets.xcassets/AppIcon.appiconset/icon-20@3x.png b/apps/ios/Sources/Assets.xcassets/AppIcon.appiconset/icon-20@3x.png deleted file mode 100644 index ca160dc2e84..00000000000 Binary files a/apps/ios/Sources/Assets.xcassets/AppIcon.appiconset/icon-20@3x.png and /dev/null differ diff --git a/apps/ios/Sources/Assets.xcassets/AppIcon.appiconset/icon-29@1x.png b/apps/ios/Sources/Assets.xcassets/AppIcon.appiconset/icon-29@1x.png deleted file mode 100644 index 9020a8672d3..00000000000 Binary files a/apps/ios/Sources/Assets.xcassets/AppIcon.appiconset/icon-29@1x.png and /dev/null differ diff --git a/apps/ios/Sources/Assets.xcassets/AppIcon.appiconset/icon-29@2x.png b/apps/ios/Sources/Assets.xcassets/AppIcon.appiconset/icon-29@2x.png deleted file mode 100644 index ff85b417fec..00000000000 Binary files a/apps/ios/Sources/Assets.xcassets/AppIcon.appiconset/icon-29@2x.png and /dev/null differ diff --git a/apps/ios/Sources/Assets.xcassets/AppIcon.appiconset/icon-29@3x.png b/apps/ios/Sources/Assets.xcassets/AppIcon.appiconset/icon-29@3x.png deleted file mode 100644 index e12fff03140..00000000000 Binary files a/apps/ios/Sources/Assets.xcassets/AppIcon.appiconset/icon-29@3x.png and /dev/null differ diff --git a/apps/ios/Sources/Assets.xcassets/AppIcon.appiconset/icon-40@1x.png b/apps/ios/Sources/Assets.xcassets/AppIcon.appiconset/icon-40@1x.png deleted file mode 100644 index dd8a14724eb..00000000000 Binary files a/apps/ios/Sources/Assets.xcassets/AppIcon.appiconset/icon-40@1x.png and /dev/null differ diff --git a/apps/ios/Sources/Assets.xcassets/AppIcon.appiconset/icon-40@2x.png b/apps/ios/Sources/Assets.xcassets/AppIcon.appiconset/icon-40@2x.png deleted file mode 100644 index 9b3da5155ef..00000000000 Binary files a/apps/ios/Sources/Assets.xcassets/AppIcon.appiconset/icon-40@2x.png and /dev/null differ diff --git a/apps/ios/Sources/Assets.xcassets/AppIcon.appiconset/icon-40@3x.png b/apps/ios/Sources/Assets.xcassets/AppIcon.appiconset/icon-40@3x.png deleted file mode 100644 index f57a0c1323c..00000000000 Binary files a/apps/ios/Sources/Assets.xcassets/AppIcon.appiconset/icon-40@3x.png and /dev/null differ diff --git a/apps/ios/Sources/Assets.xcassets/AppIcon.appiconset/icon-60@2x.png b/apps/ios/Sources/Assets.xcassets/AppIcon.appiconset/icon-60@2x.png deleted file mode 100644 index f57a0c1323c..00000000000 Binary files a/apps/ios/Sources/Assets.xcassets/AppIcon.appiconset/icon-60@2x.png and /dev/null differ diff --git a/apps/ios/Sources/Assets.xcassets/AppIcon.appiconset/icon-60@3x.png b/apps/ios/Sources/Assets.xcassets/AppIcon.appiconset/icon-60@3x.png deleted file mode 100644 index b94278f29d0..00000000000 Binary files a/apps/ios/Sources/Assets.xcassets/AppIcon.appiconset/icon-60@3x.png and /dev/null differ diff --git a/apps/ios/Sources/Assets.xcassets/AppIcon.appiconset/icon-76@2x.png b/apps/ios/Sources/Assets.xcassets/AppIcon.appiconset/icon-76@2x.png deleted file mode 100644 index 2d6240dc679..00000000000 Binary files a/apps/ios/Sources/Assets.xcassets/AppIcon.appiconset/icon-76@2x.png and /dev/null differ diff --git a/apps/ios/Sources/Assets.xcassets/AppIcon.appiconset/icon-83.5@2x.png b/apps/ios/Sources/Assets.xcassets/AppIcon.appiconset/icon-83.5@2x.png deleted file mode 100644 index 7321091c561..00000000000 Binary files a/apps/ios/Sources/Assets.xcassets/AppIcon.appiconset/icon-83.5@2x.png and /dev/null differ diff --git a/apps/ios/Sources/Assets.xcassets/Contents.json b/apps/ios/Sources/Assets.xcassets/Contents.json deleted file mode 100644 index 73c00596a7f..00000000000 --- a/apps/ios/Sources/Assets.xcassets/Contents.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "info" : { - "author" : "xcode", - "version" : 1 - } -} diff --git a/apps/ios/Sources/Chat/IOSGatewayChatTransport.swift b/apps/ios/Sources/Chat/IOSGatewayChatTransport.swift index 3c828551ada..9571839059d 100644 --- a/apps/ios/Sources/Chat/IOSGatewayChatTransport.swift +++ b/apps/ios/Sources/Chat/IOSGatewayChatTransport.swift @@ -2,8 +2,10 @@ import OpenClawChatUI import OpenClawKit import OpenClawProtocol import Foundation +import OSLog struct IOSGatewayChatTransport: OpenClawChatTransport, Sendable { + private static let logger = Logger(subsystem: "ai.openclaw", category: "ios.chat.transport") private let gateway: GatewayNodeSession init(gateway: GatewayNodeSession) { @@ -33,10 +35,8 @@ struct IOSGatewayChatTransport: OpenClawChatTransport, Sendable { } func setActiveSessionKey(_ sessionKey: String) async throws { - struct Subscribe: Codable { var sessionKey: String } - let data = try JSONEncoder().encode(Subscribe(sessionKey: sessionKey)) - let json = String(data: data, encoding: .utf8) - await self.gateway.sendEvent(event: "chat.subscribe", payloadJSON: json) + // Operator clients receive chat events without node-style subscriptions. + // (chat.subscribe is a node event, not an operator RPC method.) } func requestHistory(sessionKey: String) async throws -> OpenClawChatHistoryPayload { @@ -54,6 +54,7 @@ struct IOSGatewayChatTransport: OpenClawChatTransport, Sendable { idempotencyKey: String, attachments: [OpenClawChatAttachmentPayload]) async throws -> OpenClawChatSendResponse { + Self.logger.info("chat.send start sessionKey=\(sessionKey, privacy: .public) len=\(message.count, privacy: .public) attachments=\(attachments.count, privacy: .public)") struct Params: Codable { var sessionKey: String var message: String @@ -72,8 +73,15 @@ struct IOSGatewayChatTransport: OpenClawChatTransport, Sendable { idempotencyKey: idempotencyKey) let data = try JSONEncoder().encode(params) let json = String(data: data, encoding: .utf8) - let res = try await self.gateway.request(method: "chat.send", paramsJSON: json, timeoutSeconds: 35) - return try JSONDecoder().decode(OpenClawChatSendResponse.self, from: res) + do { + let res = try await self.gateway.request(method: "chat.send", paramsJSON: json, timeoutSeconds: 35) + let decoded = try JSONDecoder().decode(OpenClawChatSendResponse.self, from: res) + Self.logger.info("chat.send ok runId=\(decoded.runId, privacy: .public)") + return decoded + } catch { + Self.logger.error("chat.send failed \(error.localizedDescription, privacy: .public)") + throw error + } } func requestHealth(timeoutMs: Int) async throws -> Bool { diff --git a/apps/ios/Sources/Gateway/DeepLinkAgentPromptAlert.swift b/apps/ios/Sources/Gateway/DeepLinkAgentPromptAlert.swift new file mode 100644 index 00000000000..0624e976b51 --- /dev/null +++ b/apps/ios/Sources/Gateway/DeepLinkAgentPromptAlert.swift @@ -0,0 +1,40 @@ +import SwiftUI + +struct DeepLinkAgentPromptAlert: ViewModifier { + @Environment(NodeAppModel.self) private var appModel: NodeAppModel + + private var promptBinding: Binding { + Binding( + get: { self.appModel.pendingAgentDeepLinkPrompt }, + set: { _ in + // Keep prompt state until explicit user action. + }) + } + + func body(content: Content) -> some View { + content.alert(item: self.promptBinding) { prompt in + Alert( + title: Text("Run OpenClaw agent?"), + message: Text( + """ + Message: + \(prompt.messagePreview) + + URL: + \(prompt.urlPreview) + """), + primaryButton: .cancel(Text("Cancel")) { + self.appModel.declinePendingAgentDeepLinkPrompt() + }, + secondaryButton: .default(Text("Run")) { + Task { await self.appModel.approvePendingAgentDeepLinkPrompt() } + }) + } + } +} + +extension View { + func deepLinkAgentPromptAlert() -> some View { + self.modifier(DeepLinkAgentPromptAlert()) + } +} diff --git a/apps/ios/Sources/Gateway/GatewayConnectionController.swift b/apps/ios/Sources/Gateway/GatewayConnectionController.swift index 995e2f36d04..2b7f94ba453 100644 --- a/apps/ios/Sources/Gateway/GatewayConnectionController.swift +++ b/apps/ios/Sources/Gateway/GatewayConnectionController.swift @@ -5,6 +5,7 @@ import CoreMotion import CryptoKit import EventKit import Foundation +import Darwin import OpenClawKit import Network import Observation @@ -72,32 +73,55 @@ final class GatewayConnectionController { } } - func connect(_ gateway: GatewayDiscoveryModel.DiscoveredGateway) async { + func allowAutoConnectAgain() { + self.didAutoConnect = false + self.maybeAutoConnect() + } + + func restartDiscovery() { + self.discovery.stop() + self.didAutoConnect = false + self.discovery.start() + self.updateFromDiscovery() + } + + + /// Returns `nil` when a connect attempt was started, otherwise returns a user-facing error. + func connectWithDiagnostics(_ gateway: GatewayDiscoveryModel.DiscoveredGateway) async -> String? { await self.connectDiscoveredGateway(gateway) } private func connectDiscoveredGateway( - _ gateway: GatewayDiscoveryModel.DiscoveredGateway) async + _ gateway: GatewayDiscoveryModel.DiscoveredGateway) async -> String? { let instanceId = UserDefaults.standard.string(forKey: "node.instanceId")? .trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + if instanceId.isEmpty { + return "Missing instanceId (node.instanceId). Try restarting the app." + } let token = GatewaySettingsStore.loadGatewayToken(instanceId: instanceId) let password = GatewaySettingsStore.loadGatewayPassword(instanceId: instanceId) // Resolve the service endpoint (SRV/A/AAAA). TXT is unauthenticated; do not route via TXT. - guard let target = await self.resolveServiceEndpoint(gateway.endpoint) else { return } + guard let target = await self.resolveServiceEndpoint(gateway.endpoint) else { + return "Failed to resolve the discovered gateway endpoint." + } let stableID = gateway.stableID // Discovery is a LAN operation; refuse unauthenticated plaintext connects. let tlsRequired = true let stored = GatewayTLSStore.loadFingerprint(stableID: stableID) - guard gateway.tlsEnabled || stored != nil else { return } + guard gateway.tlsEnabled || stored != nil else { + return "Discovered gateway is missing TLS and no trusted fingerprint is stored." + } if tlsRequired, stored == nil { guard let url = self.buildGatewayURL(host: target.host, port: target.port, useTLS: true) - else { return } - guard let fp = await self.probeTLSFingerprint(url: url) else { return } + else { return "Failed to build TLS URL for trust verification." } + guard let fp = await self.probeTLSFingerprint(url: url) else { + return "Failed to read TLS fingerprint from discovered gateway." + } self.pendingTrustConnect = (url: url, stableID: stableID, isManual: false) self.pendingTrustPrompt = TrustPrompt( stableID: stableID, @@ -107,7 +131,7 @@ final class GatewayConnectionController { fingerprintSha256: fp, isManual: false) self.appModel?.gatewayStatusText = "Verify gateway TLS fingerprint" - return + return nil } let tlsParams = stored.map { fp in @@ -118,7 +142,7 @@ final class GatewayConnectionController { host: target.host, port: target.port, useTLS: tlsParams?.required == true) - else { return } + else { return "Failed to build discovered gateway URL." } GatewaySettingsStore.saveLastGatewayConnectionDiscovered(stableID: stableID, useTLS: true) self.didAutoConnect = true self.startAutoConnect( @@ -127,6 +151,11 @@ final class GatewayConnectionController { tls: tlsParams, token: token, password: password) + return nil + } + + func connect(_ gateway: GatewayDiscoveryModel.DiscoveredGateway) async { + _ = await self.connectWithDiagnostics(gateway) } func connectManual(host: String, port: Int, useTLS: Bool) async { @@ -134,7 +163,7 @@ final class GatewayConnectionController { .trimmingCharacters(in: .whitespacesAndNewlines) ?? "" let token = GatewaySettingsStore.loadGatewayToken(instanceId: instanceId) let password = GatewaySettingsStore.loadGatewayPassword(instanceId: instanceId) - let resolvedUseTLS = useTLS + let resolvedUseTLS = self.resolveManualUseTLS(host: host, useTLS: useTLS) guard let resolvedPort = self.resolveManualPort(host: host, port: port, useTLS: resolvedUseTLS) else { return } let stableID = self.manualStableID(host: host, port: resolvedPort) @@ -187,6 +216,23 @@ final class GatewayConnectionController { } } + /// Rebuild connect options from current local settings (caps/commands/permissions) + /// and re-apply the active gateway config so capability changes take effect immediately. + func refreshActiveGatewayRegistrationFromSettings() { + guard let appModel else { return } + guard let cfg = appModel.activeGatewayConnectConfig else { return } + guard appModel.gatewayAutoReconnectEnabled else { return } + + let refreshedConfig = GatewayConnectConfig( + url: cfg.url, + stableID: cfg.stableID, + tls: cfg.tls, + token: cfg.token, + password: cfg.password, + nodeOptions: self.makeConnectOptions(stableID: cfg.stableID)) + appModel.applyGatewayConnectConfig(refreshedConfig) + } + func clearPendingTrustPrompt() { self.pendingTrustPrompt = nil self.pendingTrustConnect = nil @@ -281,7 +327,7 @@ final class GatewayConnectionController { let manualPort = defaults.integer(forKey: "gateway.manual.port") let manualTLS = defaults.bool(forKey: "gateway.manual.tls") - let resolvedUseTLS = manualTLS || self.shouldForceTLS(host: manualHost) + let resolvedUseTLS = self.resolveManualUseTLS(host: manualHost, useTLS: manualTLS) guard let resolvedPort = self.resolveManualPort( host: manualHost, port: manualPort, @@ -292,7 +338,7 @@ final class GatewayConnectionController { let tlsParams = self.resolveManualTLSParams( stableID: stableID, tlsEnabled: resolvedUseTLS, - allowTOFUReset: self.shouldForceTLS(host: manualHost)) + allowTOFUReset: self.shouldRequireTLS(host: manualHost)) guard let url = self.buildGatewayURL( host: manualHost, @@ -312,7 +358,7 @@ final class GatewayConnectionController { if let lastKnown = GatewaySettingsStore.loadLastGatewayConnection() { if case let .manual(host, port, useTLS, stableID) = lastKnown { - let resolvedUseTLS = useTLS || self.shouldForceTLS(host: host) + let resolvedUseTLS = self.resolveManualUseTLS(host: host, useTLS: useTLS) let stored = GatewayTLSStore.loadFingerprint(stableID: stableID) let tlsParams = stored.map { fp in GatewayTLSParams(required: true, expectedFingerprint: fp, allowTOFU: false, storeKey: stableID) @@ -490,6 +536,125 @@ final class GatewayConnectionController { } } + private func resolveHostPortFromBonjourEndpoint(_ endpoint: NWEndpoint) async -> (host: String, port: Int)? { + switch endpoint { + case let .hostPort(host, port): + return (host: host.debugDescription, port: Int(port.rawValue)) + case let .service(name, type, domain, _): + return await Self.resolveBonjourServiceToHostPort(name: name, type: type, domain: domain) + default: + return nil + } + } + + private static func resolveBonjourServiceToHostPort( + name: String, + type: String, + domain: String, + timeoutSeconds: TimeInterval = 3.0 + ) async -> (host: String, port: Int)? { + // NetService callbacks are delivered via a run loop. If we resolve from a thread without one, + // we can end up never receiving callbacks, which in turn leaks the continuation and leaves + // the UI stuck "connecting". Keep the whole lifecycle on the main run loop and always + // resume the continuation exactly once (timeout/cancel safe). + @MainActor + final class Resolver: NSObject, @preconcurrency NetServiceDelegate { + private var cont: CheckedContinuation<(host: String, port: Int)?, Never>? + private let service: NetService + private var timeoutTask: Task? + private var finished = false + + init(cont: CheckedContinuation<(host: String, port: Int)?, Never>, service: NetService) { + self.cont = cont + self.service = service + super.init() + } + + func start(timeoutSeconds: TimeInterval) { + self.service.delegate = self + self.service.schedule(in: .main, forMode: .default) + + // NetService has its own timeout, but we keep a manual one as a backstop in case + // callbacks never arrive (e.g. local network permission issues). + self.timeoutTask = Task { @MainActor [weak self] in + guard let self else { return } + let ns = UInt64(max(0.1, timeoutSeconds) * 1_000_000_000) + try? await Task.sleep(nanoseconds: ns) + self.finish(nil) + } + + self.service.resolve(withTimeout: timeoutSeconds) + } + + func netServiceDidResolveAddress(_ sender: NetService) { + self.finish(Self.extractHostPort(sender)) + } + + func netService(_ sender: NetService, didNotResolve errorDict: [String: NSNumber]) { + _ = errorDict // currently best-effort; callers surface a generic failure + self.finish(nil) + } + + private func finish(_ result: (host: String, port: Int)?) { + guard !self.finished else { return } + self.finished = true + + self.timeoutTask?.cancel() + self.timeoutTask = nil + + self.service.stop() + self.service.remove(from: .main, forMode: .default) + + let c = self.cont + self.cont = nil + c?.resume(returning: result) + } + + private static func extractHostPort(_ svc: NetService) -> (host: String, port: Int)? { + let port = svc.port + + if let host = svc.hostName?.trimmingCharacters(in: .whitespacesAndNewlines), !host.isEmpty { + return (host: host, port: port) + } + + guard let addrs = svc.addresses else { return nil } + for addrData in addrs { + let host = addrData.withUnsafeBytes { ptr -> String? in + guard let base = ptr.baseAddress, !ptr.isEmpty else { return nil } + var buffer = [CChar](repeating: 0, count: Int(NI_MAXHOST)) + + let rc = getnameinfo( + base.assumingMemoryBound(to: sockaddr.self), + socklen_t(ptr.count), + &buffer, + socklen_t(buffer.count), + nil, + 0, + NI_NUMERICHOST) + guard rc == 0 else { return nil } + return String(cString: buffer) + } + + if let host, !host.isEmpty { + return (host: host, port: port) + } + } + + return nil + } + } + + return await withCheckedContinuation { cont in + Task { @MainActor in + let service = NetService(domain: domain, type: type, name: name) + let resolver = Resolver(cont: cont, service: service) + // Keep the resolver alive for the lifetime of the NetService resolve. + objc_setAssociatedObject(service, "resolver", resolver, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) + resolver.start(timeoutSeconds: timeoutSeconds) + } + } + } + private func buildGatewayURL(host: String, port: Int, useTLS: Bool) -> URL? { let scheme = useTLS ? "wss" : "ws" var components = URLComponents() @@ -499,12 +664,65 @@ final class GatewayConnectionController { return components.url } + private func resolveManualUseTLS(host: String, useTLS: Bool) -> Bool { + useTLS || self.shouldRequireTLS(host: host) + } + + private func shouldRequireTLS(host: String) -> Bool { + !Self.isLoopbackHost(host) + } + private func shouldForceTLS(host: String) -> Bool { let trimmed = host.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() if trimmed.isEmpty { return false } return trimmed.hasSuffix(".ts.net") || trimmed.hasSuffix(".ts.net.") } + private static func isLoopbackHost(_ rawHost: String) -> Bool { + var host = rawHost.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + guard !host.isEmpty else { return false } + + if host.hasPrefix("[") && host.hasSuffix("]") { + host.removeFirst() + host.removeLast() + } + if host.hasSuffix(".") { + host.removeLast() + } + if let zoneIndex = host.firstIndex(of: "%") { + host = String(host[.. Bool { + var addr = in_addr() + let parsed = host.withCString { inet_pton(AF_INET, $0, &addr) == 1 } + guard parsed else { return false } + let value = UInt32(bigEndian: addr.s_addr) + let firstOctet = UInt8((value >> 24) & 0xFF) + return firstOctet == 127 + } + + private static func isLoopbackIPv6(_ host: String) -> Bool { + var addr = in6_addr() + let parsed = host.withCString { inet_pton(AF_INET6, $0, &addr) == 1 } + guard parsed else { return false } + return withUnsafeBytes(of: &addr) { rawBytes in + let bytes = rawBytes.bindMemory(to: UInt8.self) + let isV6Loopback = bytes[0..<15].allSatisfy { $0 == 0 } && bytes[15] == 1 + if isV6Loopback { return true } + + let isMappedV4 = bytes[0..<10].allSatisfy { $0 == 0 } && bytes[10] == 0xFF && bytes[11] == 0xFF + return isMappedV4 && bytes[12] == 127 + } + } + private func manualStableID(host: String, port: Int) -> String { "manual|\(host.lowercased())|\(port)" } @@ -582,6 +800,9 @@ final class GatewayConnectionController { if locationMode != .off { caps.append(OpenClawCapability.location.rawValue) } caps.append(OpenClawCapability.device.rawValue) + if WatchMessagingService.isSupportedOnDevice() { + caps.append(OpenClawCapability.watch.rawValue) + } caps.append(OpenClawCapability.photos.rawValue) caps.append(OpenClawCapability.contacts.rawValue) caps.append(OpenClawCapability.calendar.rawValue) @@ -625,6 +846,10 @@ final class GatewayConnectionController { commands.append(OpenClawDeviceCommand.status.rawValue) commands.append(OpenClawDeviceCommand.info.rawValue) } + if caps.contains(OpenClawCapability.watch.rawValue) { + commands.append(OpenClawWatchCommand.status.rawValue) + commands.append(OpenClawWatchCommand.notify.rawValue) + } if caps.contains(OpenClawCapability.photos.rawValue) { commands.append(OpenClawPhotosCommand.latest.rawValue) } @@ -675,6 +900,12 @@ final class GatewayConnectionController { permissions["motion"] = motionStatus == .authorized || pedometerStatus == .authorized + let watchStatus = WatchMessagingService.currentStatusSnapshot() + permissions["watchSupported"] = watchStatus.supported + permissions["watchPaired"] = watchStatus.paired + permissions["watchAppInstalled"] = watchStatus.appInstalled + permissions["watchReachable"] = watchStatus.reachable + return permissions } @@ -782,6 +1013,14 @@ extension GatewayConnectionController { { self.resolveDiscoveredTLSParams(gateway: gateway, allowTOFU: allowTOFU) } + + func _test_resolveManualUseTLS(host: String, useTLS: Bool) -> Bool { + self.resolveManualUseTLS(host: host, useTLS: useTLS) + } + + func _test_resolveManualPort(host: String, port: Int, useTLS: Bool) -> Int? { + self.resolveManualPort(host: host, port: port, useTLS: useTLS) + } } #endif diff --git a/apps/ios/Sources/Gateway/GatewayConnectionIssue.swift b/apps/ios/Sources/Gateway/GatewayConnectionIssue.swift new file mode 100644 index 00000000000..56d490e226b --- /dev/null +++ b/apps/ios/Sources/Gateway/GatewayConnectionIssue.swift @@ -0,0 +1,71 @@ +import Foundation + +enum GatewayConnectionIssue: Equatable { + case none + case tokenMissing + case unauthorized + case pairingRequired(requestId: String?) + case network + case unknown(String) + + var requestId: String? { + if case let .pairingRequired(requestId) = self { + return requestId + } + return nil + } + + var needsAuthToken: Bool { + switch self { + case .tokenMissing, .unauthorized: + return true + default: + return false + } + } + + var needsPairing: Bool { + if case .pairingRequired = self { return true } + return false + } + + static func detect(from statusText: String) -> Self { + let trimmed = statusText.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return .none } + let lower = trimmed.lowercased() + + if lower.contains("pairing required") || lower.contains("not_paired") || lower.contains("not paired") { + return .pairingRequired(requestId: self.extractRequestId(from: trimmed)) + } + if lower.contains("gateway token missing") { + return .tokenMissing + } + if lower.contains("unauthorized") { + return .unauthorized + } + if lower.contains("connection refused") || + lower.contains("timed out") || + lower.contains("network is unreachable") || + lower.contains("cannot find host") || + lower.contains("could not connect") + { + return .network + } + if lower.hasPrefix("gateway error:") { + return .unknown(trimmed) + } + return .none + } + + private static func extractRequestId(from statusText: String) -> String? { + let marker = "requestId:" + guard let range = statusText.range(of: marker) else { return nil } + let suffix = statusText[range.upperBound...] + let trimmed = suffix.trimmingCharacters(in: .whitespacesAndNewlines) + let end = trimmed.firstIndex(where: { ch in + ch == ")" || ch.isWhitespace || ch == "," || ch == ";" + }) ?? trimmed.endIndex + let id = String(trimmed[.. String? { + let value = KeychainStore.loadString( + service: self.talkService, + account: self.talkElevenLabsApiKeyAccount)? + .trimmingCharacters(in: .whitespacesAndNewlines) + if value?.isEmpty == false { return value } + return nil + } + + static func saveTalkElevenLabsApiKey(_ apiKey: String?) { + let trimmed = apiKey?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + if trimmed.isEmpty { + _ = KeychainStore.delete(service: self.talkService, account: self.talkElevenLabsApiKeyAccount) + return + } + _ = KeychainStore.saveString( + trimmed, + service: self.talkService, + account: self.talkElevenLabsApiKeyAccount) + } + static func saveLastGatewayConnectionManual(host: String, port: Int, useTLS: Bool, stableID: String) { let defaults = UserDefaults.standard defaults.set(LastGatewayKind.manual.rawValue, forKey: self.lastGatewayKindDefaultsKey) @@ -184,6 +207,25 @@ enum GatewaySettingsStore { return .manual(host: host, port: port, useTLS: useTLS, stableID: stableID) } + static func clearLastGatewayConnection(defaults: UserDefaults = .standard) { + defaults.removeObject(forKey: self.lastGatewayKindDefaultsKey) + defaults.removeObject(forKey: self.lastGatewayHostDefaultsKey) + defaults.removeObject(forKey: self.lastGatewayPortDefaultsKey) + defaults.removeObject(forKey: self.lastGatewayTlsDefaultsKey) + defaults.removeObject(forKey: self.lastGatewayStableIDDefaultsKey) + } + + static func deleteGatewayCredentials(instanceId: String) { + let trimmed = instanceId.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return } + _ = KeychainStore.delete( + service: self.gatewayService, + account: self.gatewayTokenAccount(instanceId: trimmed)) + _ = KeychainStore.delete( + service: self.gatewayService, + account: self.gatewayPasswordAccount(instanceId: trimmed)) + } + static func loadGatewayClientIdOverride(stableID: String) -> String? { let trimmedID = stableID.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmedID.isEmpty else { return nil } diff --git a/apps/ios/Sources/Gateway/GatewayTrustPromptAlert.swift b/apps/ios/Sources/Gateway/GatewayTrustPromptAlert.swift index f117ad9ea46..eff6b71bad5 100644 --- a/apps/ios/Sources/Gateway/GatewayTrustPromptAlert.swift +++ b/apps/ios/Sources/Gateway/GatewayTrustPromptAlert.swift @@ -6,10 +6,10 @@ struct GatewayTrustPromptAlert: ViewModifier { private var promptBinding: Binding { Binding( get: { self.gatewayController.pendingTrustPrompt }, - set: { newValue in - if newValue == nil { - self.gatewayController.clearPendingTrustPrompt() - } + set: { _ in + // Keep pending trust state until explicit user action. + // `alert(item:)` may set the binding to nil during dismissal, which can race with + // the button handler and cause accept to no-op. }) } @@ -39,4 +39,3 @@ extension View { self.modifier(GatewayTrustPromptAlert()) } } - diff --git a/apps/ios/Sources/Info.plist b/apps/ios/Sources/Info.plist index 6fe56394eed..c34fccb5052 100644 --- a/apps/ios/Sources/Info.plist +++ b/apps/ios/Sources/Info.plist @@ -17,15 +17,26 @@ CFBundleName $(PRODUCT_NAME) CFBundlePackageType - APPL - CFBundleShortVersionString - 2026.2.16 - CFBundleVersion - 20260216 - NSAppTransportSecurity - - NSAllowsArbitraryLoadsInWebContent - + APPL + CFBundleShortVersionString + 2026.2.23 + CFBundleURLTypes + + + CFBundleURLName + ai.openclaw.ios + CFBundleURLSchemes + + openclaw + + + + CFBundleVersion + 20260223 + NSAppTransportSecurity + + NSAllowsArbitraryLoadsInWebContent + NSBonjourServices @@ -51,6 +62,11 @@ UIBackgroundModes audio + remote-notification + + BGTaskSchedulerPermittedIdentifiers + + ai.openclaw.ios.bgrefresh UILaunchScreen diff --git a/apps/ios/Sources/Location/LocationService.swift b/apps/ios/Sources/Location/LocationService.swift index 99265d02e89..f1f0f69ed7f 100644 --- a/apps/ios/Sources/Location/LocationService.swift +++ b/apps/ios/Sources/Location/LocationService.swift @@ -12,6 +12,10 @@ final class LocationService: NSObject, CLLocationManagerDelegate { private let manager = CLLocationManager() private var authContinuation: CheckedContinuation? private var locationContinuation: CheckedContinuation? + private var updatesContinuation: AsyncStream.Continuation? + private var isStreaming = false + private var significantLocationCallback: (@Sendable (CLLocation) -> Void)? + private var isMonitoringSignificantChanges = false override init() { super.init() @@ -104,6 +108,56 @@ final class LocationService: NSObject, CLLocationManagerDelegate { } } + func startLocationUpdates( + desiredAccuracy: OpenClawLocationAccuracy, + significantChangesOnly: Bool) -> AsyncStream + { + self.stopLocationUpdates() + + self.manager.desiredAccuracy = Self.accuracyValue(desiredAccuracy) + self.manager.pausesLocationUpdatesAutomatically = true + self.manager.allowsBackgroundLocationUpdates = true + + self.isStreaming = true + if significantChangesOnly { + self.manager.startMonitoringSignificantLocationChanges() + } else { + self.manager.startUpdatingLocation() + } + + return AsyncStream(bufferingPolicy: .bufferingNewest(1)) { continuation in + self.updatesContinuation = continuation + continuation.onTermination = { @Sendable _ in + Task { @MainActor in + self.stopLocationUpdates() + } + } + } + } + + func stopLocationUpdates() { + guard self.isStreaming else { return } + self.isStreaming = false + self.manager.stopUpdatingLocation() + self.manager.stopMonitoringSignificantLocationChanges() + self.updatesContinuation?.finish() + self.updatesContinuation = nil + } + + func startMonitoringSignificantLocationChanges(onUpdate: @escaping @Sendable (CLLocation) -> Void) { + self.significantLocationCallback = onUpdate + guard !self.isMonitoringSignificantChanges else { return } + self.isMonitoringSignificantChanges = true + self.manager.startMonitoringSignificantLocationChanges() + } + + func stopMonitoringSignificantLocationChanges() { + guard self.isMonitoringSignificantChanges else { return } + self.isMonitoringSignificantChanges = false + self.significantLocationCallback = nil + self.manager.stopMonitoringSignificantLocationChanges() + } + nonisolated func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) { let status = manager.authorizationStatus Task { @MainActor in @@ -117,12 +171,22 @@ final class LocationService: NSObject, CLLocationManagerDelegate { nonisolated func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) { let locs = locations Task { @MainActor in - guard let cont = self.locationContinuation else { return } - self.locationContinuation = nil - if let latest = locs.last { - cont.resume(returning: latest) - } else { - cont.resume(throwing: Error.unavailable) + // Resolve the one-shot continuation first (if any). + if let cont = self.locationContinuation { + self.locationContinuation = nil + if let latest = locs.last { + cont.resume(returning: latest) + } else { + cont.resume(throwing: Error.unavailable) + } + // Don't return — also forward to significant-change callback below + // so both consumers receive updates when both are active. + } + if let callback = self.significantLocationCallback, let latest = locs.last { + callback(latest) + } + if let latest = locs.last, let updates = self.updatesContinuation { + updates.yield(latest) } } } diff --git a/apps/ios/Sources/Location/SignificantLocationMonitor.swift b/apps/ios/Sources/Location/SignificantLocationMonitor.swift new file mode 100644 index 00000000000..1b8d5ca2a0d --- /dev/null +++ b/apps/ios/Sources/Location/SignificantLocationMonitor.swift @@ -0,0 +1,42 @@ +import CoreLocation +import Foundation +import OpenClawKit + +/// Monitors significant location changes and pushes `location.update` +/// events to the gateway so the severance hook can determine whether +/// the user is at their configured work location. +@MainActor +enum SignificantLocationMonitor { + static func startIfNeeded( + locationService: any LocationServicing, + locationMode: OpenClawLocationMode, + gateway: GatewayNodeSession, + beforeSend: (@MainActor @Sendable () async -> Void)? = nil + ) { + guard locationMode == .always else { return } + let status = locationService.authorizationStatus() + guard status == .authorizedAlways else { return } + locationService.startMonitoringSignificantLocationChanges { location in + struct Payload: Codable { + var lat: Double + var lon: Double + var accuracyMeters: Double + var source: String? + } + let payload = Payload( + lat: location.coordinate.latitude, + lon: location.coordinate.longitude, + accuracyMeters: location.horizontalAccuracy, + source: "ios-significant-location") + guard let data = try? JSONEncoder().encode(payload), + let json = String(data: data, encoding: .utf8) + else { return } + Task { @MainActor in + if let beforeSend { + await beforeSend() + } + await gateway.sendEvent(event: "location.update", payloadJSON: json) + } + } + } +} diff --git a/apps/ios/Sources/Model/NodeAppModel.swift b/apps/ios/Sources/Model/NodeAppModel.swift index 0ca521ccc60..fc5e6097b18 100644 --- a/apps/ios/Sources/Model/NodeAppModel.swift +++ b/apps/ios/Sources/Model/NodeAppModel.swift @@ -2,6 +2,8 @@ import OpenClawChatUI import OpenClawKit import OpenClawProtocol import Observation +import os +import Security import SwiftUI import UIKit import UserNotifications @@ -10,7 +12,6 @@ import UserNotifications private struct NotificationCallError: Error, Sendable { let message: String } - // Ensures notification requests return promptly even if the system prompt blocks. private final class NotificationInvokeLatch: @unchecked Sendable { private let lock = NSLock() @@ -38,9 +39,25 @@ private final class NotificationInvokeLatch: @unchecked Sendable { } } +private enum IOSDeepLinkAgentPolicy { + static let maxMessageChars = 20000 + static let maxUnkeyedConfirmChars = 240 +} + @MainActor @Observable final class NodeAppModel { + struct AgentDeepLinkPrompt: Identifiable, Equatable { + let id: String + let messagePreview: String + let urlPreview: String + let request: AgentDeepLink + } + + private let deepLinkLogger = Logger(subsystem: "ai.openclaw.ios", category: "DeepLink") + private let pushWakeLogger = Logger(subsystem: "ai.openclaw.ios", category: "PushWake") + private let locationWakeLogger = Logger(subsystem: "ai.openclaw.ios", category: "LocationWake") + private let watchReplyLogger = Logger(subsystem: "ai.openclaw.ios", category: "WatchReply") enum CameraHUDKind { case photo case recording @@ -53,35 +70,26 @@ final class NodeAppModel { private let camera: any CameraServicing private let screenRecorder: any ScreenRecordingServicing var gatewayStatusText: String = "Offline" + var nodeStatusText: String = "Offline" + var operatorStatusText: String = "Offline" var gatewayServerName: String? var gatewayRemoteAddress: String? var connectedGatewayID: String? var gatewayAutoReconnectEnabled: Bool = true + // When the gateway requires pairing approval, we pause reconnect churn and show a stable UX. + // Reconnect loops (both our own and the underlying WebSocket watchdog) can otherwise generate + // multiple pending requests and cause the onboarding UI to "flip-flop". + var gatewayPairingPaused: Bool = false + var gatewayPairingRequestId: String? var seamColorHex: String? private var mainSessionBaseKey: String = "main" var selectedAgentId: String? var gatewayDefaultAgentId: String? var gatewayAgents: [AgentSummary] = [] - - var mainSessionKey: String { - let base = SessionKey.normalizeMainKey(self.mainSessionBaseKey) - let agentId = (self.selectedAgentId ?? "").trimmingCharacters(in: .whitespacesAndNewlines) - let defaultId = (self.gatewayDefaultAgentId ?? "").trimmingCharacters(in: .whitespacesAndNewlines) - if agentId.isEmpty || (!defaultId.isEmpty && agentId == defaultId) { return base } - return SessionKey.makeAgentSessionKey(agentId: agentId, baseKey: base) - } - - var activeAgentName: String { - let agentId = (self.selectedAgentId ?? "").trimmingCharacters(in: .whitespacesAndNewlines) - let defaultId = (self.gatewayDefaultAgentId ?? "").trimmingCharacters(in: .whitespacesAndNewlines) - let resolvedId = agentId.isEmpty ? defaultId : agentId - if resolvedId.isEmpty { return "Main" } - if let match = self.gatewayAgents.first(where: { $0.id == resolvedId }) { - let name = (match.name ?? "").trimmingCharacters(in: .whitespacesAndNewlines) - return name.isEmpty ? match.id : name - } - return resolvedId - } + var lastShareEventText: String = "No share events yet." + var openChatRequestID: Int = 0 + private(set) var pendingAgentDeepLinkPrompt: AgentDeepLinkPrompt? + private var lastAgentDeepLinkPromptAt: Date = .distantPast // Primary "node" connection: used for device capabilities and node.invoke requests. private let nodeGateway = GatewayNodeSession() @@ -104,16 +112,29 @@ final class NodeAppModel { private let calendarService: any CalendarServicing private let remindersService: any RemindersServicing private let motionService: any MotionServicing + private let watchMessagingService: any WatchMessagingServicing var lastAutoA2uiURL: String? private var pttVoiceWakeSuspended = false private var talkVoiceWakeSuspended = false private var backgroundVoiceWakeSuspended = false private var backgroundTalkSuspended = false + private var backgroundTalkKeptActive = false private var backgroundedAt: Date? private var reconnectAfterBackgroundArmed = false + private var backgroundGraceTaskID: UIBackgroundTaskIdentifier = .invalid + @ObservationIgnored private var backgroundGraceTaskTimer: Task? + private var backgroundReconnectSuppressed = false + private var backgroundReconnectLeaseUntil: Date? + private var lastSignificantLocationWakeAt: Date? + private var queuedWatchReplies: [WatchQuickReplyEvent] = [] + private var seenWatchReplyIds = Set() private var gatewayConnected = false private var operatorConnected = false + private var shareDeliveryChannel: String? + private var shareDeliveryTo: String? + private var apnsDeviceTokenHex: String? + private var apnsLastRegisteredTokenHex: String? var gatewaySession: GatewayNodeSession { self.nodeGateway } var operatorSession: GatewayNodeSession { self.operatorGateway } private(set) var activeGatewayConnectConfig: GatewayConnectConfig? @@ -135,6 +156,7 @@ final class NodeAppModel { calendarService: any CalendarServicing = CalendarService(), remindersService: any RemindersServicing = RemindersService(), motionService: any MotionServicing = MotionService(), + watchMessagingService: any WatchMessagingServicing = WatchMessagingService(), talkMode: TalkModeManager = TalkModeManager()) { self.screen = screen @@ -148,8 +170,15 @@ final class NodeAppModel { self.calendarService = calendarService self.remindersService = remindersService self.motionService = motionService + self.watchMessagingService = watchMessagingService self.talkMode = talkMode + self.apnsDeviceTokenHex = UserDefaults.standard.string(forKey: Self.apnsDeviceTokenUserDefaultsKey) GatewayDiagnostics.bootstrap() + self.watchMessagingService.setReplyHandler { [weak self] event in + Task { @MainActor in + await self?.handleWatchQuickReply(event) + } + } self.voiceWake.configure { [weak self] cmd in guard let self else { return } @@ -164,6 +193,7 @@ final class NodeAppModel { let enabled = UserDefaults.standard.bool(forKey: "voiceWake.enabled") self.voiceWake.setEnabled(enabled) self.talkMode.attachGateway(self.operatorGateway) + self.refreshLastShareEventFromRelay() let talkEnabled = UserDefaults.standard.bool(forKey: "talk.enabled") // Route through the coordinator so VoiceWake and Talk don't fight over the microphone. self.setTalkEnabled(talkEnabled) @@ -264,17 +294,23 @@ final class NodeAppModel { func setScenePhase(_ phase: ScenePhase) { + let keepTalkActive = UserDefaults.standard.bool(forKey: "talk.background.enabled") switch phase { case .background: self.isBackgrounded = true self.stopGatewayHealthMonitor() self.backgroundedAt = Date() self.reconnectAfterBackgroundArmed = true - // Be conservative: release the mic when the app backgrounds. + self.beginBackgroundConnectionGracePeriod() + // Release voice wake mic in background. self.backgroundVoiceWakeSuspended = self.voiceWake.suspendForExternalAudioCapture() - self.backgroundTalkSuspended = self.talkMode.suspendForBackground() + let shouldKeepTalkActive = keepTalkActive && self.talkMode.isEnabled + self.backgroundTalkKeptActive = shouldKeepTalkActive + self.backgroundTalkSuspended = self.talkMode.suspendForBackground(keepActive: shouldKeepTalkActive) case .active, .inactive: self.isBackgrounded = false + self.endBackgroundConnectionGracePeriod(reason: "scene_foreground") + self.clearBackgroundReconnectSuppression(reason: "scene_foreground") if self.operatorConnected { self.startGatewayHealthMonitor() } @@ -284,8 +320,12 @@ final class NodeAppModel { Task { [weak self] in guard let self else { return } let suspended = await MainActor.run { self.backgroundTalkSuspended } - await MainActor.run { self.backgroundTalkSuspended = false } - await self.talkMode.resumeAfterBackground(wasSuspended: suspended) + let keptActive = await MainActor.run { self.backgroundTalkKeptActive } + await MainActor.run { + self.backgroundTalkSuspended = false + self.backgroundTalkKeptActive = false + } + await self.talkMode.resumeAfterBackground(wasSuspended: suspended, wasKeptActive: keptActive) } } if phase == .active, self.reconnectAfterBackgroundArmed { @@ -322,9 +362,98 @@ final class NodeAppModel { } @unknown default: self.isBackgrounded = false + self.endBackgroundConnectionGracePeriod(reason: "scene_unknown") + self.clearBackgroundReconnectSuppression(reason: "scene_unknown") } } + private func beginBackgroundConnectionGracePeriod(seconds: TimeInterval = 25) { + self.grantBackgroundReconnectLease(seconds: seconds, reason: "scene_background_grace") + self.endBackgroundConnectionGracePeriod(reason: "restart") + let taskID = UIApplication.shared.beginBackgroundTask(withName: "gateway-background-grace") { [weak self] in + Task { @MainActor in + self?.suppressBackgroundReconnect( + reason: "background_grace_expired", + disconnectIfNeeded: true) + self?.endBackgroundConnectionGracePeriod(reason: "expired") + } + } + guard taskID != .invalid else { + self.pushWakeLogger.info("Background grace unavailable: beginBackgroundTask returned invalid") + return + } + self.backgroundGraceTaskID = taskID + self.pushWakeLogger.info("Background grace started seconds=\(seconds, privacy: .public)") + self.backgroundGraceTaskTimer = Task { [weak self] in + guard let self else { return } + try? await Task.sleep(nanoseconds: UInt64(max(1, seconds) * 1_000_000_000)) + await MainActor.run { + self.suppressBackgroundReconnect(reason: "background_grace_timer", disconnectIfNeeded: true) + self.endBackgroundConnectionGracePeriod(reason: "timer") + } + } + } + + private func endBackgroundConnectionGracePeriod(reason: String) { + self.backgroundGraceTaskTimer?.cancel() + self.backgroundGraceTaskTimer = nil + guard self.backgroundGraceTaskID != .invalid else { return } + UIApplication.shared.endBackgroundTask(self.backgroundGraceTaskID) + self.backgroundGraceTaskID = .invalid + self.pushWakeLogger.info("Background grace ended reason=\(reason, privacy: .public)") + } + + private func grantBackgroundReconnectLease(seconds: TimeInterval, reason: String) { + guard self.isBackgrounded else { return } + let leaseSeconds = max(5, seconds) + let leaseUntil = Date().addingTimeInterval(leaseSeconds) + if let existing = self.backgroundReconnectLeaseUntil, existing > leaseUntil { + // Keep the longer lease if one is already active. + } else { + self.backgroundReconnectLeaseUntil = leaseUntil + } + let wasSuppressed = self.backgroundReconnectSuppressed + self.backgroundReconnectSuppressed = false + self.pushWakeLogger.info( + "Background reconnect lease reason=\(reason, privacy: .public) seconds=\(leaseSeconds, privacy: .public) wasSuppressed=\(wasSuppressed, privacy: .public)") + } + + private func suppressBackgroundReconnect(reason: String, disconnectIfNeeded: Bool) { + guard self.isBackgrounded else { return } + let hadLease = self.backgroundReconnectLeaseUntil != nil + let changed = hadLease || !self.backgroundReconnectSuppressed + self.backgroundReconnectLeaseUntil = nil + self.backgroundReconnectSuppressed = true + guard changed else { return } + self.pushWakeLogger.info( + "Background reconnect suppressed reason=\(reason, privacy: .public) disconnect=\(disconnectIfNeeded, privacy: .public)") + guard disconnectIfNeeded else { return } + Task { [weak self] in + guard let self else { return } + await self.operatorGateway.disconnect() + await self.nodeGateway.disconnect() + await MainActor.run { + self.operatorConnected = false + self.gatewayConnected = false + self.talkMode.updateGatewayConnected(false) + if self.isBackgrounded { + self.gatewayStatusText = "Background idle" + self.gatewayServerName = nil + self.gatewayRemoteAddress = nil + self.showLocalCanvasOnDisconnect() + } + } + } + } + + private func clearBackgroundReconnectSuppression(reason: String) { + let changed = self.backgroundReconnectSuppressed || self.backgroundReconnectLeaseUntil != nil + self.backgroundReconnectSuppressed = false + self.backgroundReconnectLeaseUntil = nil + guard changed else { return } + self.pushWakeLogger.info("Background reconnect cleared reason=\(reason, privacy: .public)") + } + func setVoiceWakeEnabled(_ enabled: Bool) { self.voiceWake.setEnabled(enabled) if enabled { @@ -340,6 +469,7 @@ final class NodeAppModel { } func setTalkEnabled(_ enabled: Bool) { + UserDefaults.standard.set(enabled, forKey: "talk.enabled") if enabled { // Voice wake holds the microphone continuously; talk mode needs exclusive access for STT. // When talk is enabled from the UI, prioritize talk and pause voice wake. @@ -351,6 +481,11 @@ final class NodeAppModel { self.talkVoiceWakeSuspended = false } self.talkMode.setEnabled(enabled) + Task { [weak self] in + await self?.pushTalkModeToGateway( + enabled: enabled, + phase: enabled ? "enabled" : "disabled") + } } func requestLocationPermissions(mode: OpenClawLocationMode) async -> Bool { @@ -366,30 +501,20 @@ final class NodeAppModel { } } - private func applyMainSessionKey(_ key: String?) { - let trimmed = (key ?? "").trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty else { return } - let current = self.mainSessionBaseKey.trimmingCharacters(in: .whitespacesAndNewlines) - if trimmed == current { return } - self.mainSessionBaseKey = trimmed - self.talkMode.updateMainSessionKey(self.mainSessionKey) - } - var seamColor: Color { Self.color(fromHex: self.seamColorHex) ?? Self.defaultSeamColor } private static let defaultSeamColor = Color(red: 79 / 255.0, green: 122 / 255.0, blue: 154 / 255.0) - - private static func color(fromHex raw: String?) -> Color? { - let trimmed = (raw ?? "").trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty else { return nil } - let hex = trimmed.hasPrefix("#") ? String(trimmed.dropFirst()) : trimmed - guard hex.count == 6, let value = Int(hex, radix: 16) else { return nil } - let r = Double((value >> 16) & 0xFF) / 255.0 - let g = Double((value >> 8) & 0xFF) / 255.0 - let b = Double(value & 0xFF) / 255.0 - return Color(red: r, green: g, blue: b) + private static let apnsDeviceTokenUserDefaultsKey = "push.apns.deviceTokenHex" + private static let deepLinkKeyUserDefaultsKey = "deeplink.agent.key" + private static let canvasUnattendedDeepLinkKey: String = NodeAppModel.generateDeepLinkKey() + private static var apnsEnvironment: String { +#if DEBUG + "sandbox" +#else + "production" +#endif } private func refreshBrandingFromGateway() async { @@ -447,6 +572,16 @@ final class NodeAppModel { GatewaySettingsStore.saveGatewaySelectedAgentId(stableID: stableID, agentId: self.selectedAgentId) } self.talkMode.updateMainSessionKey(self.mainSessionKey) + if let relay = ShareGatewayRelaySettings.loadConfig() { + ShareGatewayRelaySettings.saveConfig( + ShareGatewayRelayConfig( + gatewayURLString: relay.gatewayURLString, + token: relay.token, + password: relay.password, + sessionKey: self.mainSessionKey, + deliveryChannel: self.shareDeliveryChannel, + deliveryTo: self.shareDeliveryTo)) + } } func setGlobalWakeWords(_ words: [String]) async { @@ -479,16 +614,49 @@ final class NodeAppModel { let stream = await self.operatorGateway.subscribeServerEvents(bufferingNewest: 200) for await evt in stream { if Task.isCancelled { return } - guard evt.event == "voicewake.changed" else { continue } guard let payload = evt.payload else { continue } - struct Payload: Decodable { var triggers: [String] } - guard let decoded = try? GatewayPayloadDecoding.decode(payload, as: Payload.self) else { continue } - let triggers = VoiceWakePreferences.sanitizeTriggerWords(decoded.triggers) - VoiceWakePreferences.saveTriggerWords(triggers) + switch evt.event { + case "voicewake.changed": + struct Payload: Decodable { var triggers: [String] } + guard let decoded = try? GatewayPayloadDecoding.decode(payload, as: Payload.self) else { continue } + let triggers = VoiceWakePreferences.sanitizeTriggerWords(decoded.triggers) + VoiceWakePreferences.saveTriggerWords(triggers) + case "talk.mode": + struct Payload: Decodable { + var enabled: Bool + var phase: String? + } + guard let decoded = try? GatewayPayloadDecoding.decode(payload, as: Payload.self) else { continue } + self.applyTalkModeSync(enabled: decoded.enabled, phase: decoded.phase) + default: + continue + } } } } + private func applyTalkModeSync(enabled: Bool, phase: String?) { + _ = phase + guard self.talkMode.isEnabled != enabled else { return } + self.setTalkEnabled(enabled) + } + + private func pushTalkModeToGateway(enabled: Bool, phase: String?) async { + guard await self.isOperatorConnected() else { return } + struct TalkModePayload: Encodable { + var enabled: Bool + var phase: String? + } + let payload = TalkModePayload(enabled: enabled, phase: phase) + guard let data = try? JSONEncoder().encode(payload), + let json = String(data: data, encoding: .utf8) + else { return } + _ = try? await self.operatorGateway.request( + method: "talk.mode", + paramsJSON: json, + timeoutSeconds: 8) + } + private func startGatewayHealthMonitor() { self.gatewayHealthMonitorDisabled = false self.gatewayHealthMonitor.start( @@ -504,7 +672,7 @@ final class NodeAppModel { } catch { if let gatewayError = error as? GatewayResponseError { let lower = gatewayError.message.lowercased() - if lower.contains("unauthorized role") { + if lower.contains("unauthorized role") || lower.contains("missing scope") { await self.setGatewayHealthMonitorDisabled(true) return true } @@ -515,8 +683,11 @@ final class NodeAppModel { onFailure: { [weak self] _ in guard let self else { return } await self.operatorGateway.disconnect() + await self.nodeGateway.disconnect() await MainActor.run { self.operatorConnected = false + self.gatewayConnected = false + self.gatewayStatusText = "Reconnecting…" self.talkMode.updateGatewayConnected(false) } }) @@ -526,104 +697,6 @@ final class NodeAppModel { self.gatewayHealthMonitor.stop() } - private func refreshWakeWordsFromGateway() async { - do { - let data = try await self.operatorGateway.request(method: "voicewake.get", paramsJSON: "{}", timeoutSeconds: 8) - guard let triggers = VoiceWakePreferences.decodeGatewayTriggers(from: data) else { return } - VoiceWakePreferences.saveTriggerWords(triggers) - } catch { - if let gatewayError = error as? GatewayResponseError { - let lower = gatewayError.message.lowercased() - if lower.contains("unauthorized role") { - await self.setGatewayHealthMonitorDisabled(true) - return - } - } - // Best-effort only. - } - } - - private func isGatewayHealthMonitorDisabled() -> Bool { - self.gatewayHealthMonitorDisabled - } - - private func setGatewayHealthMonitorDisabled(_ disabled: Bool) { - self.gatewayHealthMonitorDisabled = disabled - } - - func sendVoiceTranscript(text: String, sessionKey: String?) async throws { - if await !self.isGatewayConnected() { - throw NSError(domain: "Gateway", code: 10, userInfo: [ - NSLocalizedDescriptionKey: "Gateway not connected", - ]) - } - struct Payload: Codable { - var text: String - var sessionKey: String? - } - let payload = Payload(text: text, sessionKey: sessionKey) - let data = try JSONEncoder().encode(payload) - guard let json = String(bytes: data, encoding: .utf8) else { - throw NSError(domain: "NodeAppModel", code: 1, userInfo: [ - NSLocalizedDescriptionKey: "Failed to encode voice transcript payload as UTF-8", - ]) - } - await self.nodeGateway.sendEvent(event: "voice.transcript", payloadJSON: json) - } - - func handleDeepLink(url: URL) async { - guard let route = DeepLinkParser.parse(url) else { return } - - switch route { - case let .agent(link): - await self.handleAgentDeepLink(link, originalURL: url) - } - } - - private func handleAgentDeepLink(_ link: AgentDeepLink, originalURL: URL) async { - let message = link.message.trimmingCharacters(in: .whitespacesAndNewlines) - guard !message.isEmpty else { return } - - if message.count > 20000 { - self.screen.errorText = "Deep link too large (message exceeds 20,000 characters)." - return - } - - guard await self.isGatewayConnected() else { - self.screen.errorText = "Gateway not connected (cannot forward deep link)." - return - } - - do { - try await self.sendAgentRequest(link: link) - self.screen.errorText = nil - } catch { - self.screen.errorText = "Agent request failed: \(error.localizedDescription)" - } - } - - private func sendAgentRequest(link: AgentDeepLink) async throws { - if link.message.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { - throw NSError(domain: "DeepLink", code: 1, userInfo: [ - NSLocalizedDescriptionKey: "invalid agent message", - ]) - } - - // iOS gateway forwards to the gateway; no local auth prompts here. - // (Key-based unattended auth is handled on macOS for openclaw:// links.) - let data = try JSONEncoder().encode(link) - guard let json = String(bytes: data, encoding: .utf8) else { - throw NSError(domain: "NodeAppModel", code: 2, userInfo: [ - NSLocalizedDescriptionKey: "Failed to encode agent request payload as UTF-8", - ]) - } - await self.nodeGateway.sendEvent(event: "agent.request", payloadJSON: json) - } - - private func isGatewayConnected() async -> Bool { - self.gatewayConnected - } - private func handleInvoke(_ req: BridgeInvokeRequest) async -> BridgeInvokeResponse { let command = req.command @@ -1345,6 +1418,14 @@ private extension NodeAppModel { return try await self.handleDeviceInvoke(req) } + register([ + OpenClawWatchCommand.status.rawValue, + OpenClawWatchCommand.notify.rawValue, + ]) { [weak self] req in + guard let self else { throw NodeCapabilityRouter.RouterError.handlerUnavailable } + return try await self.handleWatchInvoke(req) + } + register([OpenClawPhotosCommand.latest.rawValue]) { [weak self] req in guard let self else { throw NodeCapabilityRouter.RouterError.handlerUnavailable } return try await self.handlePhotosInvoke(req) @@ -1395,14 +1476,74 @@ private extension NodeAppModel { return NodeCapabilityRouter(handlers: handlers) } + func handleWatchInvoke(_ req: BridgeInvokeRequest) async throws -> BridgeInvokeResponse { + switch req.command { + case OpenClawWatchCommand.status.rawValue: + let status = await self.watchMessagingService.status() + let payload = OpenClawWatchStatusPayload( + supported: status.supported, + paired: status.paired, + appInstalled: status.appInstalled, + reachable: status.reachable, + activationState: status.activationState) + let json = try Self.encodePayload(payload) + return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: json) + case OpenClawWatchCommand.notify.rawValue: + let params = try Self.decodeParams(OpenClawWatchNotifyParams.self, from: req.paramsJSON) + let title = params.title.trimmingCharacters(in: .whitespacesAndNewlines) + let body = params.body.trimmingCharacters(in: .whitespacesAndNewlines) + if title.isEmpty && body.isEmpty { + return BridgeInvokeResponse( + id: req.id, + ok: false, + error: OpenClawNodeError( + code: .invalidRequest, + message: "INVALID_REQUEST: empty watch notification")) + } + do { + let result = try await self.watchMessagingService.sendNotification( + id: req.id, + params: params) + if result.queuedForDelivery || !result.deliveredImmediately { + let invokeID = req.id + Task { @MainActor in + await WatchPromptNotificationBridge.scheduleMirroredWatchPromptNotificationIfNeeded( + invokeID: invokeID, + params: params, + sendResult: result) + } + } + let payload = OpenClawWatchNotifyPayload( + deliveredImmediately: result.deliveredImmediately, + queuedForDelivery: result.queuedForDelivery, + transport: result.transport) + let json = try Self.encodePayload(payload) + return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: json) + } catch { + return BridgeInvokeResponse( + id: req.id, + ok: false, + error: OpenClawNodeError( + code: .unavailable, + message: error.localizedDescription)) + } + default: + return BridgeInvokeResponse( + id: req.id, + ok: false, + error: OpenClawNodeError(code: .invalidRequest, message: "INVALID_REQUEST: unknown command")) + } + } + func locationMode() -> OpenClawLocationMode { let raw = UserDefaults.standard.string(forKey: "location.enabledMode") ?? "off" return OpenClawLocationMode(rawValue: raw) ?? .off } func isLocationPreciseEnabled() -> Bool { - if UserDefaults.standard.object(forKey: "location.preciseEnabled") == nil { return true } - return UserDefaults.standard.bool(forKey: "location.preciseEnabled") + // iOS settings now expose a single location mode control. + // Default location tool precision stays high unless a command explicitly requests balanced. + true } static func decodeParams(_ type: T.Type, from json: String?) throws -> T { @@ -1454,6 +1595,34 @@ private extension NodeAppModel { } extension NodeAppModel { + var mainSessionKey: String { + let base = SessionKey.normalizeMainKey(self.mainSessionBaseKey) + let agentId = (self.selectedAgentId ?? "").trimmingCharacters(in: .whitespacesAndNewlines) + let defaultId = (self.gatewayDefaultAgentId ?? "").trimmingCharacters(in: .whitespacesAndNewlines) + if agentId.isEmpty || (!defaultId.isEmpty && agentId == defaultId) { return base } + return SessionKey.makeAgentSessionKey(agentId: agentId, baseKey: base) + } + + var chatSessionKey: String { + let base = "ios" + let agentId = (self.selectedAgentId ?? "").trimmingCharacters(in: .whitespacesAndNewlines) + let defaultId = (self.gatewayDefaultAgentId ?? "").trimmingCharacters(in: .whitespacesAndNewlines) + if agentId.isEmpty || (!defaultId.isEmpty && agentId == defaultId) { return base } + return SessionKey.makeAgentSessionKey(agentId: agentId, baseKey: base) + } + + var activeAgentName: String { + let agentId = (self.selectedAgentId ?? "").trimmingCharacters(in: .whitespacesAndNewlines) + let defaultId = (self.gatewayDefaultAgentId ?? "").trimmingCharacters(in: .whitespacesAndNewlines) + let resolvedId = agentId.isEmpty ? defaultId : agentId + if resolvedId.isEmpty { return "Main" } + if let match = self.gatewayAgents.first(where: { $0.id == resolvedId }) { + let name = (match.name ?? "").trimmingCharacters(in: .whitespacesAndNewlines) + return name.isEmpty ? match.id : name + } + return resolvedId + } + func connectToGateway( url: URL, gatewayStableID: String, @@ -1506,6 +1675,8 @@ extension NodeAppModel { func disconnectGateway() { self.gatewayAutoReconnectEnabled = false + self.gatewayPairingPaused = false + self.gatewayPairingRequestId = nil self.nodeGatewayTask?.cancel() self.nodeGatewayTask = nil self.operatorGatewayTask?.cancel() @@ -1528,6 +1699,7 @@ extension NodeAppModel { self.seamColorHex = nil self.mainSessionBaseKey = "main" self.talkMode.updateMainSessionKey(self.mainSessionKey) + ShareGatewayRelaySettings.clearConfig() self.showLocalCanvasOnDisconnect() } } @@ -1535,6 +1707,8 @@ extension NodeAppModel { private extension NodeAppModel { func prepareForGatewayConnect(url: URL, stableID: String) { self.gatewayAutoReconnectEnabled = true + self.gatewayPairingPaused = false + self.gatewayPairingRequestId = nil self.nodeGatewayTask?.cancel() self.operatorGatewayTask?.cancel() self.gatewayHealthMonitor.stop() @@ -1548,6 +1722,24 @@ private extension NodeAppModel { self.gatewayDefaultAgentId = nil self.gatewayAgents = [] self.selectedAgentId = GatewaySettingsStore.loadGatewaySelectedAgentId(stableID: stableID) + self.apnsLastRegisteredTokenHex = nil + } + + func refreshBackgroundReconnectSuppressionIfNeeded(source: String) { + guard self.isBackgrounded else { return } + guard !self.backgroundReconnectSuppressed else { return } + guard let leaseUntil = self.backgroundReconnectLeaseUntil else { + self.suppressBackgroundReconnect(reason: "\(source):no_lease", disconnectIfNeeded: true) + return + } + if Date() >= leaseUntil { + self.suppressBackgroundReconnect(reason: "\(source):lease_expired", disconnectIfNeeded: true) + } + } + + func shouldPauseReconnectLoopInBackground(source: String) -> Bool { + self.refreshBackgroundReconnectSuppressionIfNeeded(source: source) + return self.isBackgrounded && self.backgroundReconnectSuppressed } func startOperatorGatewayLoop( @@ -1564,6 +1756,15 @@ private extension NodeAppModel { guard let self else { return } var attempt = 0 while !Task.isCancelled { + if self.gatewayPairingPaused { + try? await Task.sleep(nanoseconds: 1_000_000_000) + continue + } + if !self.gatewayAutoReconnectEnabled { + try? await Task.sleep(nanoseconds: 1_000_000_000) + continue + } + if self.shouldPauseReconnectLoopInBackground(source: "operator_loop") { try? await Task.sleep(nanoseconds: 2_000_000_000); continue } if await self.isOperatorConnected() { try? await Task.sleep(nanoseconds: 1_000_000_000) continue @@ -1590,8 +1791,10 @@ private extension NodeAppModel { } GatewayDiagnostics.log( "operator gateway connected host=\(url.host ?? "?") scheme=\(url.scheme ?? "?")") + await self.talkMode.reloadConfig() await self.refreshBrandingFromGateway() await self.refreshAgentsFromGateway() + await self.refreshShareRouteFromGateway() await self.startVoiceWakeSync() await MainActor.run { self.startGatewayHealthMonitor() } }, @@ -1639,8 +1842,18 @@ private extension NodeAppModel { var attempt = 0 var currentOptions = nodeOptions var didFallbackClientId = false + var pausedForPairingApproval = false while !Task.isCancelled { + if self.gatewayPairingPaused { + try? await Task.sleep(nanoseconds: 1_000_000_000) + continue + } + if !self.gatewayAutoReconnectEnabled { + try? await Task.sleep(nanoseconds: 1_000_000_000) + continue + } + if self.shouldPauseReconnectLoopInBackground(source: "node_loop") { try? await Task.sleep(nanoseconds: 2_000_000_000); continue } if await self.isGatewayConnected() { try? await Task.sleep(nanoseconds: 1_000_000_000) continue @@ -1669,12 +1882,36 @@ private extension NodeAppModel { self.screen.errorText = nil UserDefaults.standard.set(true, forKey: "gateway.autoconnect") } - GatewayDiagnostics.log( - "gateway connected host=\(url.host ?? "?") scheme=\(url.scheme ?? "?")") + let relayData = await MainActor.run { + ( + sessionKey: self.mainSessionKey, + deliveryChannel: self.shareDeliveryChannel, + deliveryTo: self.shareDeliveryTo + ) + } + ShareGatewayRelaySettings.saveConfig( + ShareGatewayRelayConfig( + gatewayURLString: url.absoluteString, + token: token, + password: password, + sessionKey: relayData.sessionKey, + deliveryChannel: relayData.deliveryChannel, + deliveryTo: relayData.deliveryTo)) + GatewayDiagnostics.log("gateway connected host=\(url.host ?? "?") scheme=\(url.scheme ?? "?")") if let addr = await self.nodeGateway.currentRemoteAddress() { await MainActor.run { self.gatewayRemoteAddress = addr } } await self.showA2UIOnConnectIfNeeded() + await self.onNodeGatewayConnected() + await MainActor.run { + SignificantLocationMonitor.startIfNeeded( + locationService: self.locationService, + locationMode: self.locationMode(), + gateway: self.nodeGateway, + beforeSend: { [weak self] in + await self?.handleSignificantLocationWakeIfNeeded() + }) + } }, onDisconnected: { [weak self] reason in guard let self else { return } @@ -1726,11 +1963,60 @@ private extension NodeAppModel { self.showLocalCanvasOnDisconnect() } GatewayDiagnostics.log("gateway connect error: \(error.localizedDescription)") + + // If auth is missing/rejected, pause reconnect churn until the user intervenes. + // Reconnect loops only spam the same failing handshake and make onboarding noisy. + let lower = error.localizedDescription.lowercased() + if lower.contains("unauthorized") || lower.contains("gateway token missing") { + await MainActor.run { + self.gatewayAutoReconnectEnabled = false + } + } + + // If pairing is required, stop reconnect churn. The user must approve the request + // on the gateway before another connect attempt will succeed, and retry loops can + // generate multiple pending requests. + if lower.contains("not_paired") || lower.contains("pairing required") { + let requestId: String? = { + // GatewayResponseError for connect decorates the message with `(requestId: ...)`. + // Keep this resilient since other layers may wrap the text. + let text = error.localizedDescription + guard let start = text.range(of: "(requestId: ")?.upperBound else { return nil } + guard let end = text[start...].firstIndex(of: ")") else { return nil } + let raw = String(text[start.. String? { @@ -1775,6 +2061,621 @@ private extension NodeAppModel { } } +extension NodeAppModel { + private func refreshShareRouteFromGateway() async { + struct Params: Codable { + var includeGlobal: Bool + var includeUnknown: Bool + var limit: Int + } + struct SessionRow: Decodable { + var key: String + var updatedAt: Double? + var lastChannel: String? + var lastTo: String? + } + struct SessionsListResult: Decodable { + var sessions: [SessionRow] + } + + let normalize: (String?) -> String? = { raw in + let value = (raw ?? "").trimmingCharacters(in: .whitespacesAndNewlines) + return value.isEmpty ? nil : value + } + + do { + let data = try JSONEncoder().encode( + Params(includeGlobal: true, includeUnknown: false, limit: 80)) + guard let json = String(data: data, encoding: .utf8) else { return } + let response = try await self.operatorGateway.request( + method: "sessions.list", + paramsJSON: json, + timeoutSeconds: 10) + let decoded = try JSONDecoder().decode(SessionsListResult.self, from: response) + let currentKey = self.mainSessionKey + let sorted = decoded.sessions.sorted { ($0.updatedAt ?? 0) > ($1.updatedAt ?? 0) } + let exactMatch = sorted.first { row in + row.key == currentKey && normalize(row.lastChannel) != nil && normalize(row.lastTo) != nil + } + let selected = exactMatch + let channel = normalize(selected?.lastChannel) + let to = normalize(selected?.lastTo) + + await MainActor.run { + self.shareDeliveryChannel = channel + self.shareDeliveryTo = to + if let relay = ShareGatewayRelaySettings.loadConfig() { + ShareGatewayRelaySettings.saveConfig( + ShareGatewayRelayConfig( + gatewayURLString: relay.gatewayURLString, + token: relay.token, + password: relay.password, + sessionKey: self.mainSessionKey, + deliveryChannel: channel, + deliveryTo: to)) + } + } + } catch { + // Best-effort only. + } + } + + func runSharePipelineSelfTest() async { + self.recordShareEvent("Share self-test running…") + + let payload = SharedContentPayload( + title: "OpenClaw Share Self-Test", + url: URL(string: "https://openclaw.ai/share-self-test"), + text: "Validate iOS share->deep-link->gateway forwarding.") + guard let deepLink = ShareToAgentDeepLink.buildURL( + from: payload, + instruction: "Reply with: SHARE SELF-TEST OK") + else { + self.recordShareEvent("Self-test failed: could not build deep link.") + return + } + + await self.handleDeepLink(url: deepLink) + } + + func refreshLastShareEventFromRelay() { + if let event = ShareGatewayRelaySettings.loadLastEvent() { + self.lastShareEventText = event + } + } + + func recordShareEvent(_ text: String) { + ShareGatewayRelaySettings.saveLastEvent(text) + self.refreshLastShareEventFromRelay() + } + + func reloadTalkConfig() { + Task { [weak self] in + await self?.talkMode.reloadConfig() + } + } + + /// Back-compat hook retained for older gateway-connect flows. + func onNodeGatewayConnected() async { + await self.registerAPNsTokenIfNeeded() + await self.flushQueuedWatchRepliesIfConnected() + } + + private func handleWatchQuickReply(_ event: WatchQuickReplyEvent) async { + let replyId = event.replyId.trimmingCharacters(in: .whitespacesAndNewlines) + let actionId = event.actionId.trimmingCharacters(in: .whitespacesAndNewlines) + if replyId.isEmpty || actionId.isEmpty { + self.watchReplyLogger.info("watch reply dropped: missing replyId/actionId") + return + } + + if self.seenWatchReplyIds.contains(replyId) { + self.watchReplyLogger.debug( + "watch reply deduped replyId=\(replyId, privacy: .public)") + return + } + self.seenWatchReplyIds.insert(replyId) + + if await !self.isGatewayConnected() { + self.queuedWatchReplies.append(event) + self.watchReplyLogger.info( + "watch reply queued replyId=\(replyId, privacy: .public) action=\(actionId, privacy: .public)") + return + } + + await self.forwardWatchReplyToAgent(event) + } + + private func flushQueuedWatchRepliesIfConnected() async { + guard await self.isGatewayConnected() else { return } + guard !self.queuedWatchReplies.isEmpty else { return } + + let pending = self.queuedWatchReplies + self.queuedWatchReplies.removeAll() + for event in pending { + await self.forwardWatchReplyToAgent(event) + } + } + + private func forwardWatchReplyToAgent(_ event: WatchQuickReplyEvent) async { + let sessionKey = event.sessionKey?.trimmingCharacters(in: .whitespacesAndNewlines) + let effectiveSessionKey = (sessionKey?.isEmpty == false) ? sessionKey : self.mainSessionKey + let message = Self.makeWatchReplyAgentMessage(event) + let link = AgentDeepLink( + message: message, + sessionKey: effectiveSessionKey, + thinking: "low", + deliver: false, + to: nil, + channel: nil, + timeoutSeconds: nil, + key: event.replyId) + do { + try await self.sendAgentRequest(link: link) + self.watchReplyLogger.info( + "watch reply forwarded replyId=\(event.replyId, privacy: .public) action=\(event.actionId, privacy: .public)") + self.openChatRequestID &+= 1 + } catch { + self.watchReplyLogger.error( + "watch reply forwarding failed replyId=\(event.replyId, privacy: .public) error=\(error.localizedDescription, privacy: .public)") + self.queuedWatchReplies.insert(event, at: 0) + } + } + + private static func makeWatchReplyAgentMessage(_ event: WatchQuickReplyEvent) -> String { + let actionLabel = event.actionLabel?.trimmingCharacters(in: .whitespacesAndNewlines) + let promptId = event.promptId.trimmingCharacters(in: .whitespacesAndNewlines) + let transport = event.transport.trimmingCharacters(in: .whitespacesAndNewlines) + let summary = actionLabel?.isEmpty == false ? actionLabel! : event.actionId + var lines: [String] = [] + lines.append("Watch reply: \(summary)") + lines.append("promptId=\(promptId.isEmpty ? "unknown" : promptId)") + lines.append("actionId=\(event.actionId)") + lines.append("replyId=\(event.replyId)") + if !transport.isEmpty { + lines.append("transport=\(transport)") + } + if let sentAtMs = event.sentAtMs { + lines.append("sentAtMs=\(sentAtMs)") + } + if let note = event.note?.trimmingCharacters(in: .whitespacesAndNewlines), !note.isEmpty { + lines.append("note=\(note)") + } + return lines.joined(separator: "\n") + } + + func handleSilentPushWake(_ userInfo: [AnyHashable: Any]) async -> Bool { + let wakeId = Self.makePushWakeAttemptID() + guard Self.isSilentPushPayload(userInfo) else { + self.pushWakeLogger.info("Ignored APNs payload wakeId=\(wakeId, privacy: .public): not silent push") + return false + } + let pushKind = Self.openclawPushKind(userInfo) + self.pushWakeLogger.info( + "Silent push received wakeId=\(wakeId, privacy: .public) kind=\(pushKind, privacy: .public) backgrounded=\(self.isBackgrounded, privacy: .public) autoReconnect=\(self.gatewayAutoReconnectEnabled, privacy: .public)") + let result = await self.reconnectGatewaySessionsForSilentPushIfNeeded(wakeId: wakeId) + self.pushWakeLogger.info( + "Silent push outcome wakeId=\(wakeId, privacy: .public) applied=\(result.applied, privacy: .public) reason=\(result.reason, privacy: .public) durationMs=\(result.durationMs, privacy: .public)") + return result.applied + } + + func handleBackgroundRefreshWake(trigger: String = "bg_app_refresh") async -> Bool { + let wakeId = Self.makePushWakeAttemptID() + self.pushWakeLogger.info( + "Background refresh wake received wakeId=\(wakeId, privacy: .public) trigger=\(trigger, privacy: .public) backgrounded=\(self.isBackgrounded, privacy: .public) autoReconnect=\(self.gatewayAutoReconnectEnabled, privacy: .public)") + let result = await self.reconnectGatewaySessionsForSilentPushIfNeeded(wakeId: wakeId) + self.pushWakeLogger.info( + "Background refresh wake outcome wakeId=\(wakeId, privacy: .public) applied=\(result.applied, privacy: .public) reason=\(result.reason, privacy: .public) durationMs=\(result.durationMs, privacy: .public)") + return result.applied + } + + func handleSignificantLocationWakeIfNeeded() async { + let wakeId = Self.makePushWakeAttemptID() + let now = Date() + let throttleWindowSeconds: TimeInterval = 180 + + if await self.isGatewayConnected() { + self.locationWakeLogger.info( + "Location wake no-op wakeId=\(wakeId, privacy: .public): already connected") + return + } + if let last = self.lastSignificantLocationWakeAt, + now.timeIntervalSince(last) < throttleWindowSeconds + { + self.locationWakeLogger.info( + "Location wake throttled wakeId=\(wakeId, privacy: .public) elapsedSec=\(now.timeIntervalSince(last), privacy: .public)") + return + } + self.lastSignificantLocationWakeAt = now + + self.locationWakeLogger.info( + "Location wake begin wakeId=\(wakeId, privacy: .public) backgrounded=\(self.isBackgrounded, privacy: .public) autoReconnect=\(self.gatewayAutoReconnectEnabled, privacy: .public)") + let result = await self.reconnectGatewaySessionsForSilentPushIfNeeded(wakeId: wakeId) + self.locationWakeLogger.info( + "Location wake trigger wakeId=\(wakeId, privacy: .public) applied=\(result.applied, privacy: .public) reason=\(result.reason, privacy: .public) durationMs=\(result.durationMs, privacy: .public)") + + guard result.applied else { return } + let connected = await self.waitForGatewayConnection(timeoutMs: 5000, pollMs: 250) + self.locationWakeLogger.info( + "Location wake post-check wakeId=\(wakeId, privacy: .public) connected=\(connected, privacy: .public)") + } + + func updateAPNsDeviceToken(_ tokenData: Data) { + let tokenHex = tokenData.map { String(format: "%02x", $0) }.joined() + let trimmed = tokenHex.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return } + self.apnsDeviceTokenHex = trimmed + UserDefaults.standard.set(trimmed, forKey: Self.apnsDeviceTokenUserDefaultsKey) + Task { [weak self] in + await self?.registerAPNsTokenIfNeeded() + } + } + + private func registerAPNsTokenIfNeeded() async { + guard self.gatewayConnected else { return } + guard let token = self.apnsDeviceTokenHex?.trimmingCharacters(in: .whitespacesAndNewlines), + !token.isEmpty + else { + return + } + if token == self.apnsLastRegisteredTokenHex { + return + } + guard let topic = Bundle.main.bundleIdentifier?.trimmingCharacters(in: .whitespacesAndNewlines), + !topic.isEmpty + else { + return + } + + struct PushRegistrationPayload: Codable { + var token: String + var topic: String + var environment: String + } + + let payload = PushRegistrationPayload( + token: token, + topic: topic, + environment: Self.apnsEnvironment) + do { + let json = try Self.encodePayload(payload) + await self.nodeGateway.sendEvent(event: "push.apns.register", payloadJSON: json) + self.apnsLastRegisteredTokenHex = token + } catch { + // Best-effort only. + } + } + + private static func isSilentPushPayload(_ userInfo: [AnyHashable: Any]) -> Bool { + guard let apsAny = userInfo["aps"] else { return false } + if let aps = apsAny as? [AnyHashable: Any] { + return Self.hasContentAvailable(aps["content-available"]) + } + if let aps = apsAny as? [String: Any] { + return Self.hasContentAvailable(aps["content-available"]) + } + return false + } + + private static func hasContentAvailable(_ value: Any?) -> Bool { + if let number = value as? NSNumber { + return number.intValue == 1 + } + if let text = value as? String { + return text.trimmingCharacters(in: .whitespacesAndNewlines) == "1" + } + return false + } + + private static func makePushWakeAttemptID() -> String { + let raw = UUID().uuidString.replacingOccurrences(of: "-", with: "") + return String(raw.prefix(8)) + } + + private static func openclawPushKind(_ userInfo: [AnyHashable: Any]) -> String { + if let payload = userInfo["openclaw"] as? [String: Any], + let kind = payload["kind"] as? String + { + let trimmed = kind.trimmingCharacters(in: .whitespacesAndNewlines) + if !trimmed.isEmpty { return trimmed } + } + if let payload = userInfo["openclaw"] as? [AnyHashable: Any], + let kind = payload["kind"] as? String + { + let trimmed = kind.trimmingCharacters(in: .whitespacesAndNewlines) + if !trimmed.isEmpty { return trimmed } + } + return "unknown" + } + + private struct SilentPushWakeAttemptResult { + var applied: Bool + var reason: String + var durationMs: Int + } + + private func waitForGatewayConnection(timeoutMs: Int, pollMs: Int) async -> Bool { + let clampedTimeoutMs = max(0, timeoutMs) + let pollIntervalNs = UInt64(max(50, pollMs)) * 1_000_000 + let deadline = Date().addingTimeInterval(Double(clampedTimeoutMs) / 1000.0) + while Date() < deadline { + if await self.isGatewayConnected() { + return true + } + try? await Task.sleep(nanoseconds: pollIntervalNs) + } + return await self.isGatewayConnected() + } + + private func reconnectGatewaySessionsForSilentPushIfNeeded( + wakeId: String + ) async -> SilentPushWakeAttemptResult { + let startedAt = Date() + let makeResult: (Bool, String) -> SilentPushWakeAttemptResult = { applied, reason in + let durationMs = Int(Date().timeIntervalSince(startedAt) * 1000) + return SilentPushWakeAttemptResult( + applied: applied, + reason: reason, + durationMs: max(0, durationMs)) + } + + guard self.isBackgrounded else { + self.pushWakeLogger.info("Wake no-op wakeId=\(wakeId, privacy: .public): app not backgrounded") + return makeResult(false, "not_backgrounded") + } + guard self.gatewayAutoReconnectEnabled else { + self.pushWakeLogger.info("Wake no-op wakeId=\(wakeId, privacy: .public): auto reconnect disabled") + return makeResult(false, "auto_reconnect_disabled") + } + guard let cfg = self.activeGatewayConnectConfig else { + self.pushWakeLogger.info("Wake no-op wakeId=\(wakeId, privacy: .public): no active gateway config") + return makeResult(false, "no_active_gateway_config") + } + + self.pushWakeLogger.info( + "Wake reconnect begin wakeId=\(wakeId, privacy: .public) stableID=\(cfg.stableID, privacy: .public)") + self.grantBackgroundReconnectLease(seconds: 30, reason: "wake_\(wakeId)") + await self.operatorGateway.disconnect() + await self.nodeGateway.disconnect() + self.operatorConnected = false + self.gatewayConnected = false + self.gatewayStatusText = "Reconnecting…" + self.talkMode.updateGatewayConnected(false) + self.applyGatewayConnectConfig(cfg) + self.pushWakeLogger.info("Wake reconnect trigger applied wakeId=\(wakeId, privacy: .public)") + return makeResult(true, "reconnect_triggered") + } +} + +extension NodeAppModel { + private func refreshWakeWordsFromGateway() async { + do { + let data = try await self.operatorGateway.request(method: "voicewake.get", paramsJSON: "{}", timeoutSeconds: 8) + guard let triggers = VoiceWakePreferences.decodeGatewayTriggers(from: data) else { return } + VoiceWakePreferences.saveTriggerWords(triggers) + } catch { + if let gatewayError = error as? GatewayResponseError { + let lower = gatewayError.message.lowercased() + if lower.contains("unauthorized role") || lower.contains("missing scope") { + await self.setGatewayHealthMonitorDisabled(true) + return + } + } + // Best-effort only. + } + } + + private func isGatewayHealthMonitorDisabled() -> Bool { + self.gatewayHealthMonitorDisabled + } + + private func setGatewayHealthMonitorDisabled(_ disabled: Bool) { + self.gatewayHealthMonitorDisabled = disabled + } + + func sendVoiceTranscript(text: String, sessionKey: String?) async throws { + if await !self.isGatewayConnected() { + throw NSError(domain: "Gateway", code: 10, userInfo: [ + NSLocalizedDescriptionKey: "Gateway not connected", + ]) + } + struct Payload: Codable { + var text: String + var sessionKey: String? + } + let payload = Payload(text: text, sessionKey: sessionKey) + let data = try JSONEncoder().encode(payload) + guard let json = String(bytes: data, encoding: .utf8) else { + throw NSError(domain: "NodeAppModel", code: 1, userInfo: [ + NSLocalizedDescriptionKey: "Failed to encode voice transcript payload as UTF-8", + ]) + } + await self.nodeGateway.sendEvent(event: "voice.transcript", payloadJSON: json) + } + + func handleDeepLink(url: URL) async { + guard let route = DeepLinkParser.parse(url) else { return } + + switch route { + case let .agent(link): + await self.handleAgentDeepLink(link, originalURL: url) + case .gateway: + break + } + } + + private func handleAgentDeepLink(_ link: AgentDeepLink, originalURL: URL) async { + let message = link.message.trimmingCharacters(in: .whitespacesAndNewlines) + guard !message.isEmpty else { return } + self.deepLinkLogger.info( + "agent deep link received messageChars=\(message.count) url=\(originalURL.absoluteString, privacy: .public)" + ) + + if message.count > IOSDeepLinkAgentPolicy.maxMessageChars { + self.screen.errorText = "Deep link too large (message exceeds \(IOSDeepLinkAgentPolicy.maxMessageChars) characters)." + self.recordShareEvent("Rejected: message too large (\(message.count) chars).") + return + } + + guard await self.isGatewayConnected() else { + self.screen.errorText = "Gateway not connected (cannot forward deep link)." + self.recordShareEvent("Failed: gateway not connected.") + self.deepLinkLogger.error("agent deep link rejected: gateway not connected") + return + } + + let allowUnattended = self.isUnattendedDeepLinkAllowed(link.key) + if !allowUnattended { + if message.count > IOSDeepLinkAgentPolicy.maxUnkeyedConfirmChars { + self.screen.errorText = "Deep link blocked (message too long without key)." + self.recordShareEvent( + "Rejected: deep link over \(IOSDeepLinkAgentPolicy.maxUnkeyedConfirmChars) chars without key.") + self.deepLinkLogger.error( + "agent deep link rejected: unkeyed message too long chars=\(message.count, privacy: .public)") + return + } + if Date().timeIntervalSince(self.lastAgentDeepLinkPromptAt) < 1.0 { + self.deepLinkLogger.debug("agent deep link prompt throttled") + return + } + self.lastAgentDeepLinkPromptAt = Date() + + let urlText = originalURL.absoluteString + let prompt = AgentDeepLinkPrompt( + id: UUID().uuidString, + messagePreview: message, + urlPreview: urlText.count > 500 ? "\(urlText.prefix(500))…" : urlText, + request: self.effectiveAgentDeepLinkForPrompt(link)) + self.pendingAgentDeepLinkPrompt = prompt + self.recordShareEvent("Awaiting local confirmation (\(message.count) chars).") + self.deepLinkLogger.info("agent deep link requires local confirmation") + return + } + + await self.submitAgentDeepLink(link, messageCharCount: message.count) + } + + private func sendAgentRequest(link: AgentDeepLink) async throws { + if link.message.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + throw NSError(domain: "DeepLink", code: 1, userInfo: [ + NSLocalizedDescriptionKey: "invalid agent message", + ]) + } + + let data = try JSONEncoder().encode(link) + guard let json = String(bytes: data, encoding: .utf8) else { + throw NSError(domain: "NodeAppModel", code: 2, userInfo: [ + NSLocalizedDescriptionKey: "Failed to encode agent request payload as UTF-8", + ]) + } + await self.nodeGateway.sendEvent(event: "agent.request", payloadJSON: json) + } + + private func isGatewayConnected() async -> Bool { + self.gatewayConnected + } + + private func applyMainSessionKey(_ key: String?) { + let trimmed = (key ?? "").trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return } + let current = self.mainSessionBaseKey.trimmingCharacters(in: .whitespacesAndNewlines) + if trimmed == current { return } + self.mainSessionBaseKey = trimmed + self.talkMode.updateMainSessionKey(self.mainSessionKey) + } + + private static func color(fromHex raw: String?) -> Color? { + let trimmed = (raw ?? "").trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return nil } + let hex = trimmed.hasPrefix("#") ? String(trimmed.dropFirst()) : trimmed + guard hex.count == 6, let value = Int(hex, radix: 16) else { return nil } + let r = Double((value >> 16) & 0xFF) / 255.0 + let g = Double((value >> 8) & 0xFF) / 255.0 + let b = Double(value & 0xFF) / 255.0 + return Color(red: r, green: g, blue: b) + } + + func approvePendingAgentDeepLinkPrompt() async { + guard let prompt = self.pendingAgentDeepLinkPrompt else { return } + self.pendingAgentDeepLinkPrompt = nil + guard await self.isGatewayConnected() else { + self.screen.errorText = "Gateway not connected (cannot forward deep link)." + self.recordShareEvent("Failed: gateway not connected.") + self.deepLinkLogger.error("agent deep link approval failed: gateway not connected") + return + } + await self.submitAgentDeepLink(prompt.request, messageCharCount: prompt.messagePreview.count) + } + + func declinePendingAgentDeepLinkPrompt() { + guard self.pendingAgentDeepLinkPrompt != nil else { return } + self.pendingAgentDeepLinkPrompt = nil + self.screen.errorText = "Deep link cancelled." + self.recordShareEvent("Cancelled: deep link confirmation declined.") + self.deepLinkLogger.info("agent deep link cancelled by local user") + } + + private func submitAgentDeepLink(_ link: AgentDeepLink, messageCharCount: Int) async { + do { + try await self.sendAgentRequest(link: link) + self.screen.errorText = nil + self.recordShareEvent("Sent to gateway (\(messageCharCount) chars).") + self.deepLinkLogger.info("agent deep link forwarded to gateway") + self.openChatRequestID &+= 1 + } catch { + self.screen.errorText = "Agent request failed: \(error.localizedDescription)" + self.recordShareEvent("Failed: \(error.localizedDescription)") + self.deepLinkLogger.error("agent deep link send failed: \(error.localizedDescription, privacy: .public)") + } + } + + private func effectiveAgentDeepLinkForPrompt(_ link: AgentDeepLink) -> AgentDeepLink { + // Without a trusted key, strip delivery/routing knobs to reduce exfiltration risk. + AgentDeepLink( + message: link.message, + sessionKey: link.sessionKey, + thinking: link.thinking, + deliver: false, + to: nil, + channel: nil, + timeoutSeconds: link.timeoutSeconds, + key: link.key) + } + + private func isUnattendedDeepLinkAllowed(_ key: String?) -> Bool { + let normalizedKey = key?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + guard !normalizedKey.isEmpty else { return false } + return normalizedKey == Self.canvasUnattendedDeepLinkKey || normalizedKey == Self.expectedDeepLinkKey() + } + + private static func expectedDeepLinkKey() -> String { + let defaults = UserDefaults.standard + if let key = defaults.string(forKey: self.deepLinkKeyUserDefaultsKey), !key.isEmpty { + return key + } + let key = self.generateDeepLinkKey() + defaults.set(key, forKey: self.deepLinkKeyUserDefaultsKey) + return key + } + + private static func generateDeepLinkKey() -> String { + var bytes = [UInt8](repeating: 0, count: 32) + _ = SecRandomCopyBytes(kSecRandomDefault, bytes.count, &bytes) + let data = Data(bytes) + return data + .base64EncodedString() + .replacingOccurrences(of: "+", with: "-") + .replacingOccurrences(of: "/", with: "_") + .replacingOccurrences(of: "=", with: "") + } +} + +extension NodeAppModel { + func _bridgeConsumeMirroredWatchReply(_ event: WatchQuickReplyEvent) async { + await self.handleWatchQuickReply(event) + } +} + #if DEBUG extension NodeAppModel { func _test_handleInvoke(_ req: BridgeInvokeRequest) async -> BridgeInvokeResponse { @@ -1808,5 +2709,21 @@ extension NodeAppModel { func _test_showLocalCanvasOnDisconnect() { self.showLocalCanvasOnDisconnect() } + + func _test_applyTalkModeSync(enabled: Bool, phase: String? = nil) { + self.applyTalkModeSync(enabled: enabled, phase: phase) + } + + func _test_queuedWatchReplyCount() -> Int { + self.queuedWatchReplies.count + } + + func _test_setGatewayConnected(_ connected: Bool) { + self.gatewayConnected = connected + } + + static func _test_currentDeepLinkKey() -> String { + self.expectedDeepLinkKey() + } } #endif diff --git a/apps/ios/Sources/Onboarding/OnboardingStateStore.swift b/apps/ios/Sources/Onboarding/OnboardingStateStore.swift new file mode 100644 index 00000000000..9822ac1706f --- /dev/null +++ b/apps/ios/Sources/Onboarding/OnboardingStateStore.swift @@ -0,0 +1,52 @@ +import Foundation + +enum OnboardingConnectionMode: String, CaseIterable { + case homeNetwork = "home_network" + case remoteDomain = "remote_domain" + case developerLocal = "developer_local" + + var title: String { + switch self { + case .homeNetwork: + "Home Network" + case .remoteDomain: + "Remote Domain" + case .developerLocal: + "Same Machine (Dev)" + } + } +} + +enum OnboardingStateStore { + private static let completedDefaultsKey = "onboarding.completed" + private static let lastModeDefaultsKey = "onboarding.last_mode" + private static let lastSuccessTimeDefaultsKey = "onboarding.last_success_time" + + @MainActor + static func shouldPresentOnLaunch(appModel: NodeAppModel, defaults: UserDefaults = .standard) -> Bool { + if defaults.bool(forKey: Self.completedDefaultsKey) { return false } + // If we have a last-known connection config, don't force onboarding on launch. Auto-connect + // should handle reconnecting, and users can always open onboarding manually if needed. + if GatewaySettingsStore.loadLastGatewayConnection() != nil { return false } + return appModel.gatewayServerName == nil + } + + static func markCompleted(mode: OnboardingConnectionMode? = nil, defaults: UserDefaults = .standard) { + defaults.set(true, forKey: Self.completedDefaultsKey) + if let mode { + defaults.set(mode.rawValue, forKey: Self.lastModeDefaultsKey) + } + defaults.set(Int(Date().timeIntervalSince1970), forKey: Self.lastSuccessTimeDefaultsKey) + } + + static func markIncomplete(defaults: UserDefaults = .standard) { + defaults.set(false, forKey: Self.completedDefaultsKey) + } + + static func lastMode(defaults: UserDefaults = .standard) -> OnboardingConnectionMode? { + let raw = defaults.string(forKey: Self.lastModeDefaultsKey)? + .trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + guard !raw.isEmpty else { return nil } + return OnboardingConnectionMode(rawValue: raw) + } +} diff --git a/apps/ios/Sources/Onboarding/OnboardingWizardView.swift b/apps/ios/Sources/Onboarding/OnboardingWizardView.swift new file mode 100644 index 00000000000..c0e872b2ceb --- /dev/null +++ b/apps/ios/Sources/Onboarding/OnboardingWizardView.swift @@ -0,0 +1,890 @@ +import CoreImage +import Combine +import OpenClawKit +import PhotosUI +import SwiftUI +import UIKit + +private enum OnboardingStep: Int, CaseIterable { + case welcome + case mode + case connect + case auth + case success + + var previous: Self? { + Self(rawValue: self.rawValue - 1) + } + + var next: Self? { + Self(rawValue: self.rawValue + 1) + } + + /// Progress label for the manual setup flow (mode → connect → auth → success). + var manualProgressTitle: String { + let manualSteps: [OnboardingStep] = [.mode, .connect, .auth, .success] + guard let idx = manualSteps.firstIndex(of: self) else { return "" } + return "Step \(idx + 1) of \(manualSteps.count)" + } + + var title: String { + switch self { + case .welcome: "Welcome" + case .mode: "Connection Mode" + case .connect: "Connect" + case .auth: "Authentication" + case .success: "Connected" + } + } + + var canGoBack: Bool { + self != .welcome && self != .success + } +} + +struct OnboardingWizardView: View { + @Environment(NodeAppModel.self) private var appModel: NodeAppModel + @Environment(GatewayConnectionController.self) private var gatewayController: GatewayConnectionController + @Environment(\.scenePhase) private var scenePhase + @AppStorage("node.instanceId") private var instanceId: String = UUID().uuidString + @AppStorage("gateway.discovery.domain") private var discoveryDomain: String = "" + @AppStorage("onboarding.developerMode") private var developerModeEnabled: Bool = false + @State private var step: OnboardingStep = .welcome + @State private var selectedMode: OnboardingConnectionMode? + @State private var manualHost: String = "" + @State private var manualPort: Int = 18789 + @State private var manualPortText: String = "18789" + @State private var manualTLS: Bool = true + @State private var gatewayToken: String = "" + @State private var gatewayPassword: String = "" + @State private var connectMessage: String? + @State private var statusLine: String = "Scan the QR code from your gateway to connect." + @State private var connectingGatewayID: String? + @State private var issue: GatewayConnectionIssue = .none + @State private var didMarkCompleted = false + @State private var didAutoPresentQR = false + @State private var pairingRequestId: String? + @State private var discoveryRestartTask: Task? + @State private var showQRScanner: Bool = false + @State private var scannerError: String? + @State private var selectedPhoto: PhotosPickerItem? + @State private var lastPairingAutoResumeAttemptAt: Date? + private static let pairingAutoResumeTicker = Timer.publish(every: 2.0, on: .main, in: .common).autoconnect() + + let allowSkip: Bool + let onClose: () -> Void + + private var isFullScreenStep: Bool { + self.step == .welcome || self.step == .success + } + + var body: some View { + NavigationStack { + Group { + switch self.step { + case .welcome: + self.welcomeStep + case .success: + self.successStep + default: + Form { + switch self.step { + case .mode: + self.modeStep + case .connect: + self.connectStep + case .auth: + self.authStep + default: + EmptyView() + } + } + .scrollDismissesKeyboard(.interactively) + } + } + .navigationTitle(self.isFullScreenStep ? "" : self.step.title) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + if !self.isFullScreenStep { + ToolbarItem(placement: .principal) { + VStack(spacing: 2) { + Text(self.step.title) + .font(.headline) + Text(self.step.manualProgressTitle) + .font(.caption2) + .foregroundStyle(.secondary) + } + } + } + ToolbarItem(placement: .topBarLeading) { + if self.step.canGoBack { + Button { + self.navigateBack() + } label: { + Label("Back", systemImage: "chevron.left") + } + } else if self.allowSkip { + Button("Close") { + self.onClose() + } + } + } + ToolbarItemGroup(placement: .keyboard) { + Spacer() + Button("Done") { + UIApplication.shared.sendAction( + #selector(UIResponder.resignFirstResponder), + to: nil, from: nil, for: nil) + } + } + } + } + .gatewayTrustPromptAlert() + .alert("QR Scanner Unavailable", isPresented: Binding( + get: { self.scannerError != nil }, + set: { if !$0 { self.scannerError = nil } } + )) { + Button("OK", role: .cancel) {} + } message: { + Text(self.scannerError ?? "") + } + .sheet(isPresented: self.$showQRScanner) { + NavigationStack { + QRScannerView( + onGatewayLink: { link in + self.handleScannedLink(link) + }, + onError: { error in + self.showQRScanner = false + self.statusLine = "Scanner error: \(error)" + self.scannerError = error + }, + onDismiss: { + self.showQRScanner = false + }) + .ignoresSafeArea() + .navigationTitle("Scan QR Code") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .topBarLeading) { + Button("Cancel") { self.showQRScanner = false } + } + ToolbarItem(placement: .topBarTrailing) { + PhotosPicker(selection: self.$selectedPhoto, matching: .images) { + Label("Photos", systemImage: "photo") + } + } + } + } + .onChange(of: self.selectedPhoto) { _, newValue in + guard let item = newValue else { return } + self.selectedPhoto = nil + Task { + guard let data = try? await item.loadTransferable(type: Data.self) else { + self.showQRScanner = false + self.scannerError = "Could not load the selected image." + return + } + if let message = self.detectQRCode(from: data) { + if let link = GatewayConnectDeepLink.fromSetupCode(message) { + self.handleScannedLink(link) + return + } + if let url = URL(string: message), + let route = DeepLinkParser.parse(url), + case let .gateway(link) = route + { + self.handleScannedLink(link) + return + } + } + self.showQRScanner = false + self.scannerError = "No valid QR code found in the selected image." + } + } + } + .onAppear { + self.initializeState() + } + .onDisappear { + self.discoveryRestartTask?.cancel() + self.discoveryRestartTask = nil + } + .onChange(of: self.discoveryDomain) { _, _ in + self.scheduleDiscoveryRestart() + } + .onChange(of: self.manualPortText) { _, newValue in + let digits = newValue.filter(\.isNumber) + if digits != newValue { + self.manualPortText = digits + return + } + guard let parsed = Int(digits), parsed > 0 else { + self.manualPort = 0 + return + } + self.manualPort = min(parsed, 65535) + } + .onChange(of: self.manualPort) { _, newValue in + let normalized = newValue > 0 ? String(newValue) : "" + if self.manualPortText != normalized { + self.manualPortText = normalized + } + } + .onChange(of: self.gatewayToken) { _, newValue in + self.saveGatewayCredentials(token: newValue, password: self.gatewayPassword) + } + .onChange(of: self.gatewayPassword) { _, newValue in + self.saveGatewayCredentials(token: self.gatewayToken, password: newValue) + } + .onChange(of: self.appModel.gatewayStatusText) { _, newValue in + let next = GatewayConnectionIssue.detect(from: newValue) + // Avoid "flip-flopping" the UI by clearing actionable issues when the underlying connection + // transitions through intermediate statuses (e.g. Offline/Connecting while reconnect churns). + if self.issue.needsPairing, next.needsPairing { + // Keep the requestId sticky even if the status line omits it after we pause. + let mergedRequestId = next.requestId ?? self.issue.requestId ?? self.pairingRequestId + self.issue = .pairingRequired(requestId: mergedRequestId) + } else if self.issue.needsPairing, !next.needsPairing { + // Ignore non-pairing statuses until the user explicitly retries/scans again, or we connect. + } else if self.issue.needsAuthToken, !next.needsAuthToken, !next.needsPairing { + // Same idea for auth: once we learn credentials are missing/rejected, keep that sticky until + // the user retries/scans again or we successfully connect. + } else { + self.issue = next + } + + if let requestId = next.requestId, !requestId.isEmpty { + self.pairingRequestId = requestId + } + + // If the gateway tells us auth is missing/rejected, stop reconnect churn until the user intervenes. + if next.needsAuthToken { + self.appModel.gatewayAutoReconnectEnabled = false + } + + if self.issue.needsAuthToken || self.issue.needsPairing { + self.step = .auth + } + if !newValue.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + self.connectMessage = newValue + self.statusLine = newValue + } + } + .onChange(of: self.appModel.gatewayServerName) { _, newValue in + guard newValue != nil else { return } + self.showQRScanner = false + self.statusLine = "Connected." + if !self.didMarkCompleted, let selectedMode { + OnboardingStateStore.markCompleted(mode: selectedMode) + self.didMarkCompleted = true + } + self.onClose() + } + .onChange(of: self.scenePhase) { _, newValue in + guard newValue == .active else { return } + self.attemptAutomaticPairingResumeIfNeeded() + } + .onReceive(Self.pairingAutoResumeTicker) { _ in + self.attemptAutomaticPairingResumeIfNeeded() + } + } + + @ViewBuilder + private var welcomeStep: some View { + VStack(spacing: 0) { + Spacer() + + Image(systemName: "qrcode.viewfinder") + .font(.system(size: 64)) + .foregroundStyle(.tint) + .padding(.bottom, 20) + + Text("Welcome") + .font(.largeTitle.weight(.bold)) + .padding(.bottom, 8) + + Text("Connect to your OpenClaw gateway") + .font(.subheadline) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + .padding(.horizontal, 32) + + Spacer() + + VStack(spacing: 12) { + Button { + self.statusLine = "Opening QR scanner…" + self.showQRScanner = true + } label: { + Label("Scan QR Code", systemImage: "qrcode") + .frame(maxWidth: .infinity) + } + .buttonStyle(.borderedProminent) + .controlSize(.large) + + Button { + self.step = .mode + } label: { + Text("Set Up Manually") + .frame(maxWidth: .infinity) + } + .buttonStyle(.bordered) + .controlSize(.large) + } + .padding(.bottom, 12) + + Text(self.statusLine) + .font(.footnote) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + .padding(.horizontal, 24) + .padding(.horizontal, 24) + .padding(.bottom, 48) + } + } + + @ViewBuilder + private var modeStep: some View { + Section("Connection Mode") { + OnboardingModeRow( + title: OnboardingConnectionMode.homeNetwork.title, + subtitle: "LAN or Tailscale host", + selected: self.selectedMode == .homeNetwork) + { + self.selectMode(.homeNetwork) + } + + OnboardingModeRow( + title: OnboardingConnectionMode.remoteDomain.title, + subtitle: "VPS with domain", + selected: self.selectedMode == .remoteDomain) + { + self.selectMode(.remoteDomain) + } + + Toggle( + "Developer mode", + isOn: Binding( + get: { self.developerModeEnabled }, + set: { newValue in + self.developerModeEnabled = newValue + if !newValue, self.selectedMode == .developerLocal { + self.selectedMode = nil + } + })) + + if self.developerModeEnabled { + OnboardingModeRow( + title: OnboardingConnectionMode.developerLocal.title, + subtitle: "For local iOS app development", + selected: self.selectedMode == .developerLocal) + { + self.selectMode(.developerLocal) + } + } + } + + Section { + Button("Continue") { + self.step = .connect + } + .disabled(self.selectedMode == nil) + } + } + + @ViewBuilder + private var connectStep: some View { + if let selectedMode { + Section { + LabeledContent("Mode", value: selectedMode.title) + LabeledContent("Discovery", value: self.gatewayController.discoveryStatusText) + LabeledContent("Status", value: self.appModel.gatewayStatusText) + LabeledContent("Progress", value: self.statusLine) + } header: { + Text("Status") + } footer: { + if let connectMessage { + Text(connectMessage) + } + } + + switch selectedMode { + case .homeNetwork: + self.homeNetworkConnectSection + case .remoteDomain: + self.remoteDomainConnectSection + case .developerLocal: + self.developerConnectSection + } + } else { + Section { + Text("Choose a mode first.") + Button("Back to Mode Selection") { + self.step = .mode + } + } + } + } + + private var homeNetworkConnectSection: some View { + Group { + Section("Discovered Gateways") { + if self.gatewayController.gateways.isEmpty { + Text("No gateways found yet.") + .foregroundStyle(.secondary) + } else { + ForEach(self.gatewayController.gateways) { gateway in + let hasHost = self.gatewayHasResolvableHost(gateway) + + HStack { + VStack(alignment: .leading, spacing: 4) { + Text(gateway.name) + if let host = gateway.lanHost ?? gateway.tailnetDns { + Text(host) + .font(.footnote) + .foregroundStyle(.secondary) + } + } + Spacer() + Button { + Task { await self.connectDiscoveredGateway(gateway) } + } label: { + if self.connectingGatewayID == gateway.id { + ProgressView() + .progressViewStyle(.circular) + } else if !hasHost { + Text("Resolving…") + } else { + Text("Connect") + } + } + .disabled(self.connectingGatewayID != nil || !hasHost) + } + } + } + + Button("Restart Discovery") { + self.gatewayController.restartDiscovery() + } + .disabled(self.connectingGatewayID != nil) + } + + self.manualConnectionFieldsSection(title: "Manual Fallback") + } + } + + private var remoteDomainConnectSection: some View { + self.manualConnectionFieldsSection(title: "Domain Settings") + } + + private var developerConnectSection: some View { + Section { + TextField("Host", text: self.$manualHost) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + TextField("Port", text: self.$manualPortText) + .keyboardType(.numberPad) + Toggle("Use TLS", isOn: self.$manualTLS) + + Button { + Task { await self.connectManual() } + } label: { + if self.connectingGatewayID == "manual" { + HStack(spacing: 8) { + ProgressView() + .progressViewStyle(.circular) + Text("Connecting…") + } + } else { + Text("Connect") + } + } + .disabled(!self.canConnectManual || self.connectingGatewayID != nil) + } header: { + Text("Developer Local") + } footer: { + Text("Default host is localhost. Use your Mac LAN IP if simulator networking requires it.") + } + } + + private var authStep: some View { + Group { + Section("Authentication") { + TextField("Gateway Auth Token", text: self.$gatewayToken) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + SecureField("Gateway Password", text: self.$gatewayPassword) + + if self.issue.needsAuthToken { + Text("Gateway rejected credentials. Scan a fresh QR code or update token/password.") + .font(.footnote) + .foregroundStyle(.secondary) + } else { + Text("Auth token looks valid.") + .font(.footnote) + .foregroundStyle(.secondary) + } + } + + if self.issue.needsPairing { + Section { + Button { + self.resumeAfterPairingApproval() + } label: { + Label("Resume After Approval", systemImage: "arrow.clockwise") + } + .disabled(self.connectingGatewayID != nil) + } header: { + Text("Pairing Approval") + } footer: { + let requestLine: String = { + if let id = self.issue.requestId, !id.isEmpty { + return "Request ID: \(id)" + } + return "Request ID: check `openclaw devices list`." + }() + Text( + "Approve this device on the gateway.\n" + + "1) `openclaw devices approve` (or `openclaw devices approve `)\n" + + "2) `/pair approve` in Telegram\n" + + "\(requestLine)\n" + + "OpenClaw will also retry automatically when you return to this app.") + } + } + + Section { + Button { + self.openQRScannerFromOnboarding() + } label: { + Label("Scan QR Code Again", systemImage: "qrcode.viewfinder") + } + .disabled(self.connectingGatewayID != nil) + + Button { + Task { await self.retryLastAttempt() } + } label: { + if self.connectingGatewayID == "retry" { + ProgressView() + .progressViewStyle(.circular) + } else { + Text("Retry Connection") + } + } + .disabled(self.connectingGatewayID != nil) + } + } + } + + private var successStep: some View { + VStack(spacing: 0) { + Spacer() + + Image(systemName: "checkmark.circle.fill") + .font(.system(size: 64)) + .foregroundStyle(.green) + .padding(.bottom, 20) + + Text("Connected") + .font(.largeTitle.weight(.bold)) + .padding(.bottom, 8) + + let server = self.appModel.gatewayServerName ?? "gateway" + Text(server) + .font(.subheadline) + .foregroundStyle(.secondary) + .padding(.bottom, 4) + + if let addr = self.appModel.gatewayRemoteAddress { + Text(addr) + .font(.subheadline) + .foregroundStyle(.secondary) + } + + Spacer() + + Button { + self.onClose() + } label: { + Text("Open OpenClaw") + .frame(maxWidth: .infinity) + } + .buttonStyle(.borderedProminent) + .controlSize(.large) + .padding(.horizontal, 24) + .padding(.bottom, 48) + } + } + + @ViewBuilder + private func manualConnectionFieldsSection(title: String) -> some View { + Section(title) { + TextField("Host", text: self.$manualHost) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + TextField("Port", text: self.$manualPortText) + .keyboardType(.numberPad) + Toggle("Use TLS", isOn: self.$manualTLS) + TextField("Discovery Domain (optional)", text: self.$discoveryDomain) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + + Button { + Task { await self.connectManual() } + } label: { + if self.connectingGatewayID == "manual" { + HStack(spacing: 8) { + ProgressView() + .progressViewStyle(.circular) + Text("Connecting…") + } + } else { + Text("Connect") + } + } + .disabled(!self.canConnectManual || self.connectingGatewayID != nil) + } + } + + private func handleScannedLink(_ link: GatewayConnectDeepLink) { + self.manualHost = link.host + self.manualPort = link.port + self.manualTLS = link.tls + if let token = link.token { + self.gatewayToken = token + } + if let password = link.password { + self.gatewayPassword = password + } + self.saveGatewayCredentials(token: self.gatewayToken, password: self.gatewayPassword) + self.showQRScanner = false + self.connectMessage = "Connecting via QR code…" + self.statusLine = "QR loaded. Connecting to \(link.host):\(link.port)…" + if self.selectedMode == nil { + self.selectedMode = link.tls ? .remoteDomain : .homeNetwork + } + Task { await self.connectManual() } + } + + private func openQRScannerFromOnboarding() { + // Stop active reconnect loops before scanning new credentials. + self.appModel.disconnectGateway() + self.connectingGatewayID = nil + self.connectMessage = nil + self.issue = .none + self.pairingRequestId = nil + self.statusLine = "Opening QR scanner…" + self.showQRScanner = true + } + + private func resumeAfterPairingApproval() { + // We intentionally stop reconnect churn while unpaired to avoid generating multiple pending requests. + self.appModel.gatewayAutoReconnectEnabled = true + self.appModel.gatewayPairingPaused = false + self.appModel.gatewayPairingRequestId = nil + // Pairing state is sticky to prevent UI flip-flop during reconnect churn. + // Once the user explicitly resumes after approving, clear the sticky issue + // so new status/auth errors can surface instead of being masked as pairing. + self.issue = .none + self.connectMessage = "Retrying after approval…" + self.statusLine = "Retrying after approval…" + Task { await self.retryLastAttempt() } + } + + private func resumeAfterPairingApprovalInBackground() { + // Keep the pairing issue sticky to avoid visual flicker while we probe for approval. + self.appModel.gatewayAutoReconnectEnabled = true + self.appModel.gatewayPairingPaused = false + self.appModel.gatewayPairingRequestId = nil + Task { await self.retryLastAttempt(silent: true) } + } + + private func attemptAutomaticPairingResumeIfNeeded() { + guard self.scenePhase == .active else { return } + guard self.step == .auth else { return } + guard self.issue.needsPairing else { return } + guard self.connectingGatewayID == nil else { return } + + let now = Date() + if let last = self.lastPairingAutoResumeAttemptAt, now.timeIntervalSince(last) < 6 { + return + } + self.lastPairingAutoResumeAttemptAt = now + self.resumeAfterPairingApprovalInBackground() + } + + private func detectQRCode(from data: Data) -> String? { + guard let ciImage = CIImage(data: data) else { return nil } + let detector = CIDetector( + ofType: CIDetectorTypeQRCode, context: nil, + options: [CIDetectorAccuracy: CIDetectorAccuracyHigh]) + let features = detector?.features(in: ciImage) ?? [] + for feature in features { + if let qr = feature as? CIQRCodeFeature, let message = qr.messageString { + return message + } + } + return nil + } + + private func navigateBack() { + guard let target = self.step.previous else { return } + self.connectingGatewayID = nil + self.connectMessage = nil + self.step = target + } + private var canConnectManual: Bool { + let host = self.manualHost.trimmingCharacters(in: .whitespacesAndNewlines) + return !host.isEmpty && self.manualPort > 0 && self.manualPort <= 65535 + } + + private func initializeState() { + if self.manualHost.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + if let last = GatewaySettingsStore.loadLastGatewayConnection() { + switch last { + case let .manual(host, port, useTLS, _): + self.manualHost = host + self.manualPort = port + self.manualTLS = useTLS + case .discovered: + self.manualHost = "openclaw.local" + self.manualPort = 18789 + self.manualTLS = true + } + } else { + self.manualHost = "openclaw.local" + self.manualPort = 18789 + self.manualTLS = true + } + } + self.manualPortText = self.manualPort > 0 ? String(self.manualPort) : "" + if self.selectedMode == nil { + self.selectedMode = OnboardingStateStore.lastMode() + } + if self.selectedMode == .developerLocal && self.manualHost == "openclaw.local" { + self.manualHost = "localhost" + self.manualTLS = false + } + + let trimmedInstanceId = self.instanceId.trimmingCharacters(in: .whitespacesAndNewlines) + if !trimmedInstanceId.isEmpty { + self.gatewayToken = GatewaySettingsStore.loadGatewayToken(instanceId: trimmedInstanceId) ?? "" + self.gatewayPassword = GatewaySettingsStore.loadGatewayPassword(instanceId: trimmedInstanceId) ?? "" + } + + let hasSavedGateway = GatewaySettingsStore.loadLastGatewayConnection() != nil + let hasToken = !self.gatewayToken.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + let hasPassword = !self.gatewayPassword.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + if !self.didAutoPresentQR, !hasSavedGateway, !hasToken, !hasPassword { + self.didAutoPresentQR = true + self.statusLine = "No saved pairing found. Scan QR code to connect." + self.showQRScanner = true + } + } + + private func scheduleDiscoveryRestart() { + self.discoveryRestartTask?.cancel() + self.discoveryRestartTask = Task { @MainActor in + try? await Task.sleep(nanoseconds: 350_000_000) + guard !Task.isCancelled else { return } + self.gatewayController.restartDiscovery() + } + } + + private func saveGatewayCredentials(token: String, password: String) { + let trimmedInstanceId = self.instanceId.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmedInstanceId.isEmpty else { return } + let trimmedToken = token.trimmingCharacters(in: .whitespacesAndNewlines) + GatewaySettingsStore.saveGatewayToken(trimmedToken, instanceId: trimmedInstanceId) + let trimmedPassword = password.trimmingCharacters(in: .whitespacesAndNewlines) + GatewaySettingsStore.saveGatewayPassword(trimmedPassword, instanceId: trimmedInstanceId) + } + + private func connectDiscoveredGateway(_ gateway: GatewayDiscoveryModel.DiscoveredGateway) async { + self.connectingGatewayID = gateway.id + self.issue = .none + self.connectMessage = "Connecting to \(gateway.name)…" + self.statusLine = "Connecting to \(gateway.name)…" + defer { self.connectingGatewayID = nil } + await self.gatewayController.connect(gateway) + } + + private func selectMode(_ mode: OnboardingConnectionMode) { + self.selectedMode = mode + self.applyModeDefaults(mode) + } + + private func applyModeDefaults(_ mode: OnboardingConnectionMode) { + let host = self.manualHost.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + let hostIsDefaultLike = host.isEmpty || host == "openclaw.local" || host == "localhost" + + switch mode { + case .homeNetwork: + if hostIsDefaultLike { self.manualHost = "openclaw.local" } + self.manualTLS = true + if self.manualPort <= 0 || self.manualPort > 65535 { self.manualPort = 18789 } + case .remoteDomain: + if host == "openclaw.local" || host == "localhost" { self.manualHost = "" } + self.manualTLS = true + if self.manualPort <= 0 || self.manualPort > 65535 { self.manualPort = 18789 } + case .developerLocal: + if hostIsDefaultLike { self.manualHost = "localhost" } + self.manualTLS = false + if self.manualPort <= 0 || self.manualPort > 65535 { self.manualPort = 18789 } + } + } + + private func gatewayHasResolvableHost(_ gateway: GatewayDiscoveryModel.DiscoveredGateway) -> Bool { + let lanHost = gateway.lanHost?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + if !lanHost.isEmpty { return true } + let tailnetDns = gateway.tailnetDns?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + return !tailnetDns.isEmpty + } + + private func connectManual() async { + let host = self.manualHost.trimmingCharacters(in: .whitespacesAndNewlines) + guard !host.isEmpty, self.manualPort > 0, self.manualPort <= 65535 else { return } + self.connectingGatewayID = "manual" + self.issue = .none + self.connectMessage = "Connecting to \(host)…" + self.statusLine = "Connecting to \(host):\(self.manualPort)…" + defer { self.connectingGatewayID = nil } + await self.gatewayController.connectManual(host: host, port: self.manualPort, useTLS: self.manualTLS) + } + + private func retryLastAttempt(silent: Bool = false) async { + self.connectingGatewayID = silent ? "retry-auto" : "retry" + // Keep current auth/pairing issue sticky while retrying to avoid Step 3 UI flip-flop. + if !silent { + self.connectMessage = "Retrying…" + self.statusLine = "Retrying last connection…" + } + defer { self.connectingGatewayID = nil } + await self.gatewayController.connectLastKnown() + } +} + +private struct OnboardingModeRow: View { + let title: String + let subtitle: String + let selected: Bool + let action: () -> Void + + var body: some View { + Button(action: self.action) { + HStack { + VStack(alignment: .leading, spacing: 2) { + Text(self.title) + .font(.body.weight(.semibold)) + Text(self.subtitle) + .font(.footnote) + .foregroundStyle(.secondary) + } + Spacer() + Image(systemName: self.selected ? "checkmark.circle.fill" : "circle") + .foregroundStyle(self.selected ? Color.accentColor : Color.secondary) + } + } + .buttonStyle(.plain) + } +} diff --git a/apps/ios/Sources/Onboarding/QRScannerView.swift b/apps/ios/Sources/Onboarding/QRScannerView.swift new file mode 100644 index 00000000000..d326c09c42b --- /dev/null +++ b/apps/ios/Sources/Onboarding/QRScannerView.swift @@ -0,0 +1,96 @@ +import OpenClawKit +import SwiftUI +import VisionKit + +struct QRScannerView: UIViewControllerRepresentable { + let onGatewayLink: (GatewayConnectDeepLink) -> Void + let onError: (String) -> Void + let onDismiss: () -> Void + + func makeUIViewController(context: Context) -> UIViewController { + guard DataScannerViewController.isSupported else { + context.coordinator.reportError("QR scanning is not supported on this device.") + return UIViewController() + } + guard DataScannerViewController.isAvailable else { + context.coordinator.reportError("Camera scanning is currently unavailable.") + return UIViewController() + } + let scanner = DataScannerViewController( + recognizedDataTypes: [.barcode(symbologies: [.qr])], + isHighlightingEnabled: true) + scanner.delegate = context.coordinator + do { + try scanner.startScanning() + } catch { + context.coordinator.reportError("Could not start QR scanner.") + } + return scanner + } + + func updateUIViewController(_: UIViewController, context _: Context) {} + + static func dismantleUIViewController(_ uiViewController: UIViewController, coordinator: Coordinator) { + if let scanner = uiViewController as? DataScannerViewController { + scanner.stopScanning() + } + coordinator.parent.onDismiss() + } + + func makeCoordinator() -> Coordinator { + Coordinator(parent: self) + } + + final class Coordinator: NSObject, DataScannerViewControllerDelegate { + let parent: QRScannerView + private var handled = false + private var reportedError = false + + init(parent: QRScannerView) { + self.parent = parent + } + + func reportError(_ message: String) { + guard !self.reportedError else { return } + self.reportedError = true + Task { @MainActor in + self.parent.onError(message) + } + } + + func dataScanner(_: DataScannerViewController, didAdd items: [RecognizedItem], allItems _: [RecognizedItem]) { + guard !self.handled else { return } + for item in items { + guard case let .barcode(barcode) = item, + let payload = barcode.payloadStringValue + else { continue } + + // Try setup code format first (base64url JSON from /pair qr). + if let link = GatewayConnectDeepLink.fromSetupCode(payload) { + self.handled = true + self.parent.onGatewayLink(link) + return + } + + // Fall back to deep link URL format (openclaw://gateway?...). + if let url = URL(string: payload), + let route = DeepLinkParser.parse(url), + case let .gateway(link) = route + { + self.handled = true + self.parent.onGatewayLink(link) + return + } + } + } + + func dataScanner(_: DataScannerViewController, didRemove _: [RecognizedItem], allItems _: [RecognizedItem]) {} + + func dataScanner( + _: DataScannerViewController, + becameUnavailableWithError _: DataScannerViewController.ScanningUnavailable) + { + self.reportError("Camera is not available on this device.") + } + } +} diff --git a/apps/ios/Sources/OpenClaw.entitlements b/apps/ios/Sources/OpenClaw.entitlements new file mode 100644 index 00000000000..a2663ce930b --- /dev/null +++ b/apps/ios/Sources/OpenClaw.entitlements @@ -0,0 +1,9 @@ + + + + + aps-environment + development + + + diff --git a/apps/ios/Sources/OpenClawApp.swift b/apps/ios/Sources/OpenClawApp.swift index 8ad23ae20a1..335e09fd986 100644 --- a/apps/ios/Sources/OpenClawApp.swift +++ b/apps/ios/Sources/OpenClawApp.swift @@ -1,12 +1,461 @@ import SwiftUI +import Foundation +import OpenClawKit +import os +import UIKit +import BackgroundTasks +import UserNotifications + +private struct PendingWatchPromptAction { + var promptId: String? + var actionId: String + var actionLabel: String? + var sessionKey: String? +} + +@MainActor +final class OpenClawAppDelegate: NSObject, UIApplicationDelegate, @preconcurrency UNUserNotificationCenterDelegate { + private let logger = Logger(subsystem: "ai.openclaw.ios", category: "Push") + private let backgroundWakeLogger = Logger(subsystem: "ai.openclaw.ios", category: "BackgroundWake") + private static let wakeRefreshTaskIdentifier = "ai.openclaw.ios.bgrefresh" + private var backgroundWakeTask: Task? + private var pendingAPNsDeviceToken: Data? + private var pendingWatchPromptActions: [PendingWatchPromptAction] = [] + + weak var appModel: NodeAppModel? { + didSet { + guard let model = self.appModel else { return } + if let token = self.pendingAPNsDeviceToken { + self.pendingAPNsDeviceToken = nil + Task { @MainActor in + model.updateAPNsDeviceToken(token) + } + } + if !self.pendingWatchPromptActions.isEmpty { + let pending = self.pendingWatchPromptActions + self.pendingWatchPromptActions.removeAll() + Task { @MainActor in + for action in pending { + await model.handleMirroredWatchPromptAction( + promptId: action.promptId, + actionId: action.actionId, + actionLabel: action.actionLabel, + sessionKey: action.sessionKey) + } + } + } + } + } + + func application( + _ application: UIApplication, + didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil + ) -> Bool + { + self.registerBackgroundWakeRefreshTask() + UNUserNotificationCenter.current().delegate = self + application.registerForRemoteNotifications() + return true + } + + func application(_ application: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) { + if let appModel = self.appModel { + Task { @MainActor in + appModel.updateAPNsDeviceToken(deviceToken) + } + return + } + + self.pendingAPNsDeviceToken = deviceToken + } + + func application(_ application: UIApplication, didFailToRegisterForRemoteNotificationsWithError error: any Error) { + self.logger.error("APNs registration failed: \(error.localizedDescription, privacy: .public)") + } + + func application( + _ application: UIApplication, + didReceiveRemoteNotification userInfo: [AnyHashable: Any], + fetchCompletionHandler completionHandler: @escaping (UIBackgroundFetchResult) -> Void) + { + self.logger.info("APNs remote notification received keys=\(userInfo.keys.count, privacy: .public)") + Task { @MainActor in + guard let appModel = self.appModel else { + self.logger.info("APNs wake skipped: appModel unavailable") + self.scheduleBackgroundWakeRefresh(afterSeconds: 90, reason: "silent_push_no_model") + completionHandler(.noData) + return + } + let handled = await appModel.handleSilentPushWake(userInfo) + self.logger.info("APNs wake handled=\(handled, privacy: .public)") + if !handled { + self.scheduleBackgroundWakeRefresh(afterSeconds: 90, reason: "silent_push_not_applied") + } + completionHandler(handled ? .newData : .noData) + } + } + + func scenePhaseChanged(_ phase: ScenePhase) { + if phase == .background { + self.scheduleBackgroundWakeRefresh(afterSeconds: 120, reason: "scene_background") + } + } + + private func registerBackgroundWakeRefreshTask() { + BGTaskScheduler.shared.register( + forTaskWithIdentifier: Self.wakeRefreshTaskIdentifier, + using: nil + ) { [weak self] task in + guard let refreshTask = task as? BGAppRefreshTask else { + task.setTaskCompleted(success: false) + return + } + self?.handleBackgroundWakeRefresh(task: refreshTask) + } + } + + private func scheduleBackgroundWakeRefresh(afterSeconds delay: TimeInterval, reason: String) { + let request = BGAppRefreshTaskRequest(identifier: Self.wakeRefreshTaskIdentifier) + request.earliestBeginDate = Date().addingTimeInterval(max(60, delay)) + do { + try BGTaskScheduler.shared.submit(request) + self.backgroundWakeLogger.info( + "Scheduled background wake refresh reason=\(reason, privacy: .public) delaySeconds=\(max(60, delay), privacy: .public)") + } catch { + self.backgroundWakeLogger.error( + "Failed scheduling background wake refresh reason=\(reason, privacy: .public) error=\(error.localizedDescription, privacy: .public)") + } + } + + private func handleBackgroundWakeRefresh(task: BGAppRefreshTask) { + self.scheduleBackgroundWakeRefresh(afterSeconds: 15 * 60, reason: "reschedule") + self.backgroundWakeTask?.cancel() + + let wakeTask = Task { @MainActor [weak self] in + guard let self, let appModel = self.appModel else { return false } + return await appModel.handleBackgroundRefreshWake(trigger: "bg_app_refresh") + } + self.backgroundWakeTask = wakeTask + task.expirationHandler = { + wakeTask.cancel() + } + Task { + let applied = await wakeTask.value + task.setTaskCompleted(success: applied) + self.backgroundWakeLogger.info( + "Background wake refresh finished applied=\(applied, privacy: .public)") + } + } + + private static func isWatchPromptNotification(_ userInfo: [AnyHashable: Any]) -> Bool { + (userInfo[WatchPromptNotificationBridge.typeKey] as? String) == WatchPromptNotificationBridge.typeValue + } + + private static func parseWatchPromptAction( + from response: UNNotificationResponse) -> PendingWatchPromptAction? + { + let userInfo = response.notification.request.content.userInfo + guard Self.isWatchPromptNotification(userInfo) else { return nil } + + let promptId = userInfo[WatchPromptNotificationBridge.promptIDKey] as? String + let sessionKey = userInfo[WatchPromptNotificationBridge.sessionKeyKey] as? String + + switch response.actionIdentifier { + case WatchPromptNotificationBridge.actionPrimaryIdentifier: + let actionId = (userInfo[WatchPromptNotificationBridge.actionPrimaryIDKey] as? String)? + .trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + guard !actionId.isEmpty else { return nil } + let actionLabel = userInfo[WatchPromptNotificationBridge.actionPrimaryLabelKey] as? String + return PendingWatchPromptAction( + promptId: promptId, + actionId: actionId, + actionLabel: actionLabel, + sessionKey: sessionKey) + case WatchPromptNotificationBridge.actionSecondaryIdentifier: + let actionId = (userInfo[WatchPromptNotificationBridge.actionSecondaryIDKey] as? String)? + .trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + guard !actionId.isEmpty else { return nil } + let actionLabel = userInfo[WatchPromptNotificationBridge.actionSecondaryLabelKey] as? String + return PendingWatchPromptAction( + promptId: promptId, + actionId: actionId, + actionLabel: actionLabel, + sessionKey: sessionKey) + default: + return nil + } + } + + private func routeWatchPromptAction(_ action: PendingWatchPromptAction) async { + guard let appModel = self.appModel else { + self.pendingWatchPromptActions.append(action) + return + } + await appModel.handleMirroredWatchPromptAction( + promptId: action.promptId, + actionId: action.actionId, + actionLabel: action.actionLabel, + sessionKey: action.sessionKey) + _ = await appModel.handleBackgroundRefreshWake(trigger: "watch_prompt_action") + } + + func userNotificationCenter( + _ center: UNUserNotificationCenter, + willPresent notification: UNNotification, + withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void) + { + let userInfo = notification.request.content.userInfo + if Self.isWatchPromptNotification(userInfo) { + completionHandler([.banner, .list, .sound]) + return + } + completionHandler([]) + } + + func userNotificationCenter( + _ center: UNUserNotificationCenter, + didReceive response: UNNotificationResponse, + withCompletionHandler completionHandler: @escaping () -> Void) + { + guard let action = Self.parseWatchPromptAction(from: response) else { + completionHandler() + return + } + Task { @MainActor [weak self] in + guard let self else { + completionHandler() + return + } + await self.routeWatchPromptAction(action) + completionHandler() + } + } +} + +enum WatchPromptNotificationBridge { + static let typeKey = "openclaw.type" + static let typeValue = "watch.prompt" + static let promptIDKey = "openclaw.watch.promptId" + static let sessionKeyKey = "openclaw.watch.sessionKey" + static let actionPrimaryIDKey = "openclaw.watch.action.primary.id" + static let actionPrimaryLabelKey = "openclaw.watch.action.primary.label" + static let actionSecondaryIDKey = "openclaw.watch.action.secondary.id" + static let actionSecondaryLabelKey = "openclaw.watch.action.secondary.label" + static let actionPrimaryIdentifier = "openclaw.watch.action.primary" + static let actionSecondaryIdentifier = "openclaw.watch.action.secondary" + static let categoryPrefix = "openclaw.watch.prompt.category." + + @MainActor + static func scheduleMirroredWatchPromptNotificationIfNeeded( + invokeID: String, + params: OpenClawWatchNotifyParams, + sendResult: WatchNotificationSendResult) async + { + guard sendResult.queuedForDelivery || !sendResult.deliveredImmediately else { return } + + let title = params.title.trimmingCharacters(in: .whitespacesAndNewlines) + let body = params.body.trimmingCharacters(in: .whitespacesAndNewlines) + guard !title.isEmpty || !body.isEmpty else { return } + guard await self.requestNotificationAuthorizationIfNeeded() else { return } + + let normalizedActions = (params.actions ?? []).compactMap { action -> OpenClawWatchAction? in + let id = action.id.trimmingCharacters(in: .whitespacesAndNewlines) + let label = action.label.trimmingCharacters(in: .whitespacesAndNewlines) + guard !id.isEmpty, !label.isEmpty else { return nil } + return OpenClawWatchAction(id: id, label: label, style: action.style) + } + let primaryAction = normalizedActions.first + let secondaryAction = normalizedActions.dropFirst().first + + let center = UNUserNotificationCenter.current() + var categoryIdentifier = "" + if let primaryAction { + let categoryID = "\(self.categoryPrefix)\(invokeID)" + let category = UNNotificationCategory( + identifier: categoryID, + actions: self.categoryActions(primaryAction: primaryAction, secondaryAction: secondaryAction), + intentIdentifiers: [], + options: []) + await self.upsertNotificationCategory(category, center: center) + categoryIdentifier = categoryID + } + + var userInfo: [AnyHashable: Any] = [ + self.typeKey: self.typeValue, + ] + if let promptId = params.promptId?.trimmingCharacters(in: .whitespacesAndNewlines), !promptId.isEmpty { + userInfo[self.promptIDKey] = promptId + } + if let sessionKey = params.sessionKey?.trimmingCharacters(in: .whitespacesAndNewlines), !sessionKey.isEmpty { + userInfo[self.sessionKeyKey] = sessionKey + } + if let primaryAction { + userInfo[self.actionPrimaryIDKey] = primaryAction.id + userInfo[self.actionPrimaryLabelKey] = primaryAction.label + } + if let secondaryAction { + userInfo[self.actionSecondaryIDKey] = secondaryAction.id + userInfo[self.actionSecondaryLabelKey] = secondaryAction.label + } + + let content = UNMutableNotificationContent() + content.title = title.isEmpty ? "OpenClaw" : title + content.body = body + content.sound = .default + content.userInfo = userInfo + if !categoryIdentifier.isEmpty { + content.categoryIdentifier = categoryIdentifier + } + if #available(iOS 15.0, *) { + switch params.priority ?? .active { + case .passive: + content.interruptionLevel = .passive + case .timeSensitive: + content.interruptionLevel = .timeSensitive + case .active: + content.interruptionLevel = .active + } + } + + let request = UNNotificationRequest( + identifier: "watch.prompt.\(invokeID)", + content: content, + trigger: nil) + try? await self.addNotificationRequest(request, center: center) + } + + private static func categoryActions( + primaryAction: OpenClawWatchAction, + secondaryAction: OpenClawWatchAction?) -> [UNNotificationAction] + { + var actions: [UNNotificationAction] = [ + UNNotificationAction( + identifier: self.actionPrimaryIdentifier, + title: primaryAction.label, + options: self.notificationActionOptions(style: primaryAction.style)) + ] + if let secondaryAction { + actions.append( + UNNotificationAction( + identifier: self.actionSecondaryIdentifier, + title: secondaryAction.label, + options: self.notificationActionOptions(style: secondaryAction.style))) + } + return actions + } + + private static func notificationActionOptions(style: String?) -> UNNotificationActionOptions { + switch style?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() { + case "destructive": + return [.destructive] + case "foreground": + // For mirrored watch actions, keep handling in background when possible. + return [] + default: + return [] + } + } + + private static func requestNotificationAuthorizationIfNeeded() async -> Bool { + let center = UNUserNotificationCenter.current() + let status = await self.notificationAuthorizationStatus(center: center) + switch status { + case .authorized, .provisional, .ephemeral: + return true + case .notDetermined: + let granted = (try? await center.requestAuthorization(options: [.alert, .sound, .badge])) ?? false + if !granted { return false } + let updatedStatus = await self.notificationAuthorizationStatus(center: center) + return self.isAuthorizationStatusAllowed(updatedStatus) + case .denied: + return false + @unknown default: + return false + } + } + + private static func isAuthorizationStatusAllowed(_ status: UNAuthorizationStatus) -> Bool { + switch status { + case .authorized, .provisional, .ephemeral: + return true + case .denied, .notDetermined: + return false + @unknown default: + return false + } + } + + private static func notificationAuthorizationStatus(center: UNUserNotificationCenter) async -> UNAuthorizationStatus { + await withCheckedContinuation { continuation in + center.getNotificationSettings { settings in + continuation.resume(returning: settings.authorizationStatus) + } + } + } + + private static func upsertNotificationCategory( + _ category: UNNotificationCategory, + center: UNUserNotificationCenter) async + { + await withCheckedContinuation { continuation in + center.getNotificationCategories { categories in + var updated = categories + updated.update(with: category) + center.setNotificationCategories(updated) + continuation.resume() + } + } + } + + private static func addNotificationRequest(_ request: UNNotificationRequest, center: UNUserNotificationCenter) async throws { + try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in + center.add(request) { error in + if let error { + continuation.resume(throwing: error) + } else { + continuation.resume(returning: ()) + } + } + } + } +} + +extension NodeAppModel { + func handleMirroredWatchPromptAction( + promptId: String?, + actionId: String, + actionLabel: String?, + sessionKey: String?) async + { + let normalizedActionID = actionId.trimmingCharacters(in: .whitespacesAndNewlines) + guard !normalizedActionID.isEmpty else { return } + + let normalizedPromptID = promptId?.trimmingCharacters(in: .whitespacesAndNewlines) + let normalizedSessionKey = sessionKey?.trimmingCharacters(in: .whitespacesAndNewlines) + let normalizedActionLabel = actionLabel?.trimmingCharacters(in: .whitespacesAndNewlines) + + let event = WatchQuickReplyEvent( + replyId: UUID().uuidString, + promptId: (normalizedPromptID?.isEmpty == false) ? normalizedPromptID! : "unknown", + actionId: normalizedActionID, + actionLabel: (normalizedActionLabel?.isEmpty == false) ? normalizedActionLabel : nil, + sessionKey: (normalizedSessionKey?.isEmpty == false) ? normalizedSessionKey : nil, + note: "source=ios.notification", + sentAtMs: Int(Date().timeIntervalSince1970 * 1000), + transport: "ios.notification") + await self._bridgeConsumeMirroredWatchReply(event) + } +} @main struct OpenClawApp: App { @State private var appModel: NodeAppModel @State private var gatewayController: GatewayConnectionController + @UIApplicationDelegateAdaptor(OpenClawAppDelegate.self) private var appDelegate @Environment(\.scenePhase) private var scenePhase init() { + Self.installUncaughtExceptionLogger() GatewaySettingsStore.bootstrapPersistence() let appModel = NodeAppModel() _appModel = State(initialValue: appModel) @@ -19,13 +468,32 @@ struct OpenClawApp: App { .environment(self.appModel) .environment(self.appModel.voiceWake) .environment(self.gatewayController) + .task { + self.appDelegate.appModel = self.appModel + } .onOpenURL { url in Task { await self.appModel.handleDeepLink(url: url) } } .onChange(of: self.scenePhase) { _, newValue in self.appModel.setScenePhase(newValue) self.gatewayController.setScenePhase(newValue) + self.appDelegate.scenePhaseChanged(newValue) } } } } + +extension OpenClawApp { + private static func installUncaughtExceptionLogger() { + NSLog("OpenClaw: installing uncaught exception handler") + NSSetUncaughtExceptionHandler { exception in + // Useful when the app hits NSExceptions from SwiftUI/WebKit internals; these do not + // produce a normal Swift error backtrace. + let reason = exception.reason ?? "(no reason)" + NSLog("UNCAUGHT EXCEPTION: %@ %@", exception.name.rawValue, reason) + for line in exception.callStackSymbols { + NSLog(" %@", line) + } + } + } +} diff --git a/apps/ios/Sources/RootCanvas.swift b/apps/ios/Sources/RootCanvas.swift index c8f13eef407..dd0f389ed4d 100644 --- a/apps/ios/Sources/RootCanvas.swift +++ b/apps/ios/Sources/RootCanvas.swift @@ -3,34 +3,69 @@ import UIKit struct RootCanvas: View { @Environment(NodeAppModel.self) private var appModel + @Environment(GatewayConnectionController.self) private var gatewayController @Environment(VoiceWakeManager.self) private var voiceWake @Environment(\.colorScheme) private var systemColorScheme @Environment(\.scenePhase) private var scenePhase @AppStorage(VoiceWakePreferences.enabledKey) private var voiceWakeEnabled: Bool = false @AppStorage("screen.preventSleep") private var preventSleep: Bool = true @AppStorage("canvas.debugStatusEnabled") private var canvasDebugStatusEnabled: Bool = false + @AppStorage("onboarding.requestID") private var onboardingRequestID: Int = 0 @AppStorage("gateway.onboardingComplete") private var onboardingComplete: Bool = false @AppStorage("gateway.hasConnectedOnce") private var hasConnectedOnce: Bool = false @AppStorage("gateway.preferredStableID") private var preferredGatewayStableID: String = "" @AppStorage("gateway.manual.enabled") private var manualGatewayEnabled: Bool = false @AppStorage("gateway.manual.host") private var manualGatewayHost: String = "" + @AppStorage("onboarding.quickSetupDismissed") private var quickSetupDismissed: Bool = false @State private var presentedSheet: PresentedSheet? @State private var voiceWakeToastText: String? @State private var toastDismissTask: Task? + @State private var showOnboarding: Bool = false + @State private var onboardingAllowSkip: Bool = true + @State private var didEvaluateOnboarding: Bool = false @State private var didAutoOpenSettings: Bool = false private enum PresentedSheet: Identifiable { case settings case chat + case quickSetup var id: Int { switch self { case .settings: 0 case .chat: 1 + case .quickSetup: 2 } } } + enum StartupPresentationRoute: Equatable { + case none + case onboarding + case settings + } + + static func startupPresentationRoute( + gatewayConnected: Bool, + hasConnectedOnce: Bool, + onboardingComplete: Bool, + hasExistingGatewayConfig: Bool, + shouldPresentOnLaunch: Bool) -> StartupPresentationRoute + { + if gatewayConnected { + return .none + } + // On first run or explicit launch onboarding state, onboarding always wins. + if shouldPresentOnLaunch || !hasConnectedOnce || !onboardingComplete { + return .onboarding + } + // Settings auto-open is a recovery path for previously-connected installs only. + if !hasExistingGatewayConfig { + return .settings + } + return .none + } + var body: some View { ZStack { CanvasContent( @@ -53,34 +88,68 @@ struct RootCanvas: View { } } .gatewayTrustPromptAlert() + .deepLinkAgentPromptAlert() .sheet(item: self.$presentedSheet) { sheet in switch sheet { case .settings: SettingsTab() + .environment(self.appModel) + .environment(self.appModel.voiceWake) + .environment(self.gatewayController) case .chat: ChatSheet( + // Chat RPCs run on the operator session (read/write scopes). gateway: self.appModel.operatorSession, - sessionKey: self.appModel.mainSessionKey, + sessionKey: self.appModel.chatSessionKey, agentName: self.appModel.activeAgentName, userAccent: self.appModel.seamColor) + case .quickSetup: + GatewayQuickSetupSheet() + .environment(self.appModel) + .environment(self.gatewayController) } } + .fullScreenCover(isPresented: self.$showOnboarding) { + OnboardingWizardView( + allowSkip: self.onboardingAllowSkip, + onClose: { + self.showOnboarding = false + }) + .environment(self.appModel) + .environment(self.appModel.voiceWake) + .environment(self.gatewayController) + } .onAppear { self.updateIdleTimer() } + .onAppear { self.evaluateOnboardingPresentation(force: false) } .onAppear { self.maybeAutoOpenSettings() } .onChange(of: self.preventSleep) { _, _ in self.updateIdleTimer() } .onChange(of: self.scenePhase) { _, _ in self.updateIdleTimer() } + .onAppear { self.maybeShowQuickSetup() } + .onChange(of: self.gatewayController.gateways.count) { _, _ in self.maybeShowQuickSetup() } .onAppear { self.updateCanvasDebugStatus() } .onChange(of: self.canvasDebugStatusEnabled) { _, _ in self.updateCanvasDebugStatus() } .onChange(of: self.appModel.gatewayStatusText) { _, _ in self.updateCanvasDebugStatus() } .onChange(of: self.appModel.gatewayServerName) { _, _ in self.updateCanvasDebugStatus() } + .onChange(of: self.appModel.gatewayServerName) { _, newValue in + if newValue != nil { + self.showOnboarding = false + } + } + .onChange(of: self.onboardingRequestID) { _, _ in + self.evaluateOnboardingPresentation(force: true) + } .onChange(of: self.appModel.gatewayRemoteAddress) { _, _ in self.updateCanvasDebugStatus() } .onChange(of: self.appModel.gatewayServerName) { _, newValue in if newValue != nil { self.onboardingComplete = true self.hasConnectedOnce = true + OnboardingStateStore.markCompleted(mode: nil) } self.maybeAutoOpenSettings() } + .onChange(of: self.appModel.openChatRequestID) { _, _ in + self.presentedSheet = .chat + } .onChange(of: self.voiceWake.lastTriggeredCommand) { _, newValue in guard let newValue else { return } let trimmed = newValue.trimmingCharacters(in: .whitespacesAndNewlines) @@ -136,11 +205,31 @@ struct RootCanvas: View { self.appModel.screen.updateDebugStatus(title: title, subtitle: subtitle) } - private func shouldAutoOpenSettings() -> Bool { - if self.appModel.gatewayServerName != nil { return false } - if !self.hasConnectedOnce { return true } - if !self.onboardingComplete { return true } - return !self.hasExistingGatewayConfig() + private func evaluateOnboardingPresentation(force: Bool) { + if force { + self.onboardingAllowSkip = true + self.showOnboarding = true + return + } + + guard !self.didEvaluateOnboarding else { return } + self.didEvaluateOnboarding = true + let route = Self.startupPresentationRoute( + gatewayConnected: self.appModel.gatewayServerName != nil, + hasConnectedOnce: self.hasConnectedOnce, + onboardingComplete: self.onboardingComplete, + hasExistingGatewayConfig: self.hasExistingGatewayConfig(), + shouldPresentOnLaunch: OnboardingStateStore.shouldPresentOnLaunch(appModel: self.appModel)) + switch route { + case .none: + break + case .onboarding: + self.onboardingAllowSkip = true + self.showOnboarding = true + case .settings: + self.didAutoOpenSettings = true + self.presentedSheet = .settings + } } private func hasExistingGatewayConfig() -> Bool { @@ -151,10 +240,26 @@ struct RootCanvas: View { private func maybeAutoOpenSettings() { guard !self.didAutoOpenSettings else { return } - guard self.shouldAutoOpenSettings() else { return } + guard !self.showOnboarding else { return } + let route = Self.startupPresentationRoute( + gatewayConnected: self.appModel.gatewayServerName != nil, + hasConnectedOnce: self.hasConnectedOnce, + onboardingComplete: self.onboardingComplete, + hasExistingGatewayConfig: self.hasExistingGatewayConfig(), + shouldPresentOnLaunch: false) + guard route == .settings else { return } self.didAutoOpenSettings = true self.presentedSheet = .settings } + + private func maybeShowQuickSetup() { + guard !self.quickSetupDismissed else { return } + guard !self.showOnboarding else { return } + guard self.presentedSheet == nil else { return } + guard self.appModel.gatewayServerName == nil else { return } + guard !self.gatewayController.gateways.isEmpty else { return } + self.presentedSheet = .quickSetup + } } private struct CanvasContent: View { @@ -256,11 +361,64 @@ private struct CanvasContent: View { } private var statusActivity: StatusPill.Activity? { - StatusActivityBuilder.build( - appModel: self.appModel, - voiceWakeEnabled: self.voiceWakeEnabled, - cameraHUDText: self.cameraHUDText, - cameraHUDKind: self.cameraHUDKind) + // Status pill owns transient activity state so it doesn't overlap the connection indicator. + if self.appModel.isBackgrounded { + return StatusPill.Activity( + title: "Foreground required", + systemImage: "exclamationmark.triangle.fill", + tint: .orange) + } + + let gatewayStatus = self.appModel.gatewayStatusText.trimmingCharacters(in: .whitespacesAndNewlines) + let gatewayLower = gatewayStatus.lowercased() + if gatewayLower.contains("repair") { + return StatusPill.Activity(title: "Repairing…", systemImage: "wrench.and.screwdriver", tint: .orange) + } + if gatewayLower.contains("approval") || gatewayLower.contains("pairing") { + return StatusPill.Activity(title: "Approval pending", systemImage: "person.crop.circle.badge.clock") + } + // Avoid duplicating the primary gateway status ("Connecting…") in the activity slot. + + if self.appModel.screenRecordActive { + return StatusPill.Activity(title: "Recording screen…", systemImage: "record.circle.fill", tint: .red) + } + + if let cameraHUDText, !cameraHUDText.isEmpty, let cameraHUDKind { + let systemImage: String + let tint: Color? + switch cameraHUDKind { + case .photo: + systemImage = "camera.fill" + tint = nil + case .recording: + systemImage = "video.fill" + tint = .red + case .success: + systemImage = "checkmark.circle.fill" + tint = .green + case .error: + systemImage = "exclamationmark.triangle.fill" + tint = .red + } + return StatusPill.Activity(title: cameraHUDText, systemImage: systemImage, tint: tint) + } + + if self.voiceWakeEnabled { + let voiceStatus = self.appModel.voiceWake.statusText + if voiceStatus.localizedCaseInsensitiveContains("microphone permission") { + return StatusPill.Activity(title: "Mic permission", systemImage: "mic.slash", tint: .orange) + } + if voiceStatus == "Paused" { + // Talk mode intentionally pauses voice wake to release the mic. Don't spam the HUD for that case. + if self.appModel.talkMode.isEnabled { + return nil + } + let suffix = self.appModel.isBackgrounded ? " (background)" : "" + return StatusPill.Activity(title: "Voice Wake paused\(suffix)", systemImage: "pause.circle.fill") + } + } + + return nil } } diff --git a/apps/ios/Sources/RootTabs.swift b/apps/ios/Sources/RootTabs.swift index 35786fa89a6..4733a4a30fc 100644 --- a/apps/ios/Sources/RootTabs.swift +++ b/apps/ios/Sources/RootTabs.swift @@ -3,6 +3,7 @@ import SwiftUI struct RootTabs: View { @Environment(NodeAppModel.self) private var appModel @Environment(VoiceWakeManager.self) private var voiceWake + @Environment(\.accessibilityReduceMotion) private var reduceMotion @AppStorage(VoiceWakePreferences.enabledKey) private var voiceWakeEnabled: Bool = false @State private var selectedTab: Int = 0 @State private var voiceWakeToastText: String? @@ -52,14 +53,14 @@ struct RootTabs: View { guard !trimmed.isEmpty else { return } self.toastDismissTask?.cancel() - withAnimation(.spring(response: 0.25, dampingFraction: 0.85)) { + withAnimation(self.reduceMotion ? .none : .spring(response: 0.25, dampingFraction: 0.85)) { self.voiceWakeToastText = trimmed } self.toastDismissTask = Task { try? await Task.sleep(nanoseconds: 2_300_000_000) await MainActor.run { - withAnimation(.easeOut(duration: 0.25)) { + withAnimation(self.reduceMotion ? .none : .easeOut(duration: 0.25)) { self.voiceWakeToastText = nil } } diff --git a/apps/ios/Sources/Screen/ScreenController.swift b/apps/ios/Sources/Screen/ScreenController.swift index 506b78a2308..0045232362b 100644 --- a/apps/ios/Sources/Screen/ScreenController.swift +++ b/apps/ios/Sources/Screen/ScreenController.swift @@ -1,14 +1,12 @@ import OpenClawKit import Observation -import SwiftUI +import UIKit import WebKit @MainActor @Observable final class ScreenController { - let webView: WKWebView - private let navigationDelegate: ScreenNavigationDelegate - private let a2uiActionHandler: CanvasA2UIActionMessageHandler + private weak var activeWebView: WKWebView? var urlString: String = "" var errorText: String? @@ -24,29 +22,6 @@ final class ScreenController { private var debugStatusSubtitle: String? init() { - let config = WKWebViewConfiguration() - config.websiteDataStore = .nonPersistent() - let a2uiActionHandler = CanvasA2UIActionMessageHandler() - let userContentController = WKUserContentController() - for name in CanvasA2UIActionMessageHandler.handlerNames { - userContentController.add(a2uiActionHandler, name: name) - } - config.userContentController = userContentController - self.navigationDelegate = ScreenNavigationDelegate() - self.a2uiActionHandler = a2uiActionHandler - self.webView = WKWebView(frame: .zero, configuration: config) - // Canvas scaffold is a fully self-contained HTML page; avoid relying on transparency underlays. - self.webView.isOpaque = true - self.webView.backgroundColor = .black - self.webView.scrollView.backgroundColor = .black - self.webView.scrollView.contentInsetAdjustmentBehavior = .never - self.webView.scrollView.contentInset = .zero - self.webView.scrollView.scrollIndicatorInsets = .zero - self.webView.scrollView.automaticallyAdjustsScrollIndicatorInsets = false - self.applyScrollBehavior() - self.webView.navigationDelegate = self.navigationDelegate - self.navigationDelegate.controller = self - a2uiActionHandler.controller = self self.reload() } @@ -71,24 +46,26 @@ final class ScreenController { } func reload() { - let trimmed = self.urlString.trimmingCharacters(in: .whitespacesAndNewlines) self.applyScrollBehavior() + guard let webView = self.activeWebView else { return } + + let trimmed = self.urlString.trimmingCharacters(in: .whitespacesAndNewlines) if trimmed.isEmpty { guard let url = Self.canvasScaffoldURL else { return } self.errorText = nil - self.webView.loadFileURL(url, allowingReadAccessTo: url.deletingLastPathComponent()) + webView.loadFileURL(url, allowingReadAccessTo: url.deletingLastPathComponent()) return + } + + guard let url = URL(string: trimmed) else { + self.errorText = "Invalid URL: \(trimmed)" + return + } + self.errorText = nil + if url.isFileURL { + webView.loadFileURL(url, allowingReadAccessTo: url.deletingLastPathComponent()) } else { - guard let url = URL(string: trimmed) else { - self.errorText = "Invalid URL: \(trimmed)" - return - } - self.errorText = nil - if url.isFileURL { - self.webView.loadFileURL(url, allowingReadAccessTo: url.deletingLastPathComponent()) - } else { - self.webView.load(URLRequest(url: url)) - } + webView.load(URLRequest(url: url)) } } @@ -108,7 +85,8 @@ final class ScreenController { self.applyDebugStatusIfNeeded() } - fileprivate func applyDebugStatusIfNeeded() { + func applyDebugStatusIfNeeded() { + guard let webView = self.activeWebView else { return } let enabled = self.debugStatusEnabled let title = self.debugStatusTitle let subtitle = self.debugStatusSubtitle @@ -127,7 +105,7 @@ final class ScreenController { } catch (_) {} })() """ - self.webView.evaluateJavaScript(js) { _, _ in } + webView.evaluateJavaScript(js) { _, _ in } } func waitForA2UIReady(timeoutMs: Int) async -> Bool { @@ -154,8 +132,13 @@ final class ScreenController { } func eval(javaScript: String) async throws -> String { - try await withCheckedThrowingContinuation { cont in - self.webView.evaluateJavaScript(javaScript) { result, error in + guard let webView = self.activeWebView else { + throw NSError(domain: "Screen", code: 3, userInfo: [ + NSLocalizedDescriptionKey: "web view unavailable", + ]) + } + return try await withCheckedThrowingContinuation { cont in + webView.evaluateJavaScript(javaScript) { result, error in if let error { cont.resume(throwing: error) return @@ -174,8 +157,13 @@ final class ScreenController { if let maxWidth { config.snapshotWidth = NSNumber(value: Double(maxWidth)) } + guard let webView = self.activeWebView else { + throw NSError(domain: "Screen", code: 3, userInfo: [ + NSLocalizedDescriptionKey: "web view unavailable", + ]) + } let image: UIImage = try await withCheckedThrowingContinuation { cont in - self.webView.takeSnapshot(with: config) { image, error in + webView.takeSnapshot(with: config) { image, error in if let error { cont.resume(throwing: error) return @@ -206,8 +194,13 @@ final class ScreenController { if let maxWidth { config.snapshotWidth = NSNumber(value: Double(maxWidth)) } + guard let webView = self.activeWebView else { + throw NSError(domain: "Screen", code: 3, userInfo: [ + NSLocalizedDescriptionKey: "web view unavailable", + ]) + } let image: UIImage = try await withCheckedThrowingContinuation { cont in - self.webView.takeSnapshot(with: config) { image, error in + webView.takeSnapshot(with: config) { image, error in if let error { cont.resume(throwing: error) return @@ -238,6 +231,17 @@ final class ScreenController { return data.base64EncodedString() } + func attachWebView(_ webView: WKWebView) { + self.activeWebView = webView + self.reload() + self.applyDebugStatusIfNeeded() + } + + func detachWebView(_ webView: WKWebView) { + guard self.activeWebView === webView else { return } + self.activeWebView = nil + } + private static func bundledResourceURL( name: String, ext: String, @@ -277,9 +281,10 @@ final class ScreenController { } private func applyScrollBehavior() { + guard let webView = self.activeWebView else { return } let trimmed = self.urlString.trimmingCharacters(in: .whitespacesAndNewlines) let allowScroll = !trimmed.isEmpty - let scrollView = self.webView.scrollView + let scrollView = webView.scrollView // Default canvas needs raw touch events; external pages should scroll. scrollView.isScrollEnabled = allowScroll scrollView.bounces = allowScroll @@ -366,72 +371,3 @@ extension Double { return self } } - -// MARK: - Navigation Delegate - -/// Handles navigation policy to intercept openclaw:// deep links from canvas -@MainActor -private final class ScreenNavigationDelegate: NSObject, WKNavigationDelegate { - weak var controller: ScreenController? - - func webView( - _ webView: WKWebView, - decidePolicyFor navigationAction: WKNavigationAction, - decisionHandler: @escaping @MainActor @Sendable (WKNavigationActionPolicy) -> Void) - { - guard let url = navigationAction.request.url else { - decisionHandler(.allow) - return - } - - // Intercept openclaw:// deep links. - if url.scheme?.lowercased() == "openclaw" { - decisionHandler(.cancel) - self.controller?.onDeepLink?(url) - return - } - - decisionHandler(.allow) - } - - func webView( - _: WKWebView, - didFailProvisionalNavigation _: WKNavigation?, - withError error: any Error) - { - self.controller?.errorText = error.localizedDescription - } - - func webView(_: WKWebView, didFinish _: WKNavigation?) { - self.controller?.errorText = nil - self.controller?.applyDebugStatusIfNeeded() - } - - func webView(_: WKWebView, didFail _: WKNavigation?, withError error: any Error) { - self.controller?.errorText = error.localizedDescription - } -} - -private final class CanvasA2UIActionMessageHandler: NSObject, WKScriptMessageHandler { - static let messageName = "openclawCanvasA2UIAction" - static let handlerNames = [messageName] - - weak var controller: ScreenController? - - func userContentController(_: WKUserContentController, didReceive message: WKScriptMessage) { - guard Self.handlerNames.contains(message.name) else { return } - guard let controller else { return } - - guard let url = message.webView?.url else { return } - if url.isFileURL { - guard controller.isTrustedCanvasUIURL(url) else { return } - } else { - // For security, only accept actions from local-network pages (e.g. the canvas host). - guard controller.isLocalNetworkCanvasURL(url) else { return } - } - - guard let body = ScreenController.parseA2UIActionBody(message.body) else { return } - - controller.onA2UIAction?(body) - } -} diff --git a/apps/ios/Sources/Screen/ScreenWebView.swift b/apps/ios/Sources/Screen/ScreenWebView.swift index c464521be5f..a30d78cbd00 100644 --- a/apps/ios/Sources/Screen/ScreenWebView.swift +++ b/apps/ios/Sources/Screen/ScreenWebView.swift @@ -5,11 +5,189 @@ import WebKit struct ScreenWebView: UIViewRepresentable { var controller: ScreenController - func makeUIView(context: Context) -> WKWebView { - self.controller.webView + func makeCoordinator() -> ScreenWebViewCoordinator { + ScreenWebViewCoordinator(controller: self.controller) } - func updateUIView(_ webView: WKWebView, context: Context) { - // State changes are driven by ScreenController. + func makeUIView(context: Context) -> UIView { + context.coordinator.makeContainerView() + } + + func updateUIView(_: UIView, context: Context) { + context.coordinator.updateController(self.controller) + } + + static func dismantleUIView(_: UIView, coordinator: ScreenWebViewCoordinator) { + coordinator.teardown() + } +} + +@MainActor +final class ScreenWebViewCoordinator: NSObject { + private weak var controller: ScreenController? + private let navigationDelegate = ScreenNavigationDelegate() + private let a2uiActionHandler = CanvasA2UIActionMessageHandler() + private let userContentController = WKUserContentController() + + private(set) var managedWebView: WKWebView? + private weak var containerView: UIView? + + init(controller: ScreenController) { + self.controller = controller + super.init() + self.navigationDelegate.controller = controller + self.a2uiActionHandler.controller = controller + } + + func makeContainerView() -> UIView { + if let containerView { + return containerView + } + + let container = UIView(frame: .zero) + container.backgroundColor = .black + + let webView = Self.makeWebView(userContentController: self.userContentController) + webView.navigationDelegate = self.navigationDelegate + self.installA2UIHandlers() + + webView.translatesAutoresizingMaskIntoConstraints = false + container.addSubview(webView) + NSLayoutConstraint.activate([ + webView.leadingAnchor.constraint(equalTo: container.leadingAnchor), + webView.trailingAnchor.constraint(equalTo: container.trailingAnchor), + webView.topAnchor.constraint(equalTo: container.topAnchor), + webView.bottomAnchor.constraint(equalTo: container.bottomAnchor), + ]) + + self.managedWebView = webView + self.containerView = container + self.controller?.attachWebView(webView) + return container + } + + func updateController(_ controller: ScreenController) { + let previousController = self.controller + let controllerChanged = self.controller !== controller + self.controller = controller + self.navigationDelegate.controller = controller + self.a2uiActionHandler.controller = controller + if controllerChanged, let managedWebView { + previousController?.detachWebView(managedWebView) + controller.attachWebView(managedWebView) + } + } + + func teardown() { + if let managedWebView { + self.controller?.detachWebView(managedWebView) + managedWebView.navigationDelegate = nil + } + self.removeA2UIHandlers() + self.navigationDelegate.controller = nil + self.a2uiActionHandler.controller = nil + self.managedWebView = nil + self.containerView = nil + } + + private static func makeWebView(userContentController: WKUserContentController) -> WKWebView { + let config = WKWebViewConfiguration() + config.websiteDataStore = .nonPersistent() + config.userContentController = userContentController + + let webView = WKWebView(frame: .zero, configuration: config) + // Canvas scaffold is a fully self-contained HTML page; avoid relying on transparency underlays. + webView.isOpaque = true + webView.backgroundColor = .black + + let scrollView = webView.scrollView + scrollView.backgroundColor = .black + scrollView.contentInsetAdjustmentBehavior = .never + scrollView.contentInset = .zero + scrollView.scrollIndicatorInsets = .zero + scrollView.automaticallyAdjustsScrollIndicatorInsets = false + + return webView + } + + private func installA2UIHandlers() { + for name in CanvasA2UIActionMessageHandler.handlerNames { + self.userContentController.add(self.a2uiActionHandler, name: name) + } + } + + private func removeA2UIHandlers() { + for name in CanvasA2UIActionMessageHandler.handlerNames { + self.userContentController.removeScriptMessageHandler(forName: name) + } + } +} + +// MARK: - Navigation Delegate + +/// Handles navigation policy to intercept openclaw:// deep links from canvas +@MainActor +private final class ScreenNavigationDelegate: NSObject, WKNavigationDelegate { + weak var controller: ScreenController? + + func webView( + _: WKWebView, + decidePolicyFor navigationAction: WKNavigationAction, + decisionHandler: @escaping @MainActor @Sendable (WKNavigationActionPolicy) -> Void) + { + guard let url = navigationAction.request.url else { + decisionHandler(.allow) + return + } + + // Intercept openclaw:// deep links. + if url.scheme?.lowercased() == "openclaw" { + decisionHandler(.cancel) + self.controller?.onDeepLink?(url) + return + } + + decisionHandler(.allow) + } + + func webView( + _: WKWebView, + didFailProvisionalNavigation _: WKNavigation?, + withError error: any Error) + { + self.controller?.errorText = error.localizedDescription + } + + func webView(_: WKWebView, didFinish _: WKNavigation?) { + self.controller?.errorText = nil + self.controller?.applyDebugStatusIfNeeded() + } + + func webView(_: WKWebView, didFail _: WKNavigation?, withError error: any Error) { + self.controller?.errorText = error.localizedDescription + } +} + +private final class CanvasA2UIActionMessageHandler: NSObject, WKScriptMessageHandler { + static let messageName = "openclawCanvasA2UIAction" + static let handlerNames = [messageName] + + weak var controller: ScreenController? + + func userContentController(_: WKUserContentController, didReceive message: WKScriptMessage) { + guard Self.handlerNames.contains(message.name) else { return } + guard let controller else { return } + + guard let url = message.webView?.url else { return } + if url.isFileURL { + guard controller.isTrustedCanvasUIURL(url) else { return } + } else { + // For security, only accept actions from local-network pages (e.g. the canvas host). + guard controller.isLocalNetworkCanvasURL(url) else { return } + } + + guard let body = ScreenController.parseA2UIActionBody(message.body) else { return } + + controller.onA2UIAction?(body) } } diff --git a/apps/ios/Sources/Services/NodeServiceProtocols.swift b/apps/ios/Sources/Services/NodeServiceProtocols.swift index 002c87ad9ca..27ee7cc2776 100644 --- a/apps/ios/Sources/Services/NodeServiceProtocols.swift +++ b/apps/ios/Sources/Services/NodeServiceProtocols.swift @@ -28,6 +28,12 @@ protocol LocationServicing: Sendable { desiredAccuracy: OpenClawLocationAccuracy, maxAgeMs: Int?, timeoutMs: Int?) async throws -> CLLocation + func startLocationUpdates( + desiredAccuracy: OpenClawLocationAccuracy, + significantChangesOnly: Bool) -> AsyncStream + func stopLocationUpdates() + func startMonitoringSignificantLocationChanges(onUpdate: @escaping @Sendable (CLLocation) -> Void) + func stopMonitoringSignificantLocationChanges() } protocol DeviceStatusServicing: Sendable { @@ -59,6 +65,39 @@ protocol MotionServicing: Sendable { func pedometer(params: OpenClawPedometerParams) async throws -> OpenClawPedometerPayload } +struct WatchMessagingStatus: Sendable, Equatable { + var supported: Bool + var paired: Bool + var appInstalled: Bool + var reachable: Bool + var activationState: String +} + +struct WatchQuickReplyEvent: Sendable, Equatable { + var replyId: String + var promptId: String + var actionId: String + var actionLabel: String? + var sessionKey: String? + var note: String? + var sentAtMs: Int? + var transport: String +} + +struct WatchNotificationSendResult: Sendable, Equatable { + var deliveredImmediately: Bool + var queuedForDelivery: Bool + var transport: String +} + +protocol WatchMessagingServicing: AnyObject, Sendable { + func status() async -> WatchMessagingStatus + func setReplyHandler(_ handler: (@Sendable (WatchQuickReplyEvent) -> Void)?) + func sendNotification( + id: String, + params: OpenClawWatchNotifyParams) async throws -> WatchNotificationSendResult +} + extension CameraController: CameraServicing {} extension ScreenRecordService: ScreenRecordingServicing {} extension LocationService: LocationServicing {} diff --git a/apps/ios/Sources/Services/WatchMessagingService.swift b/apps/ios/Sources/Services/WatchMessagingService.swift new file mode 100644 index 00000000000..3511a06c2db --- /dev/null +++ b/apps/ios/Sources/Services/WatchMessagingService.swift @@ -0,0 +1,280 @@ +import Foundation +import OpenClawKit +import OSLog +@preconcurrency import WatchConnectivity + +enum WatchMessagingError: LocalizedError { + case unsupported + case notPaired + case watchAppNotInstalled + + var errorDescription: String? { + switch self { + case .unsupported: + "WATCH_UNAVAILABLE: WatchConnectivity is not supported on this device" + case .notPaired: + "WATCH_UNAVAILABLE: no paired Apple Watch" + case .watchAppNotInstalled: + "WATCH_UNAVAILABLE: OpenClaw watch companion app is not installed" + } + } +} + +final class WatchMessagingService: NSObject, WatchMessagingServicing, @unchecked Sendable { + private static let logger = Logger(subsystem: "ai.openclaw", category: "watch.messaging") + private let session: WCSession? + private let replyHandlerLock = NSLock() + private var replyHandler: (@Sendable (WatchQuickReplyEvent) -> Void)? + + override init() { + if WCSession.isSupported() { + self.session = WCSession.default + } else { + self.session = nil + } + super.init() + if let session = self.session { + session.delegate = self + session.activate() + } + } + + static func isSupportedOnDevice() -> Bool { + WCSession.isSupported() + } + + static func currentStatusSnapshot() -> WatchMessagingStatus { + guard WCSession.isSupported() else { + return WatchMessagingStatus( + supported: false, + paired: false, + appInstalled: false, + reachable: false, + activationState: "unsupported") + } + let session = WCSession.default + return status(for: session) + } + + func status() async -> WatchMessagingStatus { + await self.ensureActivated() + guard let session = self.session else { + return WatchMessagingStatus( + supported: false, + paired: false, + appInstalled: false, + reachable: false, + activationState: "unsupported") + } + return Self.status(for: session) + } + + func setReplyHandler(_ handler: (@Sendable (WatchQuickReplyEvent) -> Void)?) { + self.replyHandlerLock.lock() + self.replyHandler = handler + self.replyHandlerLock.unlock() + } + + func sendNotification( + id: String, + params: OpenClawWatchNotifyParams) async throws -> WatchNotificationSendResult + { + await self.ensureActivated() + guard let session = self.session else { + throw WatchMessagingError.unsupported + } + + let snapshot = Self.status(for: session) + guard snapshot.paired else { throw WatchMessagingError.notPaired } + guard snapshot.appInstalled else { throw WatchMessagingError.watchAppNotInstalled } + + var payload: [String: Any] = [ + "type": "watch.notify", + "id": id, + "title": params.title, + "body": params.body, + "priority": params.priority?.rawValue ?? OpenClawNotificationPriority.active.rawValue, + "sentAtMs": Int(Date().timeIntervalSince1970 * 1000), + ] + if let promptId = Self.nonEmpty(params.promptId) { + payload["promptId"] = promptId + } + if let sessionKey = Self.nonEmpty(params.sessionKey) { + payload["sessionKey"] = sessionKey + } + if let kind = Self.nonEmpty(params.kind) { + payload["kind"] = kind + } + if let details = Self.nonEmpty(params.details) { + payload["details"] = details + } + if let expiresAtMs = params.expiresAtMs { + payload["expiresAtMs"] = expiresAtMs + } + if let risk = params.risk { + payload["risk"] = risk.rawValue + } + if let actions = params.actions, !actions.isEmpty { + payload["actions"] = actions.map { action in + var encoded: [String: Any] = [ + "id": action.id, + "label": action.label, + ] + if let style = Self.nonEmpty(action.style) { + encoded["style"] = style + } + return encoded + } + } + + if snapshot.reachable { + do { + try await self.sendReachableMessage(payload, with: session) + return WatchNotificationSendResult( + deliveredImmediately: true, + queuedForDelivery: false, + transport: "sendMessage") + } catch { + Self.logger.error("watch sendMessage failed: \(error.localizedDescription, privacy: .public)") + } + } + + _ = session.transferUserInfo(payload) + return WatchNotificationSendResult( + deliveredImmediately: false, + queuedForDelivery: true, + transport: "transferUserInfo") + } + + private func sendReachableMessage(_ payload: [String: Any], with session: WCSession) async throws { + try await withCheckedThrowingContinuation { continuation in + session.sendMessage(payload, replyHandler: { _ in + continuation.resume() + }, errorHandler: { error in + continuation.resume(throwing: error) + }) + } + } + + private func emitReply(_ event: WatchQuickReplyEvent) { + let handler: ((WatchQuickReplyEvent) -> Void)? + self.replyHandlerLock.lock() + handler = self.replyHandler + self.replyHandlerLock.unlock() + handler?(event) + } + + private static func nonEmpty(_ value: String?) -> String? { + let trimmed = value?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + return trimmed.isEmpty ? nil : trimmed + } + + private static func parseQuickReplyPayload( + _ payload: [String: Any], + transport: String) -> WatchQuickReplyEvent? + { + guard (payload["type"] as? String) == "watch.reply" else { + return nil + } + guard let actionId = nonEmpty(payload["actionId"] as? String) else { + return nil + } + let promptId = nonEmpty(payload["promptId"] as? String) ?? "unknown" + let replyId = nonEmpty(payload["replyId"] as? String) ?? UUID().uuidString + let actionLabel = nonEmpty(payload["actionLabel"] as? String) + let sessionKey = nonEmpty(payload["sessionKey"] as? String) + let note = nonEmpty(payload["note"] as? String) + let sentAtMs = (payload["sentAtMs"] as? Int) ?? (payload["sentAtMs"] as? NSNumber)?.intValue + + return WatchQuickReplyEvent( + replyId: replyId, + promptId: promptId, + actionId: actionId, + actionLabel: actionLabel, + sessionKey: sessionKey, + note: note, + sentAtMs: sentAtMs, + transport: transport) + } + + private func ensureActivated() async { + guard let session = self.session else { return } + if session.activationState == .activated { return } + session.activate() + for _ in 0..<8 { + if session.activationState == .activated { return } + try? await Task.sleep(nanoseconds: 100_000_000) + } + } + + private static func status(for session: WCSession) -> WatchMessagingStatus { + WatchMessagingStatus( + supported: true, + paired: session.isPaired, + appInstalled: session.isWatchAppInstalled, + reachable: session.isReachable, + activationState: activationStateLabel(session.activationState)) + } + + private static func activationStateLabel(_ state: WCSessionActivationState) -> String { + switch state { + case .notActivated: + "notActivated" + case .inactive: + "inactive" + case .activated: + "activated" + @unknown default: + "unknown" + } + } +} + +extension WatchMessagingService: WCSessionDelegate { + func session( + _ session: WCSession, + activationDidCompleteWith activationState: WCSessionActivationState, + error: (any Error)?) + { + if let error { + Self.logger.error("watch activation failed: \(error.localizedDescription, privacy: .public)") + return + } + Self.logger.debug("watch activation state=\(Self.activationStateLabel(activationState), privacy: .public)") + } + + func sessionDidBecomeInactive(_ session: WCSession) {} + + func sessionDidDeactivate(_ session: WCSession) { + session.activate() + } + + func session(_: WCSession, didReceiveMessage message: [String: Any]) { + guard let event = Self.parseQuickReplyPayload(message, transport: "sendMessage") else { + return + } + self.emitReply(event) + } + + func session( + _: WCSession, + didReceiveMessage message: [String: Any], + replyHandler: @escaping ([String: Any]) -> Void) + { + guard let event = Self.parseQuickReplyPayload(message, transport: "sendMessage") else { + replyHandler(["ok": false, "error": "unsupported_payload"]) + return + } + replyHandler(["ok": true]) + self.emitReply(event) + } + + func session(_: WCSession, didReceiveUserInfo userInfo: [String: Any]) { + guard let event = Self.parseQuickReplyPayload(userInfo, transport: "transferUserInfo") else { + return + } + self.emitReply(event) + } + + func sessionReachabilityDidChange(_ session: WCSession) {} +} diff --git a/apps/ios/Sources/Settings/SettingsTab.swift b/apps/ios/Sources/Settings/SettingsTab.swift index 8eb725df4a1..024a4cbf42b 100644 --- a/apps/ios/Sources/Settings/SettingsTab.swift +++ b/apps/ios/Sources/Settings/SettingsTab.swift @@ -6,6 +6,12 @@ import SwiftUI import UIKit struct SettingsTab: View { + private struct FeatureHelp: Identifiable { + let id = UUID() + let title: String + let message: String + } + @Environment(NodeAppModel.self) private var appModel: NodeAppModel @Environment(VoiceWakeManager.self) private var voiceWake: VoiceWakeManager @Environment(GatewayConnectionController.self) private var gatewayController: GatewayConnectionController @@ -15,9 +21,10 @@ struct SettingsTab: View { @AppStorage("voiceWake.enabled") private var voiceWakeEnabled: Bool = false @AppStorage("talk.enabled") private var talkEnabled: Bool = false @AppStorage("talk.button.enabled") private var talkButtonEnabled: Bool = true + @AppStorage("talk.background.enabled") private var talkBackgroundEnabled: Bool = false + @AppStorage("talk.voiceDirectiveHint.enabled") private var talkVoiceDirectiveHintEnabled: Bool = true @AppStorage("camera.enabled") private var cameraEnabled: Bool = true @AppStorage("location.enabledMode") private var locationEnabledModeRaw: String = OpenClawLocationMode.off.rawValue - @AppStorage("location.preciseEnabled") private var locationPreciseEnabled: Bool = true @AppStorage("screen.preventSleep") private var preventSleep: Bool = true @AppStorage("gateway.preferredStableID") private var preferredGatewayStableID: String = "" @AppStorage("gateway.lastDiscoveredStableID") private var lastDiscoveredGatewayStableID: String = "" @@ -28,17 +35,27 @@ struct SettingsTab: View { @AppStorage("gateway.manual.tls") private var manualGatewayTLS: Bool = true @AppStorage("gateway.discovery.debugLogs") private var discoveryDebugLogsEnabled: Bool = false @AppStorage("canvas.debugStatusEnabled") private var canvasDebugStatusEnabled: Bool = false + + // Onboarding control (RootCanvas listens to onboarding.requestID and force-opens the wizard). + @AppStorage("onboarding.requestID") private var onboardingRequestID: Int = 0 + @AppStorage("gateway.onboardingComplete") private var onboardingComplete: Bool = false + @AppStorage("gateway.hasConnectedOnce") private var hasConnectedOnce: Bool = false + @State private var connectingGatewayID: String? - @State private var localIPAddress: String? @State private var lastLocationModeRaw: String = OpenClawLocationMode.off.rawValue @State private var gatewayToken: String = "" @State private var gatewayPassword: String = "" + @State private var defaultShareInstruction: String = "" @AppStorage("gateway.setupCode") private var setupCode: String = "" @State private var setupStatusText: String? @State private var manualGatewayPortText: String = "" @State private var gatewayExpanded: Bool = true @State private var selectedAgentPickerId: String = "" + @State private var showResetOnboardingAlert: Bool = false + @State private var activeFeatureHelp: FeatureHelp? + @State private var suppressCredentialPersist: Bool = false + private let gatewayLogger = Logger(subsystem: "ai.openclaw.ios", category: "GatewaySettings") var body: some View { @@ -103,7 +120,6 @@ struct SettingsTab: View { .foregroundStyle(.secondary) } - DisclosureGroup("Advanced") { if self.appModel.gatewayServerName == nil { LabeledContent("Discovery", value: self.gatewayController.discoveryStatusText) } @@ -148,69 +164,74 @@ struct SettingsTab: View { self.gatewayList(showing: .all) } - Toggle("Use Manual Gateway", isOn: self.$manualGatewayEnabled) + DisclosureGroup("Advanced") { + Toggle("Use Manual Gateway", isOn: self.$manualGatewayEnabled) - TextField("Host", text: self.$manualGatewayHost) - .textInputAutocapitalization(.never) - .autocorrectionDisabled() + TextField("Host", text: self.$manualGatewayHost) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() - TextField("Port (optional)", text: self.manualPortBinding) - .keyboardType(.numberPad) + TextField("Port (optional)", text: self.manualPortBinding) + .keyboardType(.numberPad) - Toggle("Use TLS", isOn: self.$manualGatewayTLS) + Toggle("Use TLS", isOn: self.$manualGatewayTLS) - Button { - Task { await self.connectManual() } - } label: { - if self.connectingGatewayID == "manual" { - HStack(spacing: 8) { - ProgressView() - .progressViewStyle(.circular) - Text("Connecting…") + Button { + Task { await self.connectManual() } + } label: { + if self.connectingGatewayID == "manual" { + HStack(spacing: 8) { + ProgressView() + .progressViewStyle(.circular) + Text("Connecting…") + } + } else { + Text("Connect (Manual)") } - } else { - Text("Connect (Manual)") } - } - .disabled(self.connectingGatewayID != nil || self.manualGatewayHost - .trimmingCharacters(in: .whitespacesAndNewlines) - .isEmpty || !self.manualPortIsValid) + .disabled(self.connectingGatewayID != nil || self.manualGatewayHost + .trimmingCharacters(in: .whitespacesAndNewlines) + .isEmpty || !self.manualPortIsValid) - Text( - "Use this when mDNS/Bonjour discovery is blocked. " - + "Leave port empty for 443 on tailnet DNS (TLS) or 18789 otherwise.") - .font(.footnote) - .foregroundStyle(.secondary) + Text( + "Use this when mDNS/Bonjour discovery is blocked. " + + "Leave port empty for 443 on tailnet DNS (TLS) or 18789 otherwise.") + .font(.footnote) + .foregroundStyle(.secondary) - Toggle("Discovery Debug Logs", isOn: self.$discoveryDebugLogsEnabled) - .onChange(of: self.discoveryDebugLogsEnabled) { _, newValue in - self.gatewayController.setDiscoveryDebugLoggingEnabled(newValue) + Toggle("Discovery Debug Logs", isOn: self.$discoveryDebugLogsEnabled) + .onChange(of: self.discoveryDebugLogsEnabled) { _, newValue in + self.gatewayController.setDiscoveryDebugLoggingEnabled(newValue) + } + + NavigationLink("Discovery Logs") { + GatewayDiscoveryDebugLogView() } - NavigationLink("Discovery Logs") { - GatewayDiscoveryDebugLogView() + Toggle("Debug Canvas Status", isOn: self.$canvasDebugStatusEnabled) + + TextField("Gateway Auth Token", text: self.$gatewayToken) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + + SecureField("Gateway Password", text: self.$gatewayPassword) + + Button("Reset Onboarding", role: .destructive) { + self.showResetOnboardingAlert = true + } + + VStack(alignment: .leading, spacing: 6) { + Text("Debug") + .font(.footnote.weight(.semibold)) + .foregroundStyle(.secondary) + Text(self.gatewayDebugText()) + .font(.system(size: 12, weight: .regular, design: .monospaced)) + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(10) + .background(.thinMaterial, in: RoundedRectangle(cornerRadius: 10, style: .continuous)) + } } - - Toggle("Debug Canvas Status", isOn: self.$canvasDebugStatusEnabled) - - TextField("Gateway Token", text: self.$gatewayToken) - .textInputAutocapitalization(.never) - .autocorrectionDisabled() - - SecureField("Gateway Password", text: self.$gatewayPassword) - - VStack(alignment: .leading, spacing: 6) { - Text("Debug") - .font(.footnote.weight(.semibold)) - .foregroundStyle(.secondary) - Text(self.gatewayDebugText()) - .font(.system(size: 12, weight: .regular, design: .monospaced)) - .foregroundStyle(.secondary) - .frame(maxWidth: .infinity, alignment: .leading) - .padding(10) - .background(.thinMaterial, in: RoundedRectangle(cornerRadius: 10, style: .continuous)) - } - } } label: { HStack(spacing: 10) { Circle() @@ -227,16 +248,22 @@ struct SettingsTab: View { Section("Device") { DisclosureGroup("Features") { - Toggle("Voice Wake", isOn: self.$voiceWakeEnabled) - .onChange(of: self.voiceWakeEnabled) { _, newValue in + self.featureToggle( + "Voice Wake", + isOn: self.$voiceWakeEnabled, + help: "Enables wake-word activation to start a hands-free session.") { newValue in self.appModel.setVoiceWakeEnabled(newValue) } - Toggle("Talk Mode", isOn: self.$talkEnabled) - .onChange(of: self.talkEnabled) { _, newValue in + self.featureToggle( + "Talk Mode", + isOn: self.$talkEnabled, + help: "Enables voice conversation mode with your connected OpenClaw agent.") { newValue in self.appModel.setTalkEnabled(newValue) } - // Keep this separate so users can hide the side bubble without disabling Talk Mode. - Toggle("Show Talk Button", isOn: self.$talkButtonEnabled) + self.featureToggle( + "Background Listening", + isOn: self.$talkBackgroundEnabled, + help: "Keeps listening while the app is backgrounded. Uses more battery.") NavigationLink { VoiceWakeWordsSettingsView() @@ -246,29 +273,98 @@ struct SettingsTab: View { value: VoiceWakePreferences.displayString(for: self.voiceWake.triggerWords)) } - Toggle("Allow Camera", isOn: self.$cameraEnabled) - Text("Allows the gateway to request photos or short video clips (foreground only).") - .font(.footnote) - .foregroundStyle(.secondary) + self.featureToggle( + "Allow Camera", + isOn: self.$cameraEnabled, + help: "Allows the gateway to request photos or short video clips while OpenClaw is foregrounded.") + HStack(spacing: 8) { + Text("Location Access") + Spacer() + Button { + self.activeFeatureHelp = FeatureHelp( + title: "Location Access", + message: "Controls location permissions for OpenClaw. Off disables location tools, While Using enables foreground location, and Always enables background location.") + } label: { + Image(systemName: "info.circle") + .foregroundStyle(.secondary) + } + .buttonStyle(.plain) + .accessibilityLabel("Location Access info") + } Picker("Location Access", selection: self.$locationEnabledModeRaw) { Text("Off").tag(OpenClawLocationMode.off.rawValue) Text("While Using").tag(OpenClawLocationMode.whileUsing.rawValue) Text("Always").tag(OpenClawLocationMode.always.rawValue) } + .labelsHidden() .pickerStyle(.segmented) - Toggle("Precise Location", isOn: self.$locationPreciseEnabled) - .disabled(self.locationMode == .off) + self.featureToggle( + "Prevent Sleep", + isOn: self.$preventSleep, + help: "Keeps the screen awake while OpenClaw is open.") - Text("Always requires system permission and may prompt to open Settings.") - .font(.footnote) - .foregroundStyle(.secondary) + DisclosureGroup("Advanced") { + VStack(alignment: .leading, spacing: 8) { + Text("Talk Voice (Gateway)") + .font(.footnote.weight(.semibold)) + .foregroundStyle(.secondary) + LabeledContent("Provider", value: "ElevenLabs") + LabeledContent( + "API Key", + value: self.appModel.talkMode.gatewayTalkConfigLoaded + ? (self.appModel.talkMode.gatewayTalkApiKeyConfigured ? "Configured" : "Not configured") + : "Not loaded") + LabeledContent( + "Default Model", + value: self.appModel.talkMode.gatewayTalkDefaultModelId ?? "eleven_v3 (fallback)") + LabeledContent( + "Default Voice", + value: self.appModel.talkMode.gatewayTalkDefaultVoiceId ?? "auto (first available)") + Text("Configured on gateway via talk.apiKey, talk.modelId, and talk.voiceId.") + .font(.footnote) + .foregroundStyle(.secondary) + } + self.featureToggle( + "Voice Directive Hint", + isOn: self.$talkVoiceDirectiveHintEnabled, + help: "Adds voice-switching instructions to Talk prompts. Disable to reduce prompt size.") + self.featureToggle( + "Show Talk Button", + isOn: self.$talkButtonEnabled, + help: "Shows the floating Talk button in the main interface.") + TextField("Default Share Instruction", text: self.$defaultShareInstruction, axis: .vertical) + .lineLimit(2 ... 6) + .textInputAutocapitalization(.sentences) + HStack(spacing: 8) { + Text("Default Share Instruction") + .font(.footnote) + .foregroundStyle(.secondary) + Spacer() + Button { + self.activeFeatureHelp = FeatureHelp( + title: "Default Share Instruction", + message: "Appends this instruction when sharing content into OpenClaw from iOS.") + } label: { + Image(systemName: "info.circle") + .foregroundStyle(.secondary) + } + .buttonStyle(.plain) + .accessibilityLabel("Default Share Instruction info") + } - Toggle("Prevent Sleep", isOn: self.$preventSleep) - Text("Keeps the screen awake while OpenClaw is open.") - .font(.footnote) - .foregroundStyle(.secondary) + VStack(alignment: .leading, spacing: 8) { + Button { + Task { await self.appModel.runSharePipelineSelfTest() } + } label: { + Label("Run Share Self-Test", systemImage: "checkmark.seal") + } + Text(self.appModel.lastShareEventText) + .font(.footnote) + .foregroundStyle(.secondary) + } + } } DisclosureGroup("Device Info") { @@ -276,19 +372,11 @@ struct SettingsTab: View { Text(self.instanceId) .font(.footnote) .foregroundStyle(.secondary) - LabeledContent("IP", value: self.localIPAddress ?? "—") - .contextMenu { - if let ip = self.localIPAddress { - Button { - UIPasteboard.general.string = ip - } label: { - Label("Copy", systemImage: "doc.on.doc") - } - } - } + .lineLimit(1) + .truncationMode(.middle) + LabeledContent("Device", value: self.deviceFamily()) LabeledContent("Platform", value: self.platformString()) - LabeledContent("Version", value: self.appVersion()) - LabeledContent("Model", value: self.modelIdentifier()) + LabeledContent("OpenClaw", value: self.openClawVersionString()) } } } @@ -303,8 +391,22 @@ struct SettingsTab: View { .accessibilityLabel("Close") } } + .alert("Reset Onboarding?", isPresented: self.$showResetOnboardingAlert) { + Button("Reset", role: .destructive) { + self.resetOnboarding() + } + Button("Cancel", role: .cancel) {} + } message: { + Text( + "This will disconnect, clear saved gateway connection + credentials, and reopen the onboarding wizard.") + } + .alert(item: self.$activeFeatureHelp) { help in + Alert( + title: Text(help.title), + message: Text(help.message), + dismissButton: .default(Text("OK"))) + } .onAppear { - self.localIPAddress = NetworkInterfaces.primaryIPv4Address() self.lastLocationModeRaw = self.locationEnabledModeRaw self.syncManualPortText() let trimmedInstanceId = self.instanceId.trimmingCharacters(in: .whitespacesAndNewlines) @@ -312,9 +414,14 @@ struct SettingsTab: View { self.gatewayToken = GatewaySettingsStore.loadGatewayToken(instanceId: trimmedInstanceId) ?? "" self.gatewayPassword = GatewaySettingsStore.loadGatewayPassword(instanceId: trimmedInstanceId) ?? "" } + self.defaultShareInstruction = ShareToAgentSettings.loadDefaultInstruction() + self.appModel.refreshLastShareEventFromRelay() // Keep setup front-and-center when disconnected; keep things compact once connected. self.gatewayExpanded = !self.isGatewayConnected self.selectedAgentPickerId = self.appModel.selectedAgentId ?? "" + if self.isGatewayConnected { + self.appModel.reloadTalkConfig() + } } .onChange(of: self.selectedAgentPickerId) { _, newValue in let trimmed = newValue.trimmingCharacters(in: .whitespacesAndNewlines) @@ -331,17 +438,22 @@ struct SettingsTab: View { GatewaySettingsStore.savePreferredGatewayStableID(trimmed) } .onChange(of: self.gatewayToken) { _, newValue in + guard !self.suppressCredentialPersist else { return } let trimmed = newValue.trimmingCharacters(in: .whitespacesAndNewlines) let instanceId = self.instanceId.trimmingCharacters(in: .whitespacesAndNewlines) guard !instanceId.isEmpty else { return } GatewaySettingsStore.saveGatewayToken(trimmed, instanceId: instanceId) } .onChange(of: self.gatewayPassword) { _, newValue in + guard !self.suppressCredentialPersist else { return } let trimmed = newValue.trimmingCharacters(in: .whitespacesAndNewlines) let instanceId = self.instanceId.trimmingCharacters(in: .whitespacesAndNewlines) guard !instanceId.isEmpty else { return } GatewaySettingsStore.saveGatewayPassword(trimmed, instanceId: instanceId) } + .onChange(of: self.defaultShareInstruction) { _, newValue in + ShareToAgentSettings.saveDefaultInstruction(newValue) + } .onChange(of: self.manualGatewayPort) { _, _ in self.syncManualPortText() } @@ -372,6 +484,10 @@ struct SettingsTab: View { self.locationEnabledModeRaw = previous self.lastLocationModeRaw = previous } + return + } + await MainActor.run { + self.gatewayController.refreshActiveGatewayRegistrationFromSettings() } } } @@ -421,10 +537,11 @@ struct SettingsTab: View { ForEach(rows) { gateway in HStack { VStack(alignment: .leading, spacing: 2) { - Text(gateway.name) + // Avoid localized-string formatting edge cases from Bonjour-advertised names. + Text(verbatim: gateway.name) let detailLines = self.gatewayDetailLines(gateway) ForEach(detailLines, id: \.self) { line in - Text(line) + Text(verbatim: line) .font(.footnote) .foregroundStyle(.secondary) } @@ -472,14 +589,6 @@ struct SettingsTab: View { return "iOS \(v.majorVersion).\(v.minorVersion).\(v.patchVersion)" } - private var locationMode: OpenClawLocationMode { - OpenClawLocationMode(rawValue: self.locationEnabledModeRaw) ?? .off - } - - private func appVersion() -> String { - Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "dev" - } - private func deviceFamily() -> String { switch UIDevice.current.userInterfaceIdiom { case .pad: @@ -491,14 +600,36 @@ struct SettingsTab: View { } } - private func modelIdentifier() -> String { - var systemInfo = utsname() - uname(&systemInfo) - let machine = withUnsafeBytes(of: &systemInfo.machine) { ptr in - String(bytes: ptr.prefix { $0 != 0 }, encoding: .utf8) + private func openClawVersionString() -> String { + let version = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "dev" + let build = Bundle.main.infoDictionary?["CFBundleVersion"] as? String ?? "" + let trimmedBuild = build.trimmingCharacters(in: .whitespacesAndNewlines) + if trimmedBuild.isEmpty || trimmedBuild == version { + return version + } + return "\(version) (\(trimmedBuild))" + } + + private func featureToggle( + _ title: String, + isOn: Binding, + help: String, + onChange: ((Bool) -> Void)? = nil + ) -> some View { + HStack(spacing: 8) { + Toggle(title, isOn: isOn) + Button { + self.activeFeatureHelp = FeatureHelp(title: title, message: help) + } label: { + Image(systemName: "info.circle") + .foregroundStyle(.secondary) + } + .buttonStyle(.plain) + .accessibilityLabel("\(title) info") + } + .onChange(of: isOn.wrappedValue) { _, newValue in + onChange?(newValue) } - let trimmed = machine?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" - return trimmed.isEmpty ? "unknown" : trimmed } private func connect(_ gateway: GatewayDiscoveryModel.DiscoveredGateway) async { @@ -510,7 +641,10 @@ struct SettingsTab: View { GatewaySettingsStore.saveLastDiscoveredGatewayStableID(gateway.stableID) defer { self.connectingGatewayID = nil } - await self.gatewayController.connect(gateway) + let err = await self.gatewayController.connectWithDiagnostics(gateway) + if let err { + self.setupStatusText = err + } } private func connectLastKnown() async { @@ -849,6 +983,43 @@ struct SettingsTab: View { SettingsNetworkingHelpers.httpURLString(host: host, port: port, fallback: fallback) } + private func resetOnboarding() { + // Disconnect first so RootCanvas doesn't instantly mark onboarding complete again. + self.appModel.disconnectGateway() + self.connectingGatewayID = nil + self.setupStatusText = nil + self.setupCode = "" + self.gatewayAutoConnect = false + + self.suppressCredentialPersist = true + defer { self.suppressCredentialPersist = false } + + self.gatewayToken = "" + self.gatewayPassword = "" + + let trimmedInstanceId = self.instanceId.trimmingCharacters(in: .whitespacesAndNewlines) + if !trimmedInstanceId.isEmpty { + GatewaySettingsStore.deleteGatewayCredentials(instanceId: trimmedInstanceId) + } + + // Reset onboarding state + clear saved gateway connection (the two things RootCanvas checks). + GatewaySettingsStore.clearLastGatewayConnection() + + // RootCanvas also short-circuits onboarding when these are true. + self.onboardingComplete = false + self.hasConnectedOnce = false + + // Clear manual override so it doesn't count as an existing gateway config. + self.manualGatewayEnabled = false + self.manualGatewayHost = "" + + // Force re-present even without app restart. + self.onboardingRequestID += 1 + + // The onboarding wizard is presented from RootCanvas; dismiss Settings so it can show. + self.dismiss() + } + private func gatewayDetailLines(_ gateway: GatewayDiscoveryModel.DiscoveredGateway) -> [String] { var lines: [String] = [] if let lanHost = gateway.lanHost { lines.append("LAN: \(lanHost)") } diff --git a/apps/ios/Sources/Status/StatusActivityBuilder.swift b/apps/ios/Sources/Status/StatusActivityBuilder.swift index a335e2f4643..381b3d2b9e8 100644 --- a/apps/ios/Sources/Status/StatusActivityBuilder.swift +++ b/apps/ios/Sources/Status/StatusActivityBuilder.swift @@ -1,6 +1,7 @@ import SwiftUI enum StatusActivityBuilder { + @MainActor static func build( appModel: NodeAppModel, voiceWakeEnabled: Bool, diff --git a/apps/ios/Sources/Status/StatusPill.swift b/apps/ios/Sources/Status/StatusPill.swift index cd81c011bb1..ea5e425c49d 100644 --- a/apps/ios/Sources/Status/StatusPill.swift +++ b/apps/ios/Sources/Status/StatusPill.swift @@ -2,6 +2,8 @@ import SwiftUI struct StatusPill: View { @Environment(\.scenePhase) private var scenePhase + @Environment(\.accessibilityReduceMotion) private var reduceMotion + @Environment(\.colorSchemeContrast) private var contrast enum GatewayState: Equatable { case connected @@ -49,11 +51,11 @@ struct StatusPill: View { Circle() .fill(self.gateway.color) .frame(width: 9, height: 9) - .scaleEffect(self.gateway == .connecting ? (self.pulse ? 1.15 : 0.85) : 1.0) - .opacity(self.gateway == .connecting ? (self.pulse ? 1.0 : 0.6) : 1.0) + .scaleEffect(self.gateway == .connecting && !self.reduceMotion ? (self.pulse ? 1.15 : 0.85) : 1.0) + .opacity(self.gateway == .connecting && !self.reduceMotion ? (self.pulse ? 1.0 : 0.6) : 1.0) Text(self.gateway.title) - .font(.system(size: 13, weight: .semibold)) + .font(.subheadline.weight(.semibold)) .foregroundStyle(.primary) } @@ -64,17 +66,17 @@ struct StatusPill: View { if let activity { HStack(spacing: 6) { Image(systemName: activity.systemImage) - .font(.system(size: 13, weight: .semibold)) + .font(.subheadline.weight(.semibold)) .foregroundStyle(activity.tint ?? .primary) Text(activity.title) - .font(.system(size: 13, weight: .semibold)) + .font(.subheadline.weight(.semibold)) .foregroundStyle(.primary) .lineLimit(1) } .transition(.opacity.combined(with: .move(edge: .top))) } else { Image(systemName: self.voiceWakeEnabled ? "mic.fill" : "mic.slash") - .font(.system(size: 13, weight: .semibold)) + .font(.subheadline.weight(.semibold)) .foregroundStyle(self.voiceWakeEnabled ? .primary : .secondary) .accessibilityLabel(self.voiceWakeEnabled ? "Voice Wake enabled" : "Voice Wake disabled") .transition(.opacity.combined(with: .move(edge: .top))) @@ -87,21 +89,28 @@ struct StatusPill: View { .fill(.ultraThinMaterial) .overlay { RoundedRectangle(cornerRadius: 14, style: .continuous) - .strokeBorder(.white.opacity(self.brighten ? 0.24 : 0.18), lineWidth: 0.5) + .strokeBorder( + .white.opacity(self.contrast == .increased ? 0.5 : (self.brighten ? 0.24 : 0.18)), + lineWidth: self.contrast == .increased ? 1.0 : 0.5 + ) } .shadow(color: .black.opacity(0.25), radius: 12, y: 6) } } .buttonStyle(.plain) - .accessibilityLabel("Status") + .accessibilityLabel("Connection Status") .accessibilityValue(self.accessibilityValue) - .onAppear { self.updatePulse(for: self.gateway, scenePhase: self.scenePhase) } + .accessibilityHint("Double tap to open settings") + .onAppear { self.updatePulse(for: self.gateway, scenePhase: self.scenePhase, reduceMotion: self.reduceMotion) } .onDisappear { self.pulse = false } .onChange(of: self.gateway) { _, newValue in - self.updatePulse(for: newValue, scenePhase: self.scenePhase) + self.updatePulse(for: newValue, scenePhase: self.scenePhase, reduceMotion: self.reduceMotion) } .onChange(of: self.scenePhase) { _, newValue in - self.updatePulse(for: self.gateway, scenePhase: newValue) + self.updatePulse(for: self.gateway, scenePhase: newValue, reduceMotion: self.reduceMotion) + } + .onChange(of: self.reduceMotion) { _, newValue in + self.updatePulse(for: self.gateway, scenePhase: self.scenePhase, reduceMotion: newValue) } .animation(.easeInOut(duration: 0.18), value: self.activity?.title) } @@ -113,9 +122,9 @@ struct StatusPill: View { return "\(self.gateway.title), Voice Wake \(self.voiceWakeEnabled ? "enabled" : "disabled")" } - private func updatePulse(for gateway: GatewayState, scenePhase: ScenePhase) { - guard gateway == .connecting, scenePhase == .active else { - withAnimation(.easeOut(duration: 0.2)) { self.pulse = false } + private func updatePulse(for gateway: GatewayState, scenePhase: ScenePhase, reduceMotion: Bool) { + guard gateway == .connecting, scenePhase == .active, !reduceMotion else { + withAnimation(reduceMotion ? .none : .easeOut(duration: 0.2)) { self.pulse = false } return } diff --git a/apps/ios/Sources/Status/VoiceWakeToast.swift b/apps/ios/Sources/Status/VoiceWakeToast.swift index b7942f2036f..ef6fc1295a7 100644 --- a/apps/ios/Sources/Status/VoiceWakeToast.swift +++ b/apps/ios/Sources/Status/VoiceWakeToast.swift @@ -1,17 +1,19 @@ import SwiftUI struct VoiceWakeToast: View { + @Environment(\.colorSchemeContrast) private var contrast + var command: String var brighten: Bool = false var body: some View { HStack(spacing: 10) { Image(systemName: "mic.fill") - .font(.system(size: 14, weight: .semibold)) + .font(.subheadline.weight(.semibold)) .foregroundStyle(.primary) Text(self.command) - .font(.system(size: 14, weight: .semibold)) + .font(.subheadline.weight(.semibold)) .foregroundStyle(.primary) .lineLimit(1) .truncationMode(.tail) @@ -23,11 +25,14 @@ struct VoiceWakeToast: View { .fill(.ultraThinMaterial) .overlay { RoundedRectangle(cornerRadius: 14, style: .continuous) - .strokeBorder(.white.opacity(self.brighten ? 0.24 : 0.18), lineWidth: 0.5) + .strokeBorder( + .white.opacity(self.contrast == .increased ? 0.5 : (self.brighten ? 0.24 : 0.18)), + lineWidth: self.contrast == .increased ? 1.0 : 0.5 + ) } .shadow(color: .black.opacity(0.25), radius: 12, y: 6) } - .accessibilityLabel("Voice Wake") - .accessibilityValue(self.command) + .accessibilityLabel("Voice Wake triggered") + .accessibilityValue("Command: \(self.command)") } } diff --git a/apps/ios/Sources/Voice/TalkModeManager.swift b/apps/ios/Sources/Voice/TalkModeManager.swift index 8351a6d5f9a..8f208c66d50 100644 --- a/apps/ios/Sources/Voice/TalkModeManager.swift +++ b/apps/ios/Sources/Voice/TalkModeManager.swift @@ -16,6 +16,7 @@ import Speech final class TalkModeManager: NSObject { private typealias SpeechRequest = SFSpeechAudioBufferRecognitionRequest private static let defaultModelIdFallback = "eleven_v3" + private static let redactedConfigSentinel = "__OPENCLAW_REDACTED__" var isEnabled: Bool = false var isListening: Bool = false var isSpeaking: Bool = false @@ -23,6 +24,10 @@ final class TalkModeManager: NSObject { var statusText: String = "Off" /// 0..1-ish (not calibrated). Intended for UI feedback only. var micLevel: Double = 0 + var gatewayTalkConfigLoaded: Bool = false + var gatewayTalkApiKeyConfigured: Bool = false + var gatewayTalkDefaultModelId: String? + var gatewayTalkDefaultVoiceId: String? private enum CaptureMode { case idle @@ -86,6 +91,8 @@ final class TalkModeManager: NSObject { private var incrementalSpeechBuffer = IncrementalSpeechBuffer() private var incrementalSpeechContext: IncrementalSpeechContext? private var incrementalSpeechDirective: TalkDirective? + private var incrementalSpeechPrefetch: IncrementalSpeechPrefetchState? + private var incrementalSpeechPrefetchMonitorTask: Task? private let logger = Logger(subsystem: "bot.molt", category: "TalkMode") @@ -218,8 +225,12 @@ final class TalkModeManager: NSObject { /// Suspends microphone usage without disabling Talk Mode. /// Used when the app backgrounds (or when we need to temporarily release the mic). - func suspendForBackground() -> Bool { + func suspendForBackground(keepActive: Bool = false) -> Bool { guard self.isEnabled else { return false } + if keepActive { + self.statusText = self.isListening ? "Listening" : self.statusText + return false + } let wasActive = self.isListening || self.isSpeaking || self.isPushToTalkActive self.isListening = false @@ -246,7 +257,8 @@ final class TalkModeManager: NSObject { return wasActive } - func resumeAfterBackground(wasSuspended: Bool) async { + func resumeAfterBackground(wasSuspended: Bool, wasKeptActive: Bool = false) async { + if wasKeptActive { return } guard wasSuspended else { return } guard self.isEnabled else { return } await self.start() @@ -541,6 +553,16 @@ final class TalkModeManager: NSObject { guard let self else { return } if let error { let msg = error.localizedDescription + let lowered = msg.lowercased() + let isCancellation = lowered.contains("cancelled") || lowered.contains("canceled") + if isCancellation { + GatewayDiagnostics.log("talk speech: cancelled") + if self.captureMode == .continuous, self.isEnabled, !self.isSpeaking { + self.statusText = "Listening" + } + self.logger.debug("speech recognition cancelled") + return + } GatewayDiagnostics.log("talk speech: error=\(msg)") if !self.isSpeaking { if msg.localizedCaseInsensitiveContains("no speech detected") { @@ -814,29 +836,24 @@ final class TalkModeManager: NSObject { private func subscribeChatIfNeeded(sessionKey: String) async { let key = sessionKey.trimmingCharacters(in: .whitespacesAndNewlines) guard !key.isEmpty else { return } - guard let gateway else { return } guard !self.chatSubscribedSessionKeys.contains(key) else { return } - let payload = "{\"sessionKey\":\"\(key)\"}" - await gateway.sendEvent(event: "chat.subscribe", payloadJSON: payload) + // Operator clients receive chat events without node-style subscriptions. self.chatSubscribedSessionKeys.insert(key) - self.logger.info("chat.subscribe ok sessionKey=\(key, privacy: .public)") } private func unsubscribeAllChats() async { - guard let gateway else { return } - let keys = self.chatSubscribedSessionKeys self.chatSubscribedSessionKeys.removeAll() - for key in keys { - let payload = "{\"sessionKey\":\"\(key)\"}" - await gateway.sendEvent(event: "chat.unsubscribe", payloadJSON: payload) - } } private func buildPrompt(transcript: String) -> String { let interrupted = self.lastInterruptedAtSeconds self.lastInterruptedAtSeconds = nil - return TalkPromptBuilder.build(transcript: transcript, interruptedAtSeconds: interrupted) + let includeVoiceDirectiveHint = (UserDefaults.standard.object(forKey: "talk.voiceDirectiveHint.enabled") as? Bool) ?? true + return TalkPromptBuilder.build( + transcript: transcript, + interruptedAtSeconds: interrupted, + includeVoiceDirectiveHint: includeVoiceDirectiveHint) } private enum ChatCompletionState: CustomStringConvertible { @@ -1114,6 +1131,7 @@ final class TalkModeManager: NSObject { } private func shouldInterrupt(with transcript: String) -> Bool { + guard self.shouldAllowSpeechInterruptForCurrentRoute() else { return false } let trimmed = transcript.trimmingCharacters(in: .whitespacesAndNewlines) guard trimmed.count >= 3 else { return false } if let spoken = self.lastSpokenText?.lowercased(), spoken.contains(trimmed.lowercased()) { @@ -1122,6 +1140,20 @@ final class TalkModeManager: NSObject { return true } + private func shouldAllowSpeechInterruptForCurrentRoute() -> Bool { + let route = AVAudioSession.sharedInstance().currentRoute + // Built-in speaker/receiver often feeds TTS back into STT, causing false interrupts. + // Allow barge-in for isolated outputs (headphones/Bluetooth/USB/CarPlay/AirPlay). + return !route.outputs.contains { output in + switch output.portType { + case .builtInSpeaker, .builtInReceiver: + return true + default: + return false + } + } + } + private func shouldUseIncrementalTTS() -> Bool { true } @@ -1157,6 +1189,7 @@ final class TalkModeManager: NSObject { self.incrementalSpeechQueue.removeAll() self.incrementalSpeechTask?.cancel() self.incrementalSpeechTask = nil + self.cancelIncrementalPrefetch() self.incrementalSpeechActive = true self.incrementalSpeechUsed = false self.incrementalSpeechLanguage = nil @@ -1169,6 +1202,7 @@ final class TalkModeManager: NSObject { self.incrementalSpeechQueue.removeAll() self.incrementalSpeechTask?.cancel() self.incrementalSpeechTask = nil + self.cancelIncrementalPrefetch() self.incrementalSpeechActive = false self.incrementalSpeechContext = nil self.incrementalSpeechDirective = nil @@ -1196,20 +1230,168 @@ final class TalkModeManager: NSObject { self.incrementalSpeechTask = Task { @MainActor [weak self] in guard let self else { return } + defer { + self.cancelIncrementalPrefetch() + self.isSpeaking = false + self.stopRecognition() + self.incrementalSpeechTask = nil + } while !Task.isCancelled { guard !self.incrementalSpeechQueue.isEmpty else { break } let segment = self.incrementalSpeechQueue.removeFirst() self.statusText = "Speaking…" self.isSpeaking = true self.lastSpokenText = segment - await self.speakIncrementalSegment(segment) + await self.updateIncrementalContextIfNeeded() + let context = self.incrementalSpeechContext + let prefetchedAudio = await self.consumeIncrementalPrefetchedAudioIfAvailable( + for: segment, + context: context) + if let context { + self.startIncrementalPrefetchMonitor(context: context) + } + await self.speakIncrementalSegment( + segment, + context: context, + prefetchedAudio: prefetchedAudio) + self.cancelIncrementalPrefetchMonitor() } - self.isSpeaking = false - self.stopRecognition() - self.incrementalSpeechTask = nil } } + private func cancelIncrementalPrefetch() { + self.cancelIncrementalPrefetchMonitor() + self.incrementalSpeechPrefetch?.task.cancel() + self.incrementalSpeechPrefetch = nil + } + + private func cancelIncrementalPrefetchMonitor() { + self.incrementalSpeechPrefetchMonitorTask?.cancel() + self.incrementalSpeechPrefetchMonitorTask = nil + } + + private func startIncrementalPrefetchMonitor(context: IncrementalSpeechContext) { + self.cancelIncrementalPrefetchMonitor() + self.incrementalSpeechPrefetchMonitorTask = Task { @MainActor [weak self] in + guard let self else { return } + while !Task.isCancelled { + if self.ensureIncrementalPrefetchForUpcomingSegment(context: context) { + return + } + try? await Task.sleep(nanoseconds: 40_000_000) + } + } + } + + private func ensureIncrementalPrefetchForUpcomingSegment(context: IncrementalSpeechContext) -> Bool { + guard context.canUseElevenLabs else { + self.cancelIncrementalPrefetch() + return false + } + guard let nextSegment = self.incrementalSpeechQueue.first else { return false } + if let existing = self.incrementalSpeechPrefetch { + if existing.segment == nextSegment, existing.context == context { + return true + } + existing.task.cancel() + self.incrementalSpeechPrefetch = nil + } + self.startIncrementalPrefetch(segment: nextSegment, context: context) + return self.incrementalSpeechPrefetch != nil + } + + private func startIncrementalPrefetch(segment: String, context: IncrementalSpeechContext) { + guard context.canUseElevenLabs, let apiKey = context.apiKey, let voiceId = context.voiceId else { return } + let prefetchOutputFormat = self.resolveIncrementalPrefetchOutputFormat(context: context) + let request = self.makeIncrementalTTSRequest( + text: segment, + context: context, + outputFormat: prefetchOutputFormat) + let id = UUID() + let task = Task { [weak self] in + let stream = ElevenLabsTTSClient(apiKey: apiKey).streamSynthesize(voiceId: voiceId, request: request) + var chunks: [Data] = [] + do { + for try await chunk in stream { + try Task.checkCancellation() + chunks.append(chunk) + } + await self?.completeIncrementalPrefetch(id: id, chunks: chunks) + } catch is CancellationError { + await self?.clearIncrementalPrefetch(id: id) + } catch { + await self?.failIncrementalPrefetch(id: id, error: error) + } + } + self.incrementalSpeechPrefetch = IncrementalSpeechPrefetchState( + id: id, + segment: segment, + context: context, + outputFormat: prefetchOutputFormat, + chunks: nil, + task: task) + } + + private func completeIncrementalPrefetch(id: UUID, chunks: [Data]) { + guard var prefetch = self.incrementalSpeechPrefetch, prefetch.id == id else { return } + prefetch.chunks = chunks + self.incrementalSpeechPrefetch = prefetch + } + + private func clearIncrementalPrefetch(id: UUID) { + guard let prefetch = self.incrementalSpeechPrefetch, prefetch.id == id else { return } + prefetch.task.cancel() + self.incrementalSpeechPrefetch = nil + } + + private func failIncrementalPrefetch(id: UUID, error: any Error) { + guard let prefetch = self.incrementalSpeechPrefetch, prefetch.id == id else { return } + self.logger.debug("incremental prefetch failed: \(error.localizedDescription, privacy: .public)") + prefetch.task.cancel() + self.incrementalSpeechPrefetch = nil + } + + private func consumeIncrementalPrefetchedAudioIfAvailable( + for segment: String, + context: IncrementalSpeechContext? + ) async -> IncrementalPrefetchedAudio? + { + guard let context else { + self.cancelIncrementalPrefetch() + return nil + } + guard let prefetch = self.incrementalSpeechPrefetch else { + return nil + } + guard prefetch.context == context else { + prefetch.task.cancel() + self.incrementalSpeechPrefetch = nil + return nil + } + guard prefetch.segment == segment else { + return nil + } + if let chunks = prefetch.chunks, !chunks.isEmpty { + let prefetched = IncrementalPrefetchedAudio(chunks: chunks, outputFormat: prefetch.outputFormat) + self.incrementalSpeechPrefetch = nil + return prefetched + } + await prefetch.task.value + guard let completed = self.incrementalSpeechPrefetch else { return nil } + guard completed.context == context, completed.segment == segment else { return nil } + guard let chunks = completed.chunks, !chunks.isEmpty else { return nil } + let prefetched = IncrementalPrefetchedAudio(chunks: chunks, outputFormat: completed.outputFormat) + self.incrementalSpeechPrefetch = nil + return prefetched + } + + private func resolveIncrementalPrefetchOutputFormat(context: IncrementalSpeechContext) -> String? { + if TalkTTSValidation.pcmSampleRate(from: context.outputFormat) != nil { + return ElevenLabsTTSClient.validatedOutputFormat("mp3_44100") + } + return context.outputFormat + } + private func finishIncrementalSpeech() async { guard self.incrementalSpeechActive else { return } let leftover = self.incrementalSpeechBuffer.flush() @@ -1317,77 +1499,103 @@ final class TalkModeManager: NSObject { canUseElevenLabs: canUseElevenLabs) } - private func speakIncrementalSegment(_ text: String) async { - await self.updateIncrementalContextIfNeeded() - guard let context = self.incrementalSpeechContext else { + private func makeIncrementalTTSRequest( + text: String, + context: IncrementalSpeechContext, + outputFormat: String? + ) -> ElevenLabsTTSRequest + { + ElevenLabsTTSRequest( + text: text, + modelId: context.modelId, + outputFormat: outputFormat, + speed: TalkTTSValidation.resolveSpeed( + speed: context.directive?.speed, + rateWPM: context.directive?.rateWPM), + stability: TalkTTSValidation.validatedStability( + context.directive?.stability, + modelId: context.modelId), + similarity: TalkTTSValidation.validatedUnit(context.directive?.similarity), + style: TalkTTSValidation.validatedUnit(context.directive?.style), + speakerBoost: context.directive?.speakerBoost, + seed: TalkTTSValidation.validatedSeed(context.directive?.seed), + normalize: ElevenLabsTTSClient.validatedNormalize(context.directive?.normalize), + language: context.language, + latencyTier: TalkTTSValidation.validatedLatencyTier(context.directive?.latencyTier)) + } + + private static func makeBufferedAudioStream(chunks: [Data]) -> AsyncThrowingStream { + AsyncThrowingStream { continuation in + for chunk in chunks { + continuation.yield(chunk) + } + continuation.finish() + } + } + + private func speakIncrementalSegment( + _ text: String, + context preferredContext: IncrementalSpeechContext? = nil, + prefetchedAudio: IncrementalPrefetchedAudio? = nil + ) async + { + let context: IncrementalSpeechContext + if let preferredContext { + context = preferredContext + } else { + await self.updateIncrementalContextIfNeeded() + guard let resolvedContext = self.incrementalSpeechContext else { + try? await TalkSystemSpeechSynthesizer.shared.speak( + text: text, + language: self.incrementalSpeechLanguage) + return + } + context = resolvedContext + } + + guard context.canUseElevenLabs, let apiKey = context.apiKey, let voiceId = context.voiceId else { try? await TalkSystemSpeechSynthesizer.shared.speak( text: text, language: self.incrementalSpeechLanguage) return } - if context.canUseElevenLabs, let apiKey = context.apiKey, let voiceId = context.voiceId { - let request = ElevenLabsTTSRequest( - text: text, - modelId: context.modelId, - outputFormat: context.outputFormat, - speed: TalkTTSValidation.resolveSpeed( - speed: context.directive?.speed, - rateWPM: context.directive?.rateWPM), - stability: TalkTTSValidation.validatedStability( - context.directive?.stability, - modelId: context.modelId), - similarity: TalkTTSValidation.validatedUnit(context.directive?.similarity), - style: TalkTTSValidation.validatedUnit(context.directive?.style), - speakerBoost: context.directive?.speakerBoost, - seed: TalkTTSValidation.validatedSeed(context.directive?.seed), - normalize: ElevenLabsTTSClient.validatedNormalize(context.directive?.normalize), - language: context.language, - latencyTier: TalkTTSValidation.validatedLatencyTier(context.directive?.latencyTier)) - let client = ElevenLabsTTSClient(apiKey: apiKey) - let stream = client.streamSynthesize(voiceId: voiceId, request: request) - let sampleRate = TalkTTSValidation.pcmSampleRate(from: context.outputFormat) - let result: StreamingPlaybackResult - if let sampleRate { - self.lastPlaybackWasPCM = true - var playback = await self.pcmPlayer.play(stream: stream, sampleRate: sampleRate) - if !playback.finished, playback.interruptedAt == nil { - self.logger.warning("pcm playback failed; retrying mp3") - self.lastPlaybackWasPCM = false - let mp3Format = ElevenLabsTTSClient.validatedOutputFormat("mp3_44100") - let mp3Stream = client.streamSynthesize( - voiceId: voiceId, - request: ElevenLabsTTSRequest( - text: text, - modelId: context.modelId, - outputFormat: mp3Format, - speed: TalkTTSValidation.resolveSpeed( - speed: context.directive?.speed, - rateWPM: context.directive?.rateWPM), - stability: TalkTTSValidation.validatedStability( - context.directive?.stability, - modelId: context.modelId), - similarity: TalkTTSValidation.validatedUnit(context.directive?.similarity), - style: TalkTTSValidation.validatedUnit(context.directive?.style), - speakerBoost: context.directive?.speakerBoost, - seed: TalkTTSValidation.validatedSeed(context.directive?.seed), - normalize: ElevenLabsTTSClient.validatedNormalize(context.directive?.normalize), - language: context.language, - latencyTier: TalkTTSValidation.validatedLatencyTier(context.directive?.latencyTier))) - playback = await self.mp3Player.play(stream: mp3Stream) - } - result = playback - } else { - self.lastPlaybackWasPCM = false - result = await self.mp3Player.play(stream: stream) - } - if !result.finished, let interruptedAt = result.interruptedAt { - self.lastInterruptedAtSeconds = interruptedAt - } + let client = ElevenLabsTTSClient(apiKey: apiKey) + let request = self.makeIncrementalTTSRequest( + text: text, + context: context, + outputFormat: context.outputFormat) + let stream: AsyncThrowingStream + if let prefetchedAudio, !prefetchedAudio.chunks.isEmpty { + stream = Self.makeBufferedAudioStream(chunks: prefetchedAudio.chunks) } else { - try? await TalkSystemSpeechSynthesizer.shared.speak( - text: text, - language: self.incrementalSpeechLanguage) + stream = client.streamSynthesize(voiceId: voiceId, request: request) + } + let playbackFormat = prefetchedAudio?.outputFormat ?? context.outputFormat + let sampleRate = TalkTTSValidation.pcmSampleRate(from: playbackFormat) + let result: StreamingPlaybackResult + if let sampleRate { + self.lastPlaybackWasPCM = true + var playback = await self.pcmPlayer.play(stream: stream, sampleRate: sampleRate) + if !playback.finished, playback.interruptedAt == nil { + self.logger.warning("pcm playback failed; retrying mp3") + self.lastPlaybackWasPCM = false + let mp3Format = ElevenLabsTTSClient.validatedOutputFormat("mp3_44100") + let mp3Stream = client.streamSynthesize( + voiceId: voiceId, + request: self.makeIncrementalTTSRequest( + text: text, + context: context, + outputFormat: mp3Format)) + playback = await self.mp3Player.play(stream: mp3Stream) + } + result = playback + } else { + self.lastPlaybackWasPCM = false + result = await self.mp3Player.play(stream: stream) + } + if !result.finished, let interruptedAt = result.interruptedAt { + self.lastInterruptedAtSeconds = interruptedAt } } @@ -1668,6 +1876,15 @@ extension TalkModeManager { return value.allSatisfy { $0.isLetter || $0.isNumber || $0 == "-" || $0 == "_" } } + private static func normalizedTalkApiKey(_ raw: String?) -> String? { + let trimmed = (raw ?? "").trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return nil } + guard trimmed != Self.redactedConfigSentinel else { return nil } + // Config values may be env placeholders (for example `${ELEVENLABS_API_KEY}`). + if trimmed.hasPrefix("${"), trimmed.hasSuffix("}") { return nil } + return trimmed + } + func reloadConfig() async { guard let gateway else { return } do { @@ -1699,7 +1916,19 @@ extension TalkModeManager { } self.defaultOutputFormat = (talk?["outputFormat"] as? String)? .trimmingCharacters(in: .whitespacesAndNewlines) - self.apiKey = (talk?["apiKey"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) + let rawConfigApiKey = (talk?["apiKey"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) + let configApiKey = Self.normalizedTalkApiKey(rawConfigApiKey) + let localApiKey = Self.normalizedTalkApiKey(GatewaySettingsStore.loadTalkElevenLabsApiKey()) + if rawConfigApiKey == Self.redactedConfigSentinel { + self.apiKey = (localApiKey?.isEmpty == false) ? localApiKey : nil + GatewayDiagnostics.log("talk config apiKey redacted; using local override if present") + } else { + self.apiKey = (localApiKey?.isEmpty == false) ? localApiKey : configApiKey + } + self.gatewayTalkDefaultVoiceId = self.defaultVoiceId + self.gatewayTalkDefaultModelId = self.defaultModelId + self.gatewayTalkApiKeyConfigured = (self.apiKey?.isEmpty == false) + self.gatewayTalkConfigLoaded = true if let interrupt = talk?["interruptOnSpeech"] as? Bool { self.interruptOnSpeech = interrupt } @@ -1708,6 +1937,10 @@ extension TalkModeManager { if !self.modelOverrideActive { self.currentModelId = self.defaultModelId } + self.gatewayTalkDefaultVoiceId = nil + self.gatewayTalkDefaultModelId = nil + self.gatewayTalkApiKeyConfigured = false + self.gatewayTalkConfigLoaded = false } } @@ -1829,7 +2062,7 @@ extension TalkModeManager { } #endif -private struct IncrementalSpeechContext { +private struct IncrementalSpeechContext: Equatable { let apiKey: String? let voiceId: String? let modelId: String? @@ -1839,4 +2072,18 @@ private struct IncrementalSpeechContext { let canUseElevenLabs: Bool } +private struct IncrementalSpeechPrefetchState { + let id: UUID + let segment: String + let context: IncrementalSpeechContext + let outputFormat: String? + var chunks: [Data]? + let task: Task +} + +private struct IncrementalPrefetchedAudio { + let chunks: [Data] + let outputFormat: String? +} + // swiftlint:enable type_body_length diff --git a/apps/ios/Tests/DeepLinkParserTests.swift b/apps/ios/Tests/DeepLinkParserTests.swift index 9a3d8618738..51ef9547a10 100644 --- a/apps/ios/Tests/DeepLinkParserTests.swift +++ b/apps/ios/Tests/DeepLinkParserTests.swift @@ -76,4 +76,106 @@ import Testing timeoutSeconds: nil, key: nil))) } + + @Test func parseGatewayLinkParsesCommonFields() { + let url = URL( + string: "openclaw://gateway?host=openclaw.local&port=18789&tls=1&token=abc&password=def")! + #expect( + DeepLinkParser.parse(url) == .gateway( + .init(host: "openclaw.local", port: 18789, tls: true, token: "abc", password: "def"))) + } + + @Test func parseGatewayLinkRejectsInsecureNonLoopbackWs() { + let url = URL( + string: "openclaw://gateway?host=attacker.example&port=18789&tls=0&token=abc")! + #expect(DeepLinkParser.parse(url) == nil) + } + + @Test func parseGatewayLinkRejectsInsecurePrefixBypassHost() { + let url = URL( + string: "openclaw://gateway?host=127.attacker.example&port=18789&tls=0&token=abc")! + #expect(DeepLinkParser.parse(url) == nil) + } + + @Test func parseGatewaySetupCodeParsesBase64UrlPayload() { + let payload = #"{"url":"wss://gateway.example.com:443","token":"tok","password":"pw"}"# + let encoded = Data(payload.utf8) + .base64EncodedString() + .replacingOccurrences(of: "+", with: "-") + .replacingOccurrences(of: "/", with: "_") + .replacingOccurrences(of: "=", with: "") + + let link = GatewayConnectDeepLink.fromSetupCode(encoded) + + #expect(link == .init( + host: "gateway.example.com", + port: 443, + tls: true, + token: "tok", + password: "pw")) + } + + @Test func parseGatewaySetupCodeRejectsInvalidInput() { + #expect(GatewayConnectDeepLink.fromSetupCode("not-a-valid-setup-code") == nil) + } + + @Test func parseGatewaySetupCodeDefaultsTo443ForWssWithoutPort() { + let payload = #"{"url":"wss://gateway.example.com","token":"tok"}"# + let encoded = Data(payload.utf8) + .base64EncodedString() + .replacingOccurrences(of: "+", with: "-") + .replacingOccurrences(of: "/", with: "_") + .replacingOccurrences(of: "=", with: "") + + let link = GatewayConnectDeepLink.fromSetupCode(encoded) + + #expect(link == .init( + host: "gateway.example.com", + port: 443, + tls: true, + token: "tok", + password: nil)) + } + + @Test func parseGatewaySetupCodeRejectsInsecureNonLoopbackWs() { + let payload = #"{"url":"ws://attacker.example:18789","token":"tok"}"# + let encoded = Data(payload.utf8) + .base64EncodedString() + .replacingOccurrences(of: "+", with: "-") + .replacingOccurrences(of: "/", with: "_") + .replacingOccurrences(of: "=", with: "") + + let link = GatewayConnectDeepLink.fromSetupCode(encoded) + #expect(link == nil) + } + + @Test func parseGatewaySetupCodeRejectsInsecurePrefixBypassHost() { + let payload = #"{"url":"ws://127.attacker.example:18789","token":"tok"}"# + let encoded = Data(payload.utf8) + .base64EncodedString() + .replacingOccurrences(of: "+", with: "-") + .replacingOccurrences(of: "/", with: "_") + .replacingOccurrences(of: "=", with: "") + + let link = GatewayConnectDeepLink.fromSetupCode(encoded) + #expect(link == nil) + } + + @Test func parseGatewaySetupCodeAllowsLoopbackWs() { + let payload = #"{"url":"ws://127.0.0.1:18789","token":"tok"}"# + let encoded = Data(payload.utf8) + .base64EncodedString() + .replacingOccurrences(of: "+", with: "-") + .replacingOccurrences(of: "/", with: "_") + .replacingOccurrences(of: "=", with: "") + + let link = GatewayConnectDeepLink.fromSetupCode(encoded) + + #expect(link == .init( + host: "127.0.0.1", + port: 18789, + tls: false, + token: "tok", + password: nil)) + } } diff --git a/apps/ios/Tests/GatewayConnectionControllerTests.swift b/apps/ios/Tests/GatewayConnectionControllerTests.swift index 0d3bdbba0ee..27e7aed7aea 100644 --- a/apps/ios/Tests/GatewayConnectionControllerTests.swift +++ b/apps/ios/Tests/GatewayConnectionControllerTests.swift @@ -76,4 +76,47 @@ private func withUserDefaults(_ updates: [String: Any?], _ body: () throws -> #expect(commands.contains(OpenClawLocationCommand.get.rawValue)) } } + @Test @MainActor func currentCommandsExcludeDangerousSystemExecCommands() { + withUserDefaults([ + "node.instanceId": "ios-test", + "camera.enabled": true, + "location.enabledMode": OpenClawLocationMode.whileUsing.rawValue, + ]) { + let appModel = NodeAppModel() + let controller = GatewayConnectionController(appModel: appModel, startDiscovery: false) + let commands = Set(controller._test_currentCommands()) + + // iOS should expose notify, but not host shell/exec-approval commands. + #expect(commands.contains(OpenClawSystemCommand.notify.rawValue)) + #expect(!commands.contains(OpenClawSystemCommand.run.rawValue)) + #expect(!commands.contains(OpenClawSystemCommand.which.rawValue)) + #expect(!commands.contains(OpenClawSystemCommand.execApprovalsGet.rawValue)) + #expect(!commands.contains(OpenClawSystemCommand.execApprovalsSet.rawValue)) + } + } + + @Test @MainActor func loadLastConnectionReadsSavedValues() { + withUserDefaults([:]) { + GatewaySettingsStore.saveLastGatewayConnectionManual( + host: "gateway.example.com", + port: 443, + useTLS: true, + stableID: "manual|gateway.example.com|443") + let loaded = GatewaySettingsStore.loadLastGatewayConnection() + #expect(loaded == .manual(host: "gateway.example.com", port: 443, useTLS: true, stableID: "manual|gateway.example.com|443")) + } + } + + @Test @MainActor func loadLastConnectionReturnsNilForInvalidData() { + withUserDefaults([ + "gateway.last.kind": "manual", + "gateway.last.host": "", + "gateway.last.port": 0, + "gateway.last.tls": false, + "gateway.last.stableID": "manual|invalid|0", + ]) { + let loaded = GatewaySettingsStore.loadLastGatewayConnection() + #expect(loaded == nil) + } + } } diff --git a/apps/ios/Tests/GatewayConnectionIssueTests.swift b/apps/ios/Tests/GatewayConnectionIssueTests.swift new file mode 100644 index 00000000000..8eb63f268ba --- /dev/null +++ b/apps/ios/Tests/GatewayConnectionIssueTests.swift @@ -0,0 +1,33 @@ +import Testing +@testable import OpenClaw + +@Suite(.serialized) struct GatewayConnectionIssueTests { + @Test func detectsTokenMissing() { + let issue = GatewayConnectionIssue.detect(from: "unauthorized: gateway token missing") + #expect(issue == .tokenMissing) + #expect(issue.needsAuthToken) + } + + @Test func detectsUnauthorized() { + let issue = GatewayConnectionIssue.detect(from: "Gateway error: unauthorized role") + #expect(issue == .unauthorized) + #expect(issue.needsAuthToken) + } + + @Test func detectsPairingWithRequestId() { + let issue = GatewayConnectionIssue.detect(from: "pairing required (requestId: abc123)") + #expect(issue == .pairingRequired(requestId: "abc123")) + #expect(issue.needsPairing) + #expect(issue.requestId == "abc123") + } + + @Test func detectsNetworkError() { + let issue = GatewayConnectionIssue.detect(from: "Gateway error: Connection refused") + #expect(issue == .network) + } + + @Test func returnsNoneForBenignStatus() { + let issue = GatewayConnectionIssue.detect(from: "Connected") + #expect(issue == .none) + } +} diff --git a/apps/ios/Tests/GatewayConnectionSecurityTests.swift b/apps/ios/Tests/GatewayConnectionSecurityTests.swift index 066ccb1dd22..b82ae716168 100644 --- a/apps/ios/Tests/GatewayConnectionSecurityTests.swift +++ b/apps/ios/Tests/GatewayConnectionSecurityTests.swift @@ -102,4 +102,30 @@ import Testing #expect(controller._test_didAutoConnect() == false) } + + @Test @MainActor func manualConnectionsForceTLSForNonLoopbackHosts() async { + let appModel = NodeAppModel() + let controller = GatewayConnectionController(appModel: appModel, startDiscovery: false) + + #expect(controller._test_resolveManualUseTLS(host: "gateway.example.com", useTLS: false) == true) + #expect(controller._test_resolveManualUseTLS(host: "openclaw.local", useTLS: false) == true) + #expect(controller._test_resolveManualUseTLS(host: "127.attacker.example", useTLS: false) == true) + + #expect(controller._test_resolveManualUseTLS(host: "localhost", useTLS: false) == false) + #expect(controller._test_resolveManualUseTLS(host: "127.0.0.1", useTLS: false) == false) + #expect(controller._test_resolveManualUseTLS(host: "::1", useTLS: false) == false) + #expect(controller._test_resolveManualUseTLS(host: "[::1]", useTLS: false) == false) + #expect(controller._test_resolveManualUseTLS(host: "::ffff:127.0.0.1", useTLS: false) == false) + #expect(controller._test_resolveManualUseTLS(host: "0.0.0.0", useTLS: false) == false) + } + + @Test @MainActor func manualDefaultPortUses443OnlyForTailnetTLSHosts() async { + let appModel = NodeAppModel() + let controller = GatewayConnectionController(appModel: appModel, startDiscovery: false) + + #expect(controller._test_resolveManualPort(host: "gateway.example.com", port: 0, useTLS: true) == 18789) + #expect(controller._test_resolveManualPort(host: "device.sample.ts.net", port: 0, useTLS: true) == 443) + #expect(controller._test_resolveManualPort(host: "device.sample.ts.net.", port: 0, useTLS: true) == 443) + #expect(controller._test_resolveManualPort(host: "device.sample.ts.net", port: 18789, useTLS: true) == 18789) + } } diff --git a/apps/ios/Tests/Info.plist b/apps/ios/Tests/Info.plist index e738e064fcd..a3420e27321 100644 --- a/apps/ios/Tests/Info.plist +++ b/apps/ios/Tests/Info.plist @@ -17,8 +17,8 @@ CFBundlePackageType BNDL CFBundleShortVersionString - 2026.2.16 + 2026.2.23 CFBundleVersion - 20260216 + 20260223 diff --git a/apps/ios/Tests/NodeAppModelInvokeTests.swift b/apps/ios/Tests/NodeAppModelInvokeTests.swift index 30414393996..24bc4ba0639 100644 --- a/apps/ios/Tests/NodeAppModelInvokeTests.swift +++ b/apps/ios/Tests/NodeAppModelInvokeTests.swift @@ -29,6 +29,70 @@ private func withUserDefaults(_ updates: [String: Any?], _ body: () throws -> return try body() } +private func makeAgentDeepLinkURL( + message: String, + deliver: Bool = false, + to: String? = nil, + channel: String? = nil, + key: String? = nil) -> URL +{ + var components = URLComponents() + components.scheme = "openclaw" + components.host = "agent" + var queryItems: [URLQueryItem] = [URLQueryItem(name: "message", value: message)] + if deliver { + queryItems.append(URLQueryItem(name: "deliver", value: "1")) + } + if let to { + queryItems.append(URLQueryItem(name: "to", value: to)) + } + if let channel { + queryItems.append(URLQueryItem(name: "channel", value: channel)) + } + if let key { + queryItems.append(URLQueryItem(name: "key", value: key)) + } + components.queryItems = queryItems + return components.url! +} + +@MainActor +private final class MockWatchMessagingService: @preconcurrency WatchMessagingServicing, @unchecked Sendable { + var currentStatus = WatchMessagingStatus( + supported: true, + paired: true, + appInstalled: true, + reachable: true, + activationState: "activated") + var nextSendResult = WatchNotificationSendResult( + deliveredImmediately: true, + queuedForDelivery: false, + transport: "sendMessage") + var sendError: Error? + var lastSent: (id: String, params: OpenClawWatchNotifyParams)? + private var replyHandler: (@Sendable (WatchQuickReplyEvent) -> Void)? + + func status() async -> WatchMessagingStatus { + self.currentStatus + } + + func setReplyHandler(_ handler: (@Sendable (WatchQuickReplyEvent) -> Void)?) { + self.replyHandler = handler + } + + func sendNotification(id: String, params: OpenClawWatchNotifyParams) async throws -> WatchNotificationSendResult { + self.lastSent = (id: id, params: params) + if let sendError = self.sendError { + throw sendError + } + return self.nextSendResult + } + + func emitReply(_ event: WatchQuickReplyEvent) { + self.replyHandler?(event) + } +} + @Suite(.serialized) struct NodeAppModelInvokeTests { @Test @MainActor func decodeParamsFailsWithoutJSON() { #expect(throws: Error.self) { @@ -44,6 +108,19 @@ private func withUserDefaults(_ updates: [String: Any?], _ body: () throws -> #expect(json.contains("\"value\"")) } + @Test @MainActor func chatSessionKeyDefaultsToIOSBase() { + let appModel = NodeAppModel() + #expect(appModel.chatSessionKey == "ios") + } + + @Test @MainActor func chatSessionKeyUsesAgentScopedKeyForNonDefaultAgent() { + let appModel = NodeAppModel() + appModel.gatewayDefaultAgentId = "main" + appModel.setSelectedAgentId("agent-123") + #expect(appModel.chatSessionKey == SessionKey.makeAgentSessionKey(agentId: "agent-123", baseKey: "ios")) + #expect(appModel.mainSessionKey == "agent:agent-123:main") + } + @Test @MainActor func handleInvokeRejectsBackgroundCommands() async { let appModel = NodeAppModel() appModel.setScenePhase(.background) @@ -156,6 +233,112 @@ private func withUserDefaults(_ updates: [String: Any?], _ body: () throws -> #expect(res.error?.code == .invalidRequest) } + @Test @MainActor func handleInvokeWatchStatusReturnsServiceSnapshot() async throws { + let watchService = MockWatchMessagingService() + watchService.currentStatus = WatchMessagingStatus( + supported: true, + paired: true, + appInstalled: true, + reachable: false, + activationState: "inactive") + let appModel = NodeAppModel(watchMessagingService: watchService) + let req = BridgeInvokeRequest(id: "watch-status", command: OpenClawWatchCommand.status.rawValue) + + let res = await appModel._test_handleInvoke(req) + #expect(res.ok == true) + + let payloadData = try #require(res.payloadJSON?.data(using: .utf8)) + let payload = try JSONDecoder().decode(OpenClawWatchStatusPayload.self, from: payloadData) + #expect(payload.supported == true) + #expect(payload.reachable == false) + #expect(payload.activationState == "inactive") + } + + @Test @MainActor func handleInvokeWatchNotifyRoutesToWatchService() async throws { + let watchService = MockWatchMessagingService() + watchService.nextSendResult = WatchNotificationSendResult( + deliveredImmediately: false, + queuedForDelivery: true, + transport: "transferUserInfo") + let appModel = NodeAppModel(watchMessagingService: watchService) + let params = OpenClawWatchNotifyParams( + title: "OpenClaw", + body: "Meeting with Peter is at 4pm", + priority: .timeSensitive) + let paramsData = try JSONEncoder().encode(params) + let paramsJSON = String(decoding: paramsData, as: UTF8.self) + let req = BridgeInvokeRequest( + id: "watch-notify", + command: OpenClawWatchCommand.notify.rawValue, + paramsJSON: paramsJSON) + + let res = await appModel._test_handleInvoke(req) + #expect(res.ok == true) + #expect(watchService.lastSent?.params.title == "OpenClaw") + #expect(watchService.lastSent?.params.body == "Meeting with Peter is at 4pm") + #expect(watchService.lastSent?.params.priority == .timeSensitive) + + let payloadData = try #require(res.payloadJSON?.data(using: .utf8)) + let payload = try JSONDecoder().decode(OpenClawWatchNotifyPayload.self, from: payloadData) + #expect(payload.deliveredImmediately == false) + #expect(payload.queuedForDelivery == true) + #expect(payload.transport == "transferUserInfo") + } + + @Test @MainActor func handleInvokeWatchNotifyRejectsEmptyMessage() async throws { + let watchService = MockWatchMessagingService() + let appModel = NodeAppModel(watchMessagingService: watchService) + let params = OpenClawWatchNotifyParams(title: " ", body: "\n") + let paramsData = try JSONEncoder().encode(params) + let paramsJSON = String(decoding: paramsData, as: UTF8.self) + let req = BridgeInvokeRequest( + id: "watch-notify-empty", + command: OpenClawWatchCommand.notify.rawValue, + paramsJSON: paramsJSON) + + let res = await appModel._test_handleInvoke(req) + #expect(res.ok == false) + #expect(res.error?.code == .invalidRequest) + #expect(watchService.lastSent == nil) + } + + @Test @MainActor func handleInvokeWatchNotifyReturnsUnavailableOnDeliveryFailure() async throws { + let watchService = MockWatchMessagingService() + watchService.sendError = NSError( + domain: "watch", + code: 1, + userInfo: [NSLocalizedDescriptionKey: "WATCH_UNAVAILABLE: no paired Apple Watch"]) + let appModel = NodeAppModel(watchMessagingService: watchService) + let params = OpenClawWatchNotifyParams(title: "OpenClaw", body: "Delivery check") + let paramsData = try JSONEncoder().encode(params) + let paramsJSON = String(decoding: paramsData, as: UTF8.self) + let req = BridgeInvokeRequest( + id: "watch-notify-fail", + command: OpenClawWatchCommand.notify.rawValue, + paramsJSON: paramsJSON) + + let res = await appModel._test_handleInvoke(req) + #expect(res.ok == false) + #expect(res.error?.code == .unavailable) + #expect(res.error?.message.contains("WATCH_UNAVAILABLE") == true) + } + + @Test @MainActor func watchReplyQueuesWhenGatewayOffline() async { + let watchService = MockWatchMessagingService() + let appModel = NodeAppModel(watchMessagingService: watchService) + watchService.emitReply( + WatchQuickReplyEvent( + replyId: "reply-offline-1", + promptId: "prompt-1", + actionId: "approve", + actionLabel: "Approve", + sessionKey: "ios", + note: nil, + sentAtMs: 1234, + transport: "transferUserInfo")) + #expect(appModel._test_queuedWatchReplyCount() == 1) + } + @Test @MainActor func handleDeepLinkSetsErrorWhenNotConnected() async { let appModel = NodeAppModel() let url = URL(string: "openclaw://agent?message=hello")! @@ -171,6 +354,58 @@ private func withUserDefaults(_ updates: [String: Any?], _ body: () throws -> #expect(appModel.screen.errorText?.contains("Deep link too large") == true) } + @Test @MainActor func handleDeepLinkRequiresConfirmationWhenConnectedAndUnkeyed() async { + let appModel = NodeAppModel() + appModel._test_setGatewayConnected(true) + let url = makeAgentDeepLinkURL(message: "hello from deep link") + + await appModel.handleDeepLink(url: url) + #expect(appModel.pendingAgentDeepLinkPrompt != nil) + #expect(appModel.openChatRequestID == 0) + + await appModel.approvePendingAgentDeepLinkPrompt() + #expect(appModel.pendingAgentDeepLinkPrompt == nil) + #expect(appModel.openChatRequestID == 1) + } + + @Test @MainActor func handleDeepLinkStripsDeliveryFieldsWhenUnkeyed() async throws { + let appModel = NodeAppModel() + appModel._test_setGatewayConnected(true) + let url = makeAgentDeepLinkURL( + message: "route this", + deliver: true, + to: "123456", + channel: "telegram") + + await appModel.handleDeepLink(url: url) + let prompt = try #require(appModel.pendingAgentDeepLinkPrompt) + #expect(prompt.request.deliver == false) + #expect(prompt.request.to == nil) + #expect(prompt.request.channel == nil) + } + + @Test @MainActor func handleDeepLinkRejectsLongUnkeyedMessageWhenConnected() async { + let appModel = NodeAppModel() + appModel._test_setGatewayConnected(true) + let message = String(repeating: "x", count: 241) + let url = makeAgentDeepLinkURL(message: message) + + await appModel.handleDeepLink(url: url) + #expect(appModel.pendingAgentDeepLinkPrompt == nil) + #expect(appModel.screen.errorText?.contains("blocked") == true) + } + + @Test @MainActor func handleDeepLinkBypassesPromptWithValidKey() async { + let appModel = NodeAppModel() + appModel._test_setGatewayConnected(true) + let key = NodeAppModel._test_currentDeepLinkKey() + let url = makeAgentDeepLinkURL(message: "trusted request", key: key) + + await appModel.handleDeepLink(url: url) + #expect(appModel.pendingAgentDeepLinkPrompt == nil) + #expect(appModel.openChatRequestID == 1) + } + @Test @MainActor func sendVoiceTranscriptThrowsWhenGatewayOffline() async { let appModel = NodeAppModel() await #expect(throws: Error.self) { diff --git a/apps/ios/Tests/OnboardingStateStoreTests.swift b/apps/ios/Tests/OnboardingStateStoreTests.swift new file mode 100644 index 00000000000..30c014647b6 --- /dev/null +++ b/apps/ios/Tests/OnboardingStateStoreTests.swift @@ -0,0 +1,57 @@ +import Foundation +import Testing +@testable import OpenClaw + +@Suite(.serialized) struct OnboardingStateStoreTests { + @Test @MainActor func shouldPresentWhenFreshAndDisconnected() { + let testDefaults = self.makeDefaults() + let defaults = testDefaults.defaults + defer { self.reset(testDefaults) } + + let appModel = NodeAppModel() + appModel.gatewayServerName = nil + #expect(OnboardingStateStore.shouldPresentOnLaunch(appModel: appModel, defaults: defaults)) + } + + @Test @MainActor func doesNotPresentWhenConnected() { + let testDefaults = self.makeDefaults() + let defaults = testDefaults.defaults + defer { self.reset(testDefaults) } + + let appModel = NodeAppModel() + appModel.gatewayServerName = "gateway" + #expect(!OnboardingStateStore.shouldPresentOnLaunch(appModel: appModel, defaults: defaults)) + } + + @Test @MainActor func markCompletedPersistsMode() { + let testDefaults = self.makeDefaults() + let defaults = testDefaults.defaults + defer { self.reset(testDefaults) } + + let appModel = NodeAppModel() + appModel.gatewayServerName = nil + + OnboardingStateStore.markCompleted(mode: .remoteDomain, defaults: defaults) + #expect(OnboardingStateStore.lastMode(defaults: defaults) == .remoteDomain) + #expect(!OnboardingStateStore.shouldPresentOnLaunch(appModel: appModel, defaults: defaults)) + + OnboardingStateStore.markIncomplete(defaults: defaults) + #expect(OnboardingStateStore.shouldPresentOnLaunch(appModel: appModel, defaults: defaults)) + } + + private struct TestDefaults { + var suiteName: String + var defaults: UserDefaults + } + + private func makeDefaults() -> TestDefaults { + let suiteName = "OnboardingStateStoreTests.\(UUID().uuidString)" + return TestDefaults( + suiteName: suiteName, + defaults: UserDefaults(suiteName: suiteName) ?? .standard) + } + + private func reset(_ defaults: TestDefaults) { + defaults.defaults.removePersistentDomain(forName: defaults.suiteName) + } +} diff --git a/apps/ios/Tests/ScreenControllerTests.swift b/apps/ios/Tests/ScreenControllerTests.swift index 32c36acacb7..d0e47c84fb3 100644 --- a/apps/ios/Tests/ScreenControllerTests.swift +++ b/apps/ios/Tests/ScreenControllerTests.swift @@ -2,25 +2,38 @@ import Testing import WebKit @testable import OpenClaw +@MainActor +private func mountScreen(_ screen: ScreenController) throws -> (ScreenWebViewCoordinator, WKWebView) { + let coordinator = ScreenWebViewCoordinator(controller: screen) + _ = coordinator.makeContainerView() + let webView = try #require(coordinator.managedWebView) + return (coordinator, webView) +} + @Suite struct ScreenControllerTests { - @Test @MainActor func canvasModeConfiguresWebViewForTouch() { + @Test @MainActor func canvasModeConfiguresWebViewForTouch() throws { let screen = ScreenController() + let (coordinator, webView) = try mountScreen(screen) + defer { coordinator.teardown() } - #expect(screen.webView.isOpaque == true) - #expect(screen.webView.backgroundColor == .black) + #expect(webView.isOpaque == true) + #expect(webView.backgroundColor == .black) - let scrollView = screen.webView.scrollView + let scrollView = webView.scrollView #expect(scrollView.backgroundColor == .black) #expect(scrollView.contentInsetAdjustmentBehavior == .never) #expect(scrollView.isScrollEnabled == false) #expect(scrollView.bounces == false) } - @Test @MainActor func navigateEnablesScrollForWebPages() { + @Test @MainActor func navigateEnablesScrollForWebPages() throws { let screen = ScreenController() + let (coordinator, webView) = try mountScreen(screen) + defer { coordinator.teardown() } + screen.navigate(to: "https://example.com") - let scrollView = screen.webView.scrollView + let scrollView = webView.scrollView #expect(scrollView.isScrollEnabled == true) #expect(scrollView.bounces == true) } @@ -34,6 +47,9 @@ import WebKit @Test @MainActor func evalExecutesJavaScript() async throws { let screen = ScreenController() + let (coordinator, _) = try mountScreen(screen) + defer { coordinator.teardown() } + let deadline = ContinuousClock().now.advanced(by: .seconds(3)) while true { diff --git a/apps/ios/Tests/ShareToAgentDeepLinkTests.swift b/apps/ios/Tests/ShareToAgentDeepLinkTests.swift new file mode 100644 index 00000000000..4ea178ecfa2 --- /dev/null +++ b/apps/ios/Tests/ShareToAgentDeepLinkTests.swift @@ -0,0 +1,51 @@ +import OpenClawKit +import Foundation +import Testing + +@Suite struct ShareToAgentDeepLinkTests { + @Test func buildMessageIncludesSharedFields() { + let payload = SharedContentPayload( + title: "Article", + url: URL(string: "https://example.com/post")!, + text: "Read this") + + let message = ShareToAgentDeepLink.buildMessage( + from: payload, + instruction: "Summarize and give next steps.") + #expect(message.contains("Shared from iOS.")) + #expect(message.contains("Title: Article")) + #expect(message.contains("URL: https://example.com/post")) + #expect(message.contains("Text:\nRead this")) + #expect(message.contains("Summarize and give next steps.")) + } + + @Test func buildURLEncodesAgentRoute() { + let payload = SharedContentPayload( + title: "", + url: URL(string: "https://example.com")!, + text: nil) + + let url = ShareToAgentDeepLink.buildURL(from: payload) + let parsed = url.flatMap { DeepLinkParser.parse($0) } + guard case let .agent(agent)? = parsed else { + Issue.record("Expected openclaw://agent deep link") + return + } + + #expect(agent.thinking == "low") + #expect(agent.message.contains("https://example.com")) + } + + @Test func buildURLReturnsNilWhenPayloadEmpty() { + let payload = SharedContentPayload(title: nil, url: nil, text: nil) + #expect(ShareToAgentDeepLink.buildURL(from: payload) == nil) + } + + @Test func shareInstructionSettingsRoundTrip() { + let value = "Focus on booking constraints and alternatives." + ShareToAgentSettings.saveDefaultInstruction(value) + defer { ShareToAgentSettings.saveDefaultInstruction(nil) } + + #expect(ShareToAgentSettings.loadDefaultInstruction() == value) + } +} diff --git a/apps/ios/WatchApp/Assets.xcassets/AppIcon.appiconset/Contents.json b/apps/ios/WatchApp/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 00000000000..a72027bb4fa --- /dev/null +++ b/apps/ios/WatchApp/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,116 @@ +{ + "images": [ + { + "idiom": "watch", + "role": "notificationCenter", + "subtype": "38mm", + "size": "24x24", + "scale": "2x", + "filename": "watch-notification-38@2x.png" + }, + { + "idiom": "watch", + "role": "notificationCenter", + "subtype": "42mm", + "size": "27.5x27.5", + "scale": "2x", + "filename": "watch-notification-42@2x.png" + }, + { + "idiom": "watch", + "role": "companionSettings", + "size": "29x29", + "scale": "2x", + "filename": "watch-companion-29@2x.png" + }, + { + "idiom": "watch", + "role": "companionSettings", + "size": "29x29", + "scale": "3x", + "filename": "watch-companion-29@3x.png" + }, + { + "idiom": "watch", + "role": "appLauncher", + "subtype": "38mm", + "size": "40x40", + "scale": "2x", + "filename": "watch-app-38@2x.png" + }, + { + "idiom": "watch", + "role": "appLauncher", + "subtype": "40mm", + "size": "44x44", + "scale": "2x", + "filename": "watch-app-40@2x.png" + }, + { + "idiom": "watch", + "role": "appLauncher", + "subtype": "41mm", + "size": "46x46", + "scale": "2x", + "filename": "watch-app-41@2x.png" + }, + { + "idiom": "watch", + "role": "appLauncher", + "subtype": "44mm", + "size": "50x50", + "scale": "2x", + "filename": "watch-app-44@2x.png" + }, + { + "idiom": "watch", + "role": "appLauncher", + "subtype": "45mm", + "size": "51x51", + "scale": "2x", + "filename": "watch-app-45@2x.png" + }, + { + "idiom": "watch", + "role": "quickLook", + "subtype": "38mm", + "size": "86x86", + "scale": "2x", + "filename": "watch-quicklook-38@2x.png" + }, + { + "idiom": "watch", + "role": "quickLook", + "subtype": "42mm", + "size": "98x98", + "scale": "2x", + "filename": "watch-quicklook-42@2x.png" + }, + { + "idiom": "watch", + "role": "quickLook", + "subtype": "44mm", + "size": "108x108", + "scale": "2x", + "filename": "watch-quicklook-44@2x.png" + }, + { + "idiom": "watch", + "role": "quickLook", + "subtype": "45mm", + "size": "117x117", + "scale": "2x", + "filename": "watch-quicklook-45@2x.png" + }, + { + "idiom": "watch-marketing", + "size": "1024x1024", + "scale": "1x", + "filename": "watch-marketing-1024.png" + } + ], + "info": { + "version": 1, + "author": "xcode" + } +} diff --git a/apps/ios/WatchApp/Assets.xcassets/AppIcon.appiconset/watch-app-38@2x.png b/apps/ios/WatchApp/Assets.xcassets/AppIcon.appiconset/watch-app-38@2x.png new file mode 100644 index 00000000000..82829afb947 Binary files /dev/null and b/apps/ios/WatchApp/Assets.xcassets/AppIcon.appiconset/watch-app-38@2x.png differ diff --git a/apps/ios/WatchApp/Assets.xcassets/AppIcon.appiconset/watch-app-40@2x.png b/apps/ios/WatchApp/Assets.xcassets/AppIcon.appiconset/watch-app-40@2x.png new file mode 100644 index 00000000000..114d4606420 Binary files /dev/null and b/apps/ios/WatchApp/Assets.xcassets/AppIcon.appiconset/watch-app-40@2x.png differ diff --git a/apps/ios/WatchApp/Assets.xcassets/AppIcon.appiconset/watch-app-41@2x.png b/apps/ios/WatchApp/Assets.xcassets/AppIcon.appiconset/watch-app-41@2x.png new file mode 100644 index 00000000000..5f9578b1b97 Binary files /dev/null and b/apps/ios/WatchApp/Assets.xcassets/AppIcon.appiconset/watch-app-41@2x.png differ diff --git a/apps/ios/WatchApp/Assets.xcassets/AppIcon.appiconset/watch-app-44@2x.png b/apps/ios/WatchApp/Assets.xcassets/AppIcon.appiconset/watch-app-44@2x.png new file mode 100644 index 00000000000..fe022ac7720 Binary files /dev/null and b/apps/ios/WatchApp/Assets.xcassets/AppIcon.appiconset/watch-app-44@2x.png differ diff --git a/apps/ios/WatchApp/Assets.xcassets/AppIcon.appiconset/watch-app-45@2x.png b/apps/ios/WatchApp/Assets.xcassets/AppIcon.appiconset/watch-app-45@2x.png new file mode 100644 index 00000000000..55977b8f6e7 Binary files /dev/null and b/apps/ios/WatchApp/Assets.xcassets/AppIcon.appiconset/watch-app-45@2x.png differ diff --git a/apps/ios/WatchApp/Assets.xcassets/AppIcon.appiconset/watch-companion-29@2x.png b/apps/ios/WatchApp/Assets.xcassets/AppIcon.appiconset/watch-companion-29@2x.png new file mode 100644 index 00000000000..f8be7d06911 Binary files /dev/null and b/apps/ios/WatchApp/Assets.xcassets/AppIcon.appiconset/watch-companion-29@2x.png differ diff --git a/apps/ios/WatchApp/Assets.xcassets/AppIcon.appiconset/watch-companion-29@3x.png b/apps/ios/WatchApp/Assets.xcassets/AppIcon.appiconset/watch-companion-29@3x.png new file mode 100644 index 00000000000..cce412d2452 Binary files /dev/null and b/apps/ios/WatchApp/Assets.xcassets/AppIcon.appiconset/watch-companion-29@3x.png differ diff --git a/apps/ios/WatchApp/Assets.xcassets/AppIcon.appiconset/watch-marketing-1024.png b/apps/ios/WatchApp/Assets.xcassets/AppIcon.appiconset/watch-marketing-1024.png new file mode 100644 index 00000000000..005486f2ee1 Binary files /dev/null and b/apps/ios/WatchApp/Assets.xcassets/AppIcon.appiconset/watch-marketing-1024.png differ diff --git a/apps/ios/WatchApp/Assets.xcassets/AppIcon.appiconset/watch-notification-38@2x.png b/apps/ios/WatchApp/Assets.xcassets/AppIcon.appiconset/watch-notification-38@2x.png new file mode 100644 index 00000000000..7b7a0ee0b65 Binary files /dev/null and b/apps/ios/WatchApp/Assets.xcassets/AppIcon.appiconset/watch-notification-38@2x.png differ diff --git a/apps/ios/WatchApp/Assets.xcassets/AppIcon.appiconset/watch-notification-42@2x.png b/apps/ios/WatchApp/Assets.xcassets/AppIcon.appiconset/watch-notification-42@2x.png new file mode 100644 index 00000000000..f13c9cdddda Binary files /dev/null and b/apps/ios/WatchApp/Assets.xcassets/AppIcon.appiconset/watch-notification-42@2x.png differ diff --git a/apps/ios/WatchApp/Assets.xcassets/AppIcon.appiconset/watch-quicklook-38@2x.png b/apps/ios/WatchApp/Assets.xcassets/AppIcon.appiconset/watch-quicklook-38@2x.png new file mode 100644 index 00000000000..aac0859b44c Binary files /dev/null and b/apps/ios/WatchApp/Assets.xcassets/AppIcon.appiconset/watch-quicklook-38@2x.png differ diff --git a/apps/ios/WatchApp/Assets.xcassets/AppIcon.appiconset/watch-quicklook-42@2x.png b/apps/ios/WatchApp/Assets.xcassets/AppIcon.appiconset/watch-quicklook-42@2x.png new file mode 100644 index 00000000000..d09be6e98a6 Binary files /dev/null and b/apps/ios/WatchApp/Assets.xcassets/AppIcon.appiconset/watch-quicklook-42@2x.png differ diff --git a/apps/ios/WatchApp/Assets.xcassets/AppIcon.appiconset/watch-quicklook-44@2x.png b/apps/ios/WatchApp/Assets.xcassets/AppIcon.appiconset/watch-quicklook-44@2x.png new file mode 100644 index 00000000000..5b06a48744b Binary files /dev/null and b/apps/ios/WatchApp/Assets.xcassets/AppIcon.appiconset/watch-quicklook-44@2x.png differ diff --git a/apps/ios/WatchApp/Assets.xcassets/AppIcon.appiconset/watch-quicklook-45@2x.png b/apps/ios/WatchApp/Assets.xcassets/AppIcon.appiconset/watch-quicklook-45@2x.png new file mode 100644 index 00000000000..72ba51ebb1d Binary files /dev/null and b/apps/ios/WatchApp/Assets.xcassets/AppIcon.appiconset/watch-quicklook-45@2x.png differ diff --git a/apps/ios/WatchApp/Assets.xcassets/Contents.json b/apps/ios/WatchApp/Assets.xcassets/Contents.json new file mode 100644 index 00000000000..97a8662ebdb --- /dev/null +++ b/apps/ios/WatchApp/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info": { + "version": 1, + "author": "xcode" + } +} diff --git a/apps/ios/WatchApp/Info.plist b/apps/ios/WatchApp/Info.plist new file mode 100644 index 00000000000..4e309b031a6 --- /dev/null +++ b/apps/ios/WatchApp/Info.plist @@ -0,0 +1,28 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + OpenClaw + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + APPL + CFBundleShortVersionString + 2026.2.23 + CFBundleVersion + 20260223 + WKCompanionAppBundleIdentifier + $(OPENCLAW_APP_BUNDLE_ID) + WKWatchKitApp + + + diff --git a/apps/ios/WatchExtension/Info.plist b/apps/ios/WatchExtension/Info.plist new file mode 100644 index 00000000000..1b5f28dfc43 --- /dev/null +++ b/apps/ios/WatchExtension/Info.plist @@ -0,0 +1,32 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + OpenClaw + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundleShortVersionString + 2026.2.23 + CFBundleVersion + 20260223 + NSExtension + + NSExtensionAttributes + + WKAppBundleIdentifier + $(OPENCLAW_WATCH_APP_BUNDLE_ID) + + NSExtensionPointIdentifier + com.apple.watchkit + + + diff --git a/apps/ios/WatchExtension/Sources/OpenClawWatchApp.swift b/apps/ios/WatchExtension/Sources/OpenClawWatchApp.swift new file mode 100644 index 00000000000..4c123c49f16 --- /dev/null +++ b/apps/ios/WatchExtension/Sources/OpenClawWatchApp.swift @@ -0,0 +1,28 @@ +import SwiftUI + +@main +struct OpenClawWatchApp: App { + @State private var inboxStore = WatchInboxStore() + @State private var receiver: WatchConnectivityReceiver? + + var body: some Scene { + WindowGroup { + WatchInboxView(store: self.inboxStore) { action in + guard let receiver = self.receiver else { return } + let draft = self.inboxStore.makeReplyDraft(action: action) + self.inboxStore.markReplySending(actionLabel: action.label) + Task { @MainActor in + let result = await receiver.sendReply(draft) + self.inboxStore.markReplyResult(result, actionLabel: action.label) + } + } + .task { + if self.receiver == nil { + let receiver = WatchConnectivityReceiver(store: self.inboxStore) + receiver.activate() + self.receiver = receiver + } + } + } + } +} diff --git a/apps/ios/WatchExtension/Sources/WatchConnectivityReceiver.swift b/apps/ios/WatchExtension/Sources/WatchConnectivityReceiver.swift new file mode 100644 index 00000000000..da1c3c379a3 --- /dev/null +++ b/apps/ios/WatchExtension/Sources/WatchConnectivityReceiver.swift @@ -0,0 +1,236 @@ +import Foundation +import WatchConnectivity + +struct WatchReplyDraft: Sendable { + var replyId: String + var promptId: String + var actionId: String + var actionLabel: String? + var sessionKey: String? + var note: String? + var sentAtMs: Int +} + +struct WatchReplySendResult: Sendable, Equatable { + var deliveredImmediately: Bool + var queuedForDelivery: Bool + var transport: String + var errorMessage: String? +} + +final class WatchConnectivityReceiver: NSObject, @unchecked Sendable { + private let store: WatchInboxStore + private let session: WCSession? + + init(store: WatchInboxStore) { + self.store = store + if WCSession.isSupported() { + self.session = WCSession.default + } else { + self.session = nil + } + super.init() + } + + func activate() { + guard let session = self.session else { return } + session.delegate = self + session.activate() + } + + private func ensureActivated() async { + guard let session = self.session else { return } + if session.activationState == .activated { + return + } + session.activate() + for _ in 0..<8 { + if session.activationState == .activated { + return + } + try? await Task.sleep(nanoseconds: 100_000_000) + } + } + + func sendReply(_ draft: WatchReplyDraft) async -> WatchReplySendResult { + await self.ensureActivated() + guard let session = self.session else { + return WatchReplySendResult( + deliveredImmediately: false, + queuedForDelivery: false, + transport: "none", + errorMessage: "watch session unavailable") + } + + var payload: [String: Any] = [ + "type": "watch.reply", + "replyId": draft.replyId, + "promptId": draft.promptId, + "actionId": draft.actionId, + "sentAtMs": draft.sentAtMs, + ] + if let actionLabel = draft.actionLabel?.trimmingCharacters(in: .whitespacesAndNewlines), + !actionLabel.isEmpty + { + payload["actionLabel"] = actionLabel + } + if let sessionKey = draft.sessionKey?.trimmingCharacters(in: .whitespacesAndNewlines), + !sessionKey.isEmpty + { + payload["sessionKey"] = sessionKey + } + if let note = draft.note?.trimmingCharacters(in: .whitespacesAndNewlines), !note.isEmpty { + payload["note"] = note + } + + if session.isReachable { + do { + try await withCheckedThrowingContinuation { continuation in + session.sendMessage(payload, replyHandler: { _ in + continuation.resume() + }, errorHandler: { error in + continuation.resume(throwing: error) + }) + } + return WatchReplySendResult( + deliveredImmediately: true, + queuedForDelivery: false, + transport: "sendMessage", + errorMessage: nil) + } catch { + // Fall through to queued delivery below. + } + } + + _ = session.transferUserInfo(payload) + return WatchReplySendResult( + deliveredImmediately: false, + queuedForDelivery: true, + transport: "transferUserInfo", + errorMessage: nil) + } + + private static func normalizeObject(_ value: Any) -> [String: Any]? { + if let object = value as? [String: Any] { + return object + } + if let object = value as? [AnyHashable: Any] { + var normalized: [String: Any] = [:] + normalized.reserveCapacity(object.count) + for (key, item) in object { + guard let stringKey = key as? String else { + continue + } + normalized[stringKey] = item + } + return normalized + } + return nil + } + + private static func parseActions(_ value: Any?) -> [WatchPromptAction] { + guard let raw = value as? [Any] else { + return [] + } + return raw.compactMap { item in + guard let obj = Self.normalizeObject(item) else { + return nil + } + let id = (obj["id"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + let label = (obj["label"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + guard !id.isEmpty, !label.isEmpty else { + return nil + } + let style = (obj["style"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) + return WatchPromptAction(id: id, label: label, style: style) + } + } + + private static func parseNotificationPayload(_ payload: [String: Any]) -> WatchNotifyMessage? { + guard let type = payload["type"] as? String, type == "watch.notify" else { + return nil + } + + let title = (payload["title"] as? String)? + .trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + let body = (payload["body"] as? String)? + .trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + + guard title.isEmpty == false || body.isEmpty == false else { + return nil + } + + let id = (payload["id"] as? String)? + .trimmingCharacters(in: .whitespacesAndNewlines) + let sentAtMs = (payload["sentAtMs"] as? Int) ?? (payload["sentAtMs"] as? NSNumber)?.intValue + let promptId = (payload["promptId"] as? String)? + .trimmingCharacters(in: .whitespacesAndNewlines) + let sessionKey = (payload["sessionKey"] as? String)? + .trimmingCharacters(in: .whitespacesAndNewlines) + let kind = (payload["kind"] as? String)? + .trimmingCharacters(in: .whitespacesAndNewlines) + let details = (payload["details"] as? String)? + .trimmingCharacters(in: .whitespacesAndNewlines) + let expiresAtMs = (payload["expiresAtMs"] as? Int) ?? (payload["expiresAtMs"] as? NSNumber)?.intValue + let risk = (payload["risk"] as? String)? + .trimmingCharacters(in: .whitespacesAndNewlines) + let actions = Self.parseActions(payload["actions"]) + + return WatchNotifyMessage( + id: id, + title: title, + body: body, + sentAtMs: sentAtMs, + promptId: promptId, + sessionKey: sessionKey, + kind: kind, + details: details, + expiresAtMs: expiresAtMs, + risk: risk, + actions: actions) + } +} + +extension WatchConnectivityReceiver: WCSessionDelegate { + func session( + _: WCSession, + activationDidCompleteWith _: WCSessionActivationState, + error _: (any Error)?) + {} + + func session(_: WCSession, didReceiveMessage message: [String: Any]) { + guard let incoming = Self.parseNotificationPayload(message) else { return } + Task { @MainActor in + self.store.consume(message: incoming, transport: "sendMessage") + } + } + + func session( + _: WCSession, + didReceiveMessage message: [String: Any], + replyHandler: @escaping ([String: Any]) -> Void) + { + guard let incoming = Self.parseNotificationPayload(message) else { + replyHandler(["ok": false]) + return + } + replyHandler(["ok": true]) + Task { @MainActor in + self.store.consume(message: incoming, transport: "sendMessage") + } + } + + func session(_: WCSession, didReceiveUserInfo userInfo: [String: Any]) { + guard let incoming = Self.parseNotificationPayload(userInfo) else { return } + Task { @MainActor in + self.store.consume(message: incoming, transport: "transferUserInfo") + } + } + + func session(_: WCSession, didReceiveApplicationContext applicationContext: [String: Any]) { + guard let incoming = Self.parseNotificationPayload(applicationContext) else { return } + Task { @MainActor in + self.store.consume(message: incoming, transport: "applicationContext") + } + } +} diff --git a/apps/ios/WatchExtension/Sources/WatchInboxStore.swift b/apps/ios/WatchExtension/Sources/WatchInboxStore.swift new file mode 100644 index 00000000000..2ac1d75d6e1 --- /dev/null +++ b/apps/ios/WatchExtension/Sources/WatchInboxStore.swift @@ -0,0 +1,230 @@ +import Foundation +import Observation +import UserNotifications +import WatchKit + +struct WatchPromptAction: Codable, Sendable, Equatable, Identifiable { + var id: String + var label: String + var style: String? +} + +struct WatchNotifyMessage: Sendable { + var id: String? + var title: String + var body: String + var sentAtMs: Int? + var promptId: String? + var sessionKey: String? + var kind: String? + var details: String? + var expiresAtMs: Int? + var risk: String? + var actions: [WatchPromptAction] +} + +@MainActor @Observable final class WatchInboxStore { + private struct PersistedState: Codable { + var title: String + var body: String + var transport: String + var updatedAt: Date + var lastDeliveryKey: String? + var promptId: String? + var sessionKey: String? + var kind: String? + var details: String? + var expiresAtMs: Int? + var risk: String? + var actions: [WatchPromptAction]? + var replyStatusText: String? + var replyStatusAt: Date? + } + + private static let persistedStateKey = "watch.inbox.state.v1" + private let defaults: UserDefaults + + var title = "OpenClaw" + var body = "Waiting for messages from your iPhone." + var transport = "none" + var updatedAt: Date? + var promptId: String? + var sessionKey: String? + var kind: String? + var details: String? + var expiresAtMs: Int? + var risk: String? + var actions: [WatchPromptAction] = [] + var replyStatusText: String? + var replyStatusAt: Date? + var isReplySending = false + private var lastDeliveryKey: String? + + init(defaults: UserDefaults = .standard) { + self.defaults = defaults + self.restorePersistedState() + Task { + await self.ensureNotificationAuthorization() + } + } + + func consume(message: WatchNotifyMessage, transport: String) { + let messageID = message.id? + .trimmingCharacters(in: .whitespacesAndNewlines) + let deliveryKey = self.deliveryKey( + messageID: messageID, + title: message.title, + body: message.body, + sentAtMs: message.sentAtMs) + guard deliveryKey != self.lastDeliveryKey else { return } + + let normalizedTitle = message.title.isEmpty ? "OpenClaw" : message.title + self.title = normalizedTitle + self.body = message.body + self.transport = transport + self.updatedAt = Date() + self.promptId = message.promptId + self.sessionKey = message.sessionKey + self.kind = message.kind + self.details = message.details + self.expiresAtMs = message.expiresAtMs + self.risk = message.risk + self.actions = message.actions + self.lastDeliveryKey = deliveryKey + self.replyStatusText = nil + self.replyStatusAt = nil + self.isReplySending = false + self.persistState() + + Task { + await self.postLocalNotification( + identifier: deliveryKey, + title: normalizedTitle, + body: message.body, + risk: message.risk) + } + } + + private func restorePersistedState() { + guard let data = self.defaults.data(forKey: Self.persistedStateKey), + let state = try? JSONDecoder().decode(PersistedState.self, from: data) + else { + return + } + + self.title = state.title + self.body = state.body + self.transport = state.transport + self.updatedAt = state.updatedAt + self.lastDeliveryKey = state.lastDeliveryKey + self.promptId = state.promptId + self.sessionKey = state.sessionKey + self.kind = state.kind + self.details = state.details + self.expiresAtMs = state.expiresAtMs + self.risk = state.risk + self.actions = state.actions ?? [] + self.replyStatusText = state.replyStatusText + self.replyStatusAt = state.replyStatusAt + } + + private func persistState() { + guard let updatedAt = self.updatedAt else { return } + let state = PersistedState( + title: self.title, + body: self.body, + transport: self.transport, + updatedAt: updatedAt, + lastDeliveryKey: self.lastDeliveryKey, + promptId: self.promptId, + sessionKey: self.sessionKey, + kind: self.kind, + details: self.details, + expiresAtMs: self.expiresAtMs, + risk: self.risk, + actions: self.actions, + replyStatusText: self.replyStatusText, + replyStatusAt: self.replyStatusAt) + guard let data = try? JSONEncoder().encode(state) else { return } + self.defaults.set(data, forKey: Self.persistedStateKey) + } + + private func deliveryKey(messageID: String?, title: String, body: String, sentAtMs: Int?) -> String { + if let messageID, messageID.isEmpty == false { + return "id:\(messageID)" + } + return "content:\(title)|\(body)|\(sentAtMs ?? 0)" + } + + private func ensureNotificationAuthorization() async { + let center = UNUserNotificationCenter.current() + let settings = await center.notificationSettings() + switch settings.authorizationStatus { + case .notDetermined: + _ = try? await center.requestAuthorization(options: [.alert, .sound]) + default: + break + } + } + + private func mapHapticRisk(_ risk: String?) -> WKHapticType { + switch risk?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() { + case "high": + return .failure + case "medium": + return .notification + default: + return .click + } + } + + func makeReplyDraft(action: WatchPromptAction) -> WatchReplyDraft { + let prompt = self.promptId?.trimmingCharacters(in: .whitespacesAndNewlines) + return WatchReplyDraft( + replyId: UUID().uuidString, + promptId: (prompt?.isEmpty == false) ? prompt! : "unknown", + actionId: action.id, + actionLabel: action.label, + sessionKey: self.sessionKey, + note: nil, + sentAtMs: Int(Date().timeIntervalSince1970 * 1000)) + } + + func markReplySending(actionLabel: String) { + self.isReplySending = true + self.replyStatusText = "Sending \(actionLabel)…" + self.replyStatusAt = Date() + self.persistState() + } + + func markReplyResult(_ result: WatchReplySendResult, actionLabel: String) { + self.isReplySending = false + if let errorMessage = result.errorMessage, !errorMessage.isEmpty { + self.replyStatusText = "Failed: \(errorMessage)" + } else if result.deliveredImmediately { + self.replyStatusText = "\(actionLabel): sent" + } else if result.queuedForDelivery { + self.replyStatusText = "\(actionLabel): queued" + } else { + self.replyStatusText = "\(actionLabel): sent" + } + self.replyStatusAt = Date() + self.persistState() + } + + private func postLocalNotification(identifier: String, title: String, body: String, risk: String?) async { + let content = UNMutableNotificationContent() + content.title = title + content.body = body + content.sound = .default + content.threadIdentifier = "openclaw-watch" + + let request = UNNotificationRequest( + identifier: identifier, + content: content, + trigger: UNTimeIntervalNotificationTrigger(timeInterval: 0.2, repeats: false)) + + _ = try? await UNUserNotificationCenter.current().add(request) + WKInterfaceDevice.current().play(self.mapHapticRisk(risk)) + } +} diff --git a/apps/ios/WatchExtension/Sources/WatchInboxView.swift b/apps/ios/WatchExtension/Sources/WatchInboxView.swift new file mode 100644 index 00000000000..c6f944a949e --- /dev/null +++ b/apps/ios/WatchExtension/Sources/WatchInboxView.swift @@ -0,0 +1,64 @@ +import SwiftUI + +struct WatchInboxView: View { + @Bindable var store: WatchInboxStore + var onAction: ((WatchPromptAction) -> Void)? + + private func role(for action: WatchPromptAction) -> ButtonRole? { + switch action.style?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() { + case "destructive": + return .destructive + case "cancel": + return .cancel + default: + return nil + } + } + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 8) { + Text(store.title) + .font(.headline) + .lineLimit(2) + + Text(store.body) + .font(.body) + .fixedSize(horizontal: false, vertical: true) + + if let details = store.details, !details.isEmpty { + Text(details) + .font(.footnote) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + + if !store.actions.isEmpty { + ForEach(store.actions) { action in + Button(role: self.role(for: action)) { + self.onAction?(action) + } label: { + Text(action.label) + .frame(maxWidth: .infinity) + } + .disabled(store.isReplySending) + } + } + + if let replyStatusText = store.replyStatusText, !replyStatusText.isEmpty { + Text(replyStatusText) + .font(.footnote) + .foregroundStyle(.secondary) + } + + if let updatedAt = store.updatedAt { + Text("Updated \(updatedAt.formatted(date: .omitted, time: .shortened))") + .font(.footnote) + .foregroundStyle(.secondary) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding() + } + } +} diff --git a/apps/ios/fastlane/Fastfile b/apps/ios/fastlane/Fastfile index b777c25c7a5..f1dbf6df18c 100644 --- a/apps/ios/fastlane/Fastfile +++ b/apps/ios/fastlane/Fastfile @@ -66,7 +66,8 @@ platform :ios do if team_id.nil? || team_id.strip.empty? helper_path = File.expand_path("../../scripts/ios-team-id.sh", __dir__) if File.exist?(helper_path) - team_id = sh("bash #{helper_path.shellescape}").strip + # Keep CI/local compatibility where teams are present in keychain but not Xcode account metadata. + team_id = sh("IOS_ALLOW_KEYCHAIN_TEAM_FALLBACK=1 bash #{helper_path.shellescape}").strip end end UI.user_error!("Missing IOS_DEVELOPMENT_TEAM (Apple Team ID). Add it to fastlane/.env or export it in your shell.") if team_id.nil? || team_id.strip.empty? diff --git a/apps/ios/fastlane/SETUP.md b/apps/ios/fastlane/SETUP.md index 832f1ebc15b..930258fcc79 100644 --- a/apps/ios/fastlane/SETUP.md +++ b/apps/ios/fastlane/SETUP.md @@ -22,7 +22,7 @@ ASC_KEY_PATH=/absolute/path/to/AuthKey_XXXXXXXXXX.p8 IOS_DEVELOPMENT_TEAM=YOUR_TEAM_ID ``` -Tip: run `scripts/ios-team-id.sh` from the repo root to print a Team ID to paste into `.env`. Fastlane falls back to this helper if `IOS_DEVELOPMENT_TEAM` is missing. +Tip: run `scripts/ios-team-id.sh` from the repo root to print a Team ID to paste into `.env`. The helper prefers the canonical OpenClaw team (`Y5PE65HELJ`) when present locally; otherwise it prefers the first non-personal team from your Xcode account (then personal team if needed). Fastlane uses this helper automatically if `IOS_DEVELOPMENT_TEAM` is missing. Run: diff --git a/apps/ios/project.yml b/apps/ios/project.yml index 4231172b777..1028876e510 100644 --- a/apps/ios/project.yml +++ b/apps/ios/project.yml @@ -29,9 +29,15 @@ targets: OpenClaw: type: application platform: iOS + configFiles: + Debug: Signing.xcconfig + Release: Signing.xcconfig sources: - path: Sources dependencies: + - target: OpenClawShareExtension + embed: true + - target: OpenClawWatchApp - package: OpenClawKit - package: OpenClawKit product: OpenClawChatUI @@ -69,10 +75,11 @@ targets: settings: base: CODE_SIGN_IDENTITY: "Apple Development" - CODE_SIGN_STYLE: Manual - DEVELOPMENT_TEAM: Y5PE65HELJ - PRODUCT_BUNDLE_IDENTIFIER: ai.openclaw.ios - PROVISIONING_PROFILE_SPECIFIER: "ai.openclaw.ios Development" + CODE_SIGN_ENTITLEMENTS: Sources/OpenClaw.entitlements + CODE_SIGN_STYLE: "$(OPENCLAW_CODE_SIGN_STYLE)" + DEVELOPMENT_TEAM: "$(OPENCLAW_DEVELOPMENT_TEAM)" + PRODUCT_BUNDLE_IDENTIFIER: "$(OPENCLAW_APP_BUNDLE_ID)" + PROVISIONING_PROFILE_SPECIFIER: "$(OPENCLAW_APP_PROFILE)" SWIFT_VERSION: "6.0" SWIFT_STRICT_CONCURRENCY: complete ENABLE_APPINTENTS_METADATA: NO @@ -81,13 +88,20 @@ targets: properties: CFBundleDisplayName: OpenClaw CFBundleIconName: AppIcon - CFBundleShortVersionString: "2026.2.16" - CFBundleVersion: "20260216" + CFBundleURLTypes: + - CFBundleURLName: ai.openclaw.ios + CFBundleURLSchemes: + - openclaw + CFBundleShortVersionString: "2026.2.23" + CFBundleVersion: "20260223" UILaunchScreen: {} UIApplicationSceneManifest: UIApplicationSupportsMultipleScenes: false UIBackgroundModes: - audio + - remote-notification + BGTaskSchedulerPermittedIdentifiers: + - ai.openclaw.ios.bgrefresh NSLocalNetworkUsageDescription: OpenClaw discovers and connects to your OpenClaw gateway on the local network. NSAppTransportSecurity: NSAllowsArbitraryLoadsInWebContent: true @@ -109,6 +123,90 @@ targets: - UIInterfaceOrientationLandscapeLeft - UIInterfaceOrientationLandscapeRight + OpenClawShareExtension: + type: app-extension + platform: iOS + configFiles: + Debug: Signing.xcconfig + Release: Signing.xcconfig + sources: + - path: ShareExtension + dependencies: + - package: OpenClawKit + settings: + base: + CODE_SIGN_IDENTITY: "Apple Development" + CODE_SIGN_STYLE: "$(OPENCLAW_CODE_SIGN_STYLE)" + DEVELOPMENT_TEAM: "$(OPENCLAW_DEVELOPMENT_TEAM)" + PRODUCT_BUNDLE_IDENTIFIER: "$(OPENCLAW_SHARE_BUNDLE_ID)" + PROVISIONING_PROFILE_SPECIFIER: "$(OPENCLAW_SHARE_PROFILE)" + SWIFT_VERSION: "6.0" + SWIFT_STRICT_CONCURRENCY: complete + info: + path: ShareExtension/Info.plist + properties: + CFBundleDisplayName: OpenClaw Share + CFBundleShortVersionString: "2026.2.23" + CFBundleVersion: "20260223" + NSExtension: + NSExtensionPointIdentifier: com.apple.share-services + NSExtensionPrincipalClass: "$(PRODUCT_MODULE_NAME).ShareViewController" + NSExtensionAttributes: + NSExtensionActivationRule: + NSExtensionActivationSupportsText: true + NSExtensionActivationSupportsWebURLWithMaxCount: 1 + NSExtensionActivationSupportsImageWithMaxCount: 10 + NSExtensionActivationSupportsMovieWithMaxCount: 1 + + OpenClawWatchApp: + type: application.watchapp2 + platform: watchOS + deploymentTarget: "11.0" + sources: + - path: WatchApp + dependencies: + - target: OpenClawWatchExtension + configFiles: + Debug: Config/Signing.xcconfig + Release: Config/Signing.xcconfig + settings: + base: + PRODUCT_BUNDLE_IDENTIFIER: "$(OPENCLAW_WATCH_APP_BUNDLE_ID)" + info: + path: WatchApp/Info.plist + properties: + CFBundleDisplayName: OpenClaw + CFBundleShortVersionString: "2026.2.23" + CFBundleVersion: "20260223" + WKCompanionAppBundleIdentifier: "$(OPENCLAW_APP_BUNDLE_ID)" + WKWatchKitApp: true + + OpenClawWatchExtension: + type: watchkit2-extension + platform: watchOS + deploymentTarget: "11.0" + sources: + - path: WatchExtension/Sources + dependencies: + - sdk: WatchConnectivity.framework + - sdk: UserNotifications.framework + configFiles: + Debug: Config/Signing.xcconfig + Release: Config/Signing.xcconfig + settings: + base: + PRODUCT_BUNDLE_IDENTIFIER: "$(OPENCLAW_WATCH_EXTENSION_BUNDLE_ID)" + info: + path: WatchExtension/Info.plist + properties: + CFBundleDisplayName: OpenClaw + CFBundleShortVersionString: "2026.2.23" + CFBundleVersion: "20260223" + NSExtension: + NSExtensionAttributes: + WKAppBundleIdentifier: "$(OPENCLAW_WATCH_APP_BUNDLE_ID)" + NSExtensionPointIdentifier: com.apple.watchkit + OpenClawTests: type: bundle.unit-test platform: iOS @@ -130,5 +228,5 @@ targets: path: Tests/Info.plist properties: CFBundleDisplayName: OpenClawTests - CFBundleShortVersionString: "2026.2.16" - CFBundleVersion: "20260216" + CFBundleShortVersionString: "2026.2.23" + CFBundleVersion: "20260223" diff --git a/apps/macos/Sources/OpenClaw/AppState.swift b/apps/macos/Sources/OpenClaw/AppState.swift index d960d3c038a..e9ca6c35359 100644 --- a/apps/macos/Sources/OpenClaw/AppState.swift +++ b/apps/macos/Sources/OpenClaw/AppState.swift @@ -480,8 +480,7 @@ final class AppState { remote.removeValue(forKey: "url") remoteChanged = true } - } else { - let normalizedUrl = GatewayRemoteConfig.normalizeGatewayUrlString(trimmedUrl) ?? trimmedUrl + } else if let normalizedUrl = GatewayRemoteConfig.normalizeGatewayUrlString(trimmedUrl) { if (remote["url"] as? String) != normalizedUrl { remote["url"] = normalizedUrl remoteChanged = true diff --git a/apps/macos/Sources/OpenClaw/CameraCaptureService.swift b/apps/macos/Sources/OpenClaw/CameraCaptureService.swift index 24717ec5536..4e3749d6a68 100644 --- a/apps/macos/Sources/OpenClaw/CameraCaptureService.swift +++ b/apps/macos/Sources/OpenClaw/CameraCaptureService.swift @@ -357,8 +357,8 @@ private final class PhotoCaptureDelegate: NSObject, AVCapturePhotoCaptureDelegat func photoOutput( _ output: AVCapturePhotoOutput, didFinishProcessingPhoto photo: AVCapturePhoto, - error: Error? - ) { + error: Error?) + { guard !self.didResume, let cont else { return } self.didResume = true self.cont = nil @@ -380,8 +380,8 @@ private final class PhotoCaptureDelegate: NSObject, AVCapturePhotoCaptureDelegat func photoOutput( _ output: AVCapturePhotoOutput, didFinishCaptureFor resolvedSettings: AVCaptureResolvedPhotoSettings, - error: Error? - ) { + error: Error?) + { guard let error else { return } guard !self.didResume, let cont else { return } self.didResume = true diff --git a/apps/macos/Sources/OpenClaw/CoalescingFSEventsWatcher.swift b/apps/macos/Sources/OpenClaw/CoalescingFSEventsWatcher.swift index 7999123dbe2..f9e38d81170 100644 --- a/apps/macos/Sources/OpenClaw/CoalescingFSEventsWatcher.swift +++ b/apps/macos/Sources/OpenClaw/CoalescingFSEventsWatcher.swift @@ -16,8 +16,8 @@ final class CoalescingFSEventsWatcher: @unchecked Sendable { queueLabel: String, coalesceDelay: TimeInterval = 0.12, shouldNotify: @escaping (Int, UnsafeMutableRawPointer?) -> Bool = { _, _ in true }, - onChange: @escaping () -> Void - ) { + onChange: @escaping () -> Void) + { self.paths = paths self.queue = DispatchQueue(label: queueLabel) self.coalesceDelay = coalesceDelay @@ -92,8 +92,8 @@ extension CoalescingFSEventsWatcher { private func handleEvents( numEvents: Int, eventPaths: UnsafeMutableRawPointer?, - eventFlags: UnsafePointer? - ) { + eventFlags: UnsafePointer?) + { guard numEvents > 0 else { return } guard eventFlags != nil else { return } guard self.shouldNotify(numEvents, eventPaths) else { return } @@ -108,4 +108,3 @@ extension CoalescingFSEventsWatcher { } } } - diff --git a/apps/macos/Sources/OpenClaw/DeepLinks.swift b/apps/macos/Sources/OpenClaw/DeepLinks.swift index 61b7dcd8ae6..d11d4d524c3 100644 --- a/apps/macos/Sources/OpenClaw/DeepLinks.swift +++ b/apps/macos/Sources/OpenClaw/DeepLinks.swift @@ -67,6 +67,8 @@ final class DeepLinkHandler { switch route { case let .agent(link): await self.handleAgent(link: link, originalURL: url) + case .gateway: + break } } diff --git a/apps/macos/Sources/OpenClaw/ExecAllowlistMatcher.swift b/apps/macos/Sources/OpenClaw/ExecAllowlistMatcher.swift new file mode 100644 index 00000000000..2dd720741bb --- /dev/null +++ b/apps/macos/Sources/OpenClaw/ExecAllowlistMatcher.swift @@ -0,0 +1,79 @@ +import Foundation + +enum ExecAllowlistMatcher { + static func match(entries: [ExecAllowlistEntry], resolution: ExecCommandResolution?) -> ExecAllowlistEntry? { + guard let resolution, !entries.isEmpty else { return nil } + let rawExecutable = resolution.rawExecutable + let resolvedPath = resolution.resolvedPath + + for entry in entries { + switch ExecApprovalHelpers.validateAllowlistPattern(entry.pattern) { + case .valid(let pattern): + let target = resolvedPath ?? rawExecutable + if self.matches(pattern: pattern, target: target) { return entry } + case .invalid: + continue + } + } + return nil + } + + static func matchAll( + entries: [ExecAllowlistEntry], + resolutions: [ExecCommandResolution]) -> [ExecAllowlistEntry] + { + guard !entries.isEmpty, !resolutions.isEmpty else { return [] } + var matches: [ExecAllowlistEntry] = [] + matches.reserveCapacity(resolutions.count) + for resolution in resolutions { + guard let match = self.match(entries: entries, resolution: resolution) else { + return [] + } + matches.append(match) + } + return matches + } + + private static func matches(pattern: String, target: String) -> Bool { + let trimmed = pattern.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return false } + let expanded = trimmed.hasPrefix("~") ? (trimmed as NSString).expandingTildeInPath : trimmed + let normalizedPattern = self.normalizeMatchTarget(expanded) + let normalizedTarget = self.normalizeMatchTarget(target) + guard let regex = self.regex(for: normalizedPattern) else { return false } + let range = NSRange(location: 0, length: normalizedTarget.utf16.count) + return regex.firstMatch(in: normalizedTarget, options: [], range: range) != nil + } + + private static func normalizeMatchTarget(_ value: String) -> String { + value.replacingOccurrences(of: "\\\\", with: "/").lowercased() + } + + private static func regex(for pattern: String) -> NSRegularExpression? { + var regex = "^" + var idx = pattern.startIndex + while idx < pattern.endIndex { + let ch = pattern[idx] + if ch == "*" { + let next = pattern.index(after: idx) + if next < pattern.endIndex, pattern[next] == "*" { + regex += ".*" + idx = pattern.index(after: next) + } else { + regex += "[^/]*" + idx = next + } + continue + } + if ch == "?" { + regex += "." + idx = pattern.index(after: idx) + continue + } + regex += NSRegularExpression.escapedPattern(for: String(ch)) + idx = pattern.index(after: idx) + } + regex += "$" + return try? NSRegularExpression(pattern: regex, options: [.caseInsensitive]) + } +} diff --git a/apps/macos/Sources/OpenClaw/ExecApprovalEvaluation.swift b/apps/macos/Sources/OpenClaw/ExecApprovalEvaluation.swift new file mode 100644 index 00000000000..c7d9d0928e1 --- /dev/null +++ b/apps/macos/Sources/OpenClaw/ExecApprovalEvaluation.swift @@ -0,0 +1,68 @@ +import Foundation + +struct ExecApprovalEvaluation { + let command: [String] + let displayCommand: String + let agentId: String? + let security: ExecSecurity + let ask: ExecAsk + let env: [String: String] + let resolution: ExecCommandResolution? + let allowlistResolutions: [ExecCommandResolution] + let allowlistMatches: [ExecAllowlistEntry] + let allowlistSatisfied: Bool + let allowlistMatch: ExecAllowlistEntry? + let skillAllow: Bool +} + +enum ExecApprovalEvaluator { + static func evaluate( + command: [String], + rawCommand: String?, + cwd: String?, + envOverrides: [String: String]?, + agentId: String?) async -> ExecApprovalEvaluation + { + let trimmedAgent = agentId?.trimmingCharacters(in: .whitespacesAndNewlines) + let normalizedAgentId = (trimmedAgent?.isEmpty == false) ? trimmedAgent : nil + let approvals = ExecApprovalsStore.resolve(agentId: normalizedAgentId) + let security = approvals.agent.security + let ask = approvals.agent.ask + let shellWrapper = ExecShellWrapperParser.extract(command: command, rawCommand: rawCommand).isWrapper + let env = HostEnvSanitizer.sanitize(overrides: envOverrides, shellWrapper: shellWrapper) + let displayCommand = ExecCommandFormatter.displayString(for: command, rawCommand: rawCommand) + let allowlistResolutions = ExecCommandResolution.resolveForAllowlist( + command: command, + rawCommand: rawCommand, + cwd: cwd, + env: env) + let allowlistMatches = security == .allowlist + ? ExecAllowlistMatcher.matchAll(entries: approvals.allowlist, resolutions: allowlistResolutions) + : [] + let allowlistSatisfied = security == .allowlist && + !allowlistResolutions.isEmpty && + allowlistMatches.count == allowlistResolutions.count + + let skillAllow: Bool + if approvals.agent.autoAllowSkills, !allowlistResolutions.isEmpty { + let bins = await SkillBinsCache.shared.currentBins() + skillAllow = allowlistResolutions.allSatisfy { bins.contains($0.executableName) } + } else { + skillAllow = false + } + + return ExecApprovalEvaluation( + command: command, + displayCommand: displayCommand, + agentId: normalizedAgentId, + security: security, + ask: ask, + env: env, + resolution: allowlistResolutions.first, + allowlistResolutions: allowlistResolutions, + allowlistMatches: allowlistMatches, + allowlistSatisfied: allowlistSatisfied, + allowlistMatch: allowlistSatisfied ? allowlistMatches.first : nil, + skillAllow: skillAllow) + } +} diff --git a/apps/macos/Sources/OpenClaw/ExecApprovals.swift b/apps/macos/Sources/OpenClaw/ExecApprovals.swift index f6bc8392503..08567cd0b09 100644 --- a/apps/macos/Sources/OpenClaw/ExecApprovals.swift +++ b/apps/macos/Sources/OpenClaw/ExecApprovals.swift @@ -90,6 +90,31 @@ enum ExecApprovalDecision: String, Codable, Sendable { case deny } +enum ExecAllowlistPatternValidationReason: String, Codable, Sendable, Equatable { + case empty + case missingPathComponent + + var message: String { + switch self { + case .empty: + "Pattern cannot be empty." + case .missingPathComponent: + "Path patterns only. Include '/', '~', or '\\\\'." + } + } +} + +enum ExecAllowlistPatternValidation: Sendable, Equatable { + case valid(String) + case invalid(ExecAllowlistPatternValidationReason) +} + +struct ExecAllowlistRejectedEntry: Sendable, Equatable { + let id: UUID + let pattern: String + let reason: ExecAllowlistPatternValidationReason +} + struct ExecAllowlistEntry: Codable, Hashable, Identifiable { var id: UUID var pattern: String @@ -222,13 +247,25 @@ enum ExecApprovalsStore { } agents.removeValue(forKey: "default") } + if !agents.isEmpty { + var normalizedAgents: [String: ExecApprovalsAgent] = [:] + normalizedAgents.reserveCapacity(agents.count) + for (key, var agent) in agents { + if let allowlist = agent.allowlist { + let normalized = self.normalizeAllowlistEntries(allowlist, dropInvalid: false).entries + agent.allowlist = normalized.isEmpty ? nil : normalized + } + normalizedAgents[key] = agent + } + agents = normalizedAgents + } return ExecApprovalsFile( version: 1, socket: ExecApprovalsSocketConfig( path: socketPath.isEmpty ? nil : socketPath, token: token.isEmpty ? nil : token), defaults: file.defaults, - agents: agents) + agents: agents.isEmpty ? nil : agents) } static func readSnapshot() -> ExecApprovalsSnapshot { @@ -306,7 +343,12 @@ enum ExecApprovalsStore { } static func ensureFile() -> ExecApprovalsFile { - var file = self.loadFile() + let url = self.fileURL() + let existed = FileManager().fileExists(atPath: url.path) + let loaded = self.loadFile() + let loadedHash = self.hashFile(loaded) + + var file = self.normalizeIncoming(loaded) if file.socket == nil { file.socket = ExecApprovalsSocketConfig(path: nil, token: nil) } let path = file.socket?.path?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" if path.isEmpty { @@ -317,7 +359,9 @@ enum ExecApprovalsStore { file.socket?.token = self.generateToken() } if file.agents == nil { file.agents = [:] } - self.saveFile(file) + if !existed || loadedHash != self.hashFile(file) { + self.saveFile(file) + } return file } @@ -339,16 +383,9 @@ enum ExecApprovalsStore { ?? resolvedDefaults.askFallback, autoAllowSkills: agentEntry.autoAllowSkills ?? wildcardEntry.autoAllowSkills ?? resolvedDefaults.autoAllowSkills) - let allowlist = ((wildcardEntry.allowlist ?? []) + (agentEntry.allowlist ?? [])) - .map { entry in - ExecAllowlistEntry( - id: entry.id, - pattern: entry.pattern.trimmingCharacters(in: .whitespacesAndNewlines), - lastUsedAt: entry.lastUsedAt, - lastUsedCommand: entry.lastUsedCommand, - lastResolvedPath: entry.lastResolvedPath) - } - .filter { !$0.pattern.isEmpty } + let allowlist = self.normalizeAllowlistEntries( + (wildcardEntry.allowlist ?? []) + (agentEntry.allowlist ?? []), + dropInvalid: true).entries let socketPath = self.expandPath(file.socket?.path ?? self.socketPath()) let token = file.socket?.token ?? "" return ExecApprovalsResolved( @@ -398,20 +435,30 @@ enum ExecApprovalsStore { } } - static func addAllowlistEntry(agentId: String?, pattern: String) { - let trimmed = pattern.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty else { return } + @discardableResult + static func addAllowlistEntry(agentId: String?, pattern: String) -> ExecAllowlistPatternValidationReason? { + let normalizedPattern: String + switch ExecApprovalHelpers.validateAllowlistPattern(pattern) { + case .valid(let validPattern): + normalizedPattern = validPattern + case .invalid(let reason): + return reason + } + self.updateFile { file in let key = self.agentKey(agentId) var agents = file.agents ?? [:] var entry = agents[key] ?? ExecApprovalsAgent() var allowlist = entry.allowlist ?? [] - if allowlist.contains(where: { $0.pattern == trimmed }) { return } - allowlist.append(ExecAllowlistEntry(pattern: trimmed, lastUsedAt: Date().timeIntervalSince1970 * 1000)) + if allowlist.contains(where: { $0.pattern == normalizedPattern }) { return } + allowlist.append(ExecAllowlistEntry( + pattern: normalizedPattern, + lastUsedAt: Date().timeIntervalSince1970 * 1000)) entry.allowlist = allowlist agents[key] = entry file.agents = agents } + return nil } static func recordAllowlistUse( @@ -439,25 +486,21 @@ enum ExecApprovalsStore { } } - static func updateAllowlist(agentId: String?, allowlist: [ExecAllowlistEntry]) { + @discardableResult + static func updateAllowlist(agentId: String?, allowlist: [ExecAllowlistEntry]) -> [ExecAllowlistRejectedEntry] { + var rejected: [ExecAllowlistRejectedEntry] = [] self.updateFile { file in let key = self.agentKey(agentId) var agents = file.agents ?? [:] var entry = agents[key] ?? ExecApprovalsAgent() - let cleaned = allowlist - .map { item in - ExecAllowlistEntry( - id: item.id, - pattern: item.pattern.trimmingCharacters(in: .whitespacesAndNewlines), - lastUsedAt: item.lastUsedAt, - lastUsedCommand: item.lastUsedCommand, - lastResolvedPath: item.lastResolvedPath) - } - .filter { !$0.pattern.isEmpty } + let normalized = self.normalizeAllowlistEntries(allowlist, dropInvalid: true) + rejected = normalized.rejected + let cleaned = normalized.entries entry.allowlist = cleaned agents[key] = entry file.agents = agents } + return rejected } static func updateAgentSettings(agentId: String?, mutate: (inout ExecApprovalsAgent) -> Void) { @@ -500,6 +543,14 @@ enum ExecApprovalsStore { return digest.map { String(format: "%02x", $0) }.joined() } + private static func hashFile(_ file: ExecApprovalsFile) -> String { + let encoder = JSONEncoder() + encoder.outputFormatting = [.sortedKeys] + let data = (try? encoder.encode(file)) ?? Data() + let digest = SHA256.hash(data: data) + return digest.map { String(format: "%02x", $0) }.joined() + } + private static func expandPath(_ raw: String) -> String { let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) if trimmed == "~" { @@ -519,14 +570,101 @@ enum ExecApprovalsStore { } private static func normalizedPattern(_ pattern: String?) -> String? { - let trimmed = pattern?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" - return trimmed.isEmpty ? nil : trimmed.lowercased() + switch ExecApprovalHelpers.validateAllowlistPattern(pattern) { + case .valid(let normalized): + return normalized.lowercased() + case .invalid(.empty): + return nil + case .invalid: + let trimmed = pattern?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + return trimmed.isEmpty ? nil : trimmed.lowercased() + } + } + + private static func migrateLegacyPattern(_ entry: ExecAllowlistEntry) -> ExecAllowlistEntry { + let trimmedPattern = entry.pattern.trimmingCharacters(in: .whitespacesAndNewlines) + let trimmedResolved = entry.lastResolvedPath?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + let normalizedResolved = trimmedResolved.isEmpty ? nil : trimmedResolved + + switch ExecApprovalHelpers.validateAllowlistPattern(trimmedPattern) { + case .valid(let pattern): + return ExecAllowlistEntry( + id: entry.id, + pattern: pattern, + lastUsedAt: entry.lastUsedAt, + lastUsedCommand: entry.lastUsedCommand, + lastResolvedPath: normalizedResolved) + case .invalid: + switch ExecApprovalHelpers.validateAllowlistPattern(trimmedResolved) { + case .valid(let migratedPattern): + return ExecAllowlistEntry( + id: entry.id, + pattern: migratedPattern, + lastUsedAt: entry.lastUsedAt, + lastUsedCommand: entry.lastUsedCommand, + lastResolvedPath: normalizedResolved) + case .invalid: + return ExecAllowlistEntry( + id: entry.id, + pattern: trimmedPattern, + lastUsedAt: entry.lastUsedAt, + lastUsedCommand: entry.lastUsedCommand, + lastResolvedPath: normalizedResolved) + } + } + } + + private static func normalizeAllowlistEntries( + _ entries: [ExecAllowlistEntry], + dropInvalid: Bool) -> (entries: [ExecAllowlistEntry], rejected: [ExecAllowlistRejectedEntry]) + { + var normalized: [ExecAllowlistEntry] = [] + normalized.reserveCapacity(entries.count) + var rejected: [ExecAllowlistRejectedEntry] = [] + + for entry in entries { + let migrated = self.migrateLegacyPattern(entry) + let trimmedPattern = migrated.pattern.trimmingCharacters(in: .whitespacesAndNewlines) + let trimmedResolvedPath = migrated.lastResolvedPath?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + let normalizedResolvedPath = trimmedResolvedPath.isEmpty ? nil : trimmedResolvedPath + + switch ExecApprovalHelpers.validateAllowlistPattern(trimmedPattern) { + case .valid(let pattern): + normalized.append( + ExecAllowlistEntry( + id: migrated.id, + pattern: pattern, + lastUsedAt: migrated.lastUsedAt, + lastUsedCommand: migrated.lastUsedCommand, + lastResolvedPath: normalizedResolvedPath)) + case .invalid(let reason): + if dropInvalid { + rejected.append( + ExecAllowlistRejectedEntry( + id: migrated.id, + pattern: trimmedPattern, + reason: reason)) + } else if reason != .empty { + normalized.append( + ExecAllowlistEntry( + id: migrated.id, + pattern: trimmedPattern, + lastUsedAt: migrated.lastUsedAt, + lastUsedCommand: migrated.lastUsedCommand, + lastResolvedPath: normalizedResolvedPath)) + } + } + } + + return (normalized, rejected) } private static func mergeAgents( current: ExecApprovalsAgent, legacy: ExecApprovalsAgent) -> ExecApprovalsAgent { + let currentAllowlist = self.normalizeAllowlistEntries(current.allowlist ?? [], dropInvalid: false).entries + let legacyAllowlist = self.normalizeAllowlistEntries(legacy.allowlist ?? [], dropInvalid: false).entries var seen = Set() var allowlist: [ExecAllowlistEntry] = [] func append(_ entry: ExecAllowlistEntry) { @@ -536,10 +674,10 @@ enum ExecApprovalsStore { seen.insert(key) allowlist.append(entry) } - for entry in current.allowlist ?? [] { + for entry in currentAllowlist { append(entry) } - for entry in legacy.allowlist ?? [] { + for entry in legacyAllowlist { append(entry) } @@ -552,102 +690,23 @@ enum ExecApprovalsStore { } } -struct ExecCommandResolution: Sendable { - let rawExecutable: String - let resolvedPath: String? - let executableName: String - let cwd: String? - - static func resolve( - command: [String], - rawCommand: String?, - cwd: String?, - env: [String: String]?) -> ExecCommandResolution? - { - let trimmedRaw = rawCommand?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" - if !trimmedRaw.isEmpty, let token = self.parseFirstToken(trimmedRaw) { - return self.resolveExecutable(rawExecutable: token, cwd: cwd, env: env) - } - return self.resolve(command: command, cwd: cwd, env: env) - } - - static func resolve(command: [String], cwd: String?, env: [String: String]?) -> ExecCommandResolution? { - guard let raw = command.first?.trimmingCharacters(in: .whitespacesAndNewlines), !raw.isEmpty else { - return nil - } - return self.resolveExecutable(rawExecutable: raw, cwd: cwd, env: env) - } - - private static func resolveExecutable( - rawExecutable: String, - cwd: String?, - env: [String: String]?) -> ExecCommandResolution? - { - let expanded = rawExecutable.hasPrefix("~") ? (rawExecutable as NSString).expandingTildeInPath : rawExecutable - let hasPathSeparator = expanded.contains("/") || expanded.contains("\\") - let resolvedPath: String? = { - if hasPathSeparator { - if expanded.hasPrefix("/") { - return expanded - } - let base = cwd?.trimmingCharacters(in: .whitespacesAndNewlines) - let root = (base?.isEmpty == false) ? base! : FileManager().currentDirectoryPath - return URL(fileURLWithPath: root).appendingPathComponent(expanded).path - } - let searchPaths = self.searchPaths(from: env) - return CommandResolver.findExecutable(named: expanded, searchPaths: searchPaths) - }() - let name = resolvedPath.map { URL(fileURLWithPath: $0).lastPathComponent } ?? expanded - return ExecCommandResolution( - rawExecutable: expanded, - resolvedPath: resolvedPath, - executableName: name, - cwd: cwd) - } - - private static func parseFirstToken(_ command: String) -> String? { - let trimmed = command.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty else { return nil } - guard let first = trimmed.first else { return nil } - if first == "\"" || first == "'" { - let rest = trimmed.dropFirst() - if let end = rest.firstIndex(of: first) { - return String(rest[.. [String] { - let raw = env?["PATH"] - if let raw, !raw.isEmpty { - return raw.split(separator: ":").map(String.init) - } - return CommandResolver.preferredPaths() - } -} - -enum ExecCommandFormatter { - static func displayString(for argv: [String]) -> String { - argv.map { arg in - let trimmed = arg.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty else { return "\"\"" } - let needsQuotes = trimmed.contains { $0.isWhitespace || $0 == "\"" } - if !needsQuotes { return trimmed } - let escaped = trimmed.replacingOccurrences(of: "\"", with: "\\\"") - return "\"\(escaped)\"" - }.joined(separator: " ") - } - - static func displayString(for argv: [String], rawCommand: String?) -> String { - let trimmed = rawCommand?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" - if !trimmed.isEmpty { return trimmed } - return self.displayString(for: argv) - } -} - enum ExecApprovalHelpers { + static func validateAllowlistPattern(_ pattern: String?) -> ExecAllowlistPatternValidation { + let trimmed = pattern?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + guard !trimmed.isEmpty else { return .invalid(.empty) } + guard self.containsPathComponent(trimmed) else { return .invalid(.missingPathComponent) } + return .valid(trimmed) + } + + static func isPathPattern(_ pattern: String?) -> Bool { + switch self.validateAllowlistPattern(pattern) { + case .valid: + true + case .invalid: + false + } + } + static func parseDecision(_ raw: String?) -> ExecApprovalDecision? { let trimmed = raw?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" guard !trimmed.isEmpty else { return nil } @@ -669,70 +728,9 @@ enum ExecApprovalHelpers { let pattern = resolution?.resolvedPath ?? resolution?.rawExecutable ?? command.first ?? "" return pattern.isEmpty ? nil : pattern } -} -enum ExecAllowlistMatcher { - static func match(entries: [ExecAllowlistEntry], resolution: ExecCommandResolution?) -> ExecAllowlistEntry? { - guard let resolution, !entries.isEmpty else { return nil } - let rawExecutable = resolution.rawExecutable - let resolvedPath = resolution.resolvedPath - let executableName = resolution.executableName - - for entry in entries { - let pattern = entry.pattern.trimmingCharacters(in: .whitespacesAndNewlines) - if pattern.isEmpty { continue } - let hasPath = pattern.contains("/") || pattern.contains("~") || pattern.contains("\\") - if hasPath { - let target = resolvedPath ?? rawExecutable - if self.matches(pattern: pattern, target: target) { return entry } - } else if self.matches(pattern: pattern, target: executableName) { - return entry - } - } - return nil - } - - private static func matches(pattern: String, target: String) -> Bool { - let trimmed = pattern.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty else { return false } - let expanded = trimmed.hasPrefix("~") ? (trimmed as NSString).expandingTildeInPath : trimmed - let normalizedPattern = self.normalizeMatchTarget(expanded) - let normalizedTarget = self.normalizeMatchTarget(target) - guard let regex = self.regex(for: normalizedPattern) else { return false } - let range = NSRange(location: 0, length: normalizedTarget.utf16.count) - return regex.firstMatch(in: normalizedTarget, options: [], range: range) != nil - } - - private static func normalizeMatchTarget(_ value: String) -> String { - value.replacingOccurrences(of: "\\\\", with: "/").lowercased() - } - - private static func regex(for pattern: String) -> NSRegularExpression? { - var regex = "^" - var idx = pattern.startIndex - while idx < pattern.endIndex { - let ch = pattern[idx] - if ch == "*" { - let next = pattern.index(after: idx) - if next < pattern.endIndex, pattern[next] == "*" { - regex += ".*" - idx = pattern.index(after: next) - } else { - regex += "[^/]*" - idx = next - } - continue - } - if ch == "?" { - regex += "." - idx = pattern.index(after: idx) - continue - } - regex += NSRegularExpression.escapedPattern(for: String(ch)) - idx = pattern.index(after: idx) - } - regex += "$" - return try? NSRegularExpression(pattern: regex, options: [.caseInsensitive]) + private static func containsPathComponent(_ pattern: String) -> Bool { + pattern.contains("/") || pattern.contains("~") || pattern.contains("\\") } } diff --git a/apps/macos/Sources/OpenClaw/ExecApprovalsSocket.swift b/apps/macos/Sources/OpenClaw/ExecApprovalsSocket.swift index e1432aaea1c..362a7da01d8 100644 --- a/apps/macos/Sources/OpenClaw/ExecApprovalsSocket.swift +++ b/apps/macos/Sources/OpenClaw/ExecApprovalsSocket.swift @@ -350,34 +350,7 @@ enum ExecApprovalsPromptPresenter { @MainActor private enum ExecHostExecutor { - private struct ExecApprovalContext { - let command: [String] - let displayCommand: String - let trimmedAgent: String? - let approvals: ExecApprovalsResolved - let security: ExecSecurity - let ask: ExecAsk - let autoAllowSkills: Bool - let env: [String: String]? - let resolution: ExecCommandResolution? - let allowlistMatch: ExecAllowlistEntry? - let skillAllow: Bool - } - - private static let blockedEnvKeys: Set = [ - "PATH", - "NODE_OPTIONS", - "PYTHONHOME", - "PYTHONPATH", - "PERL5LIB", - "PERL5OPT", - "RUBYOPT", - ] - - private static let blockedEnvPrefixes: [String] = [ - "DYLD_", - "LD_", - ] + private typealias ExecApprovalContext = ExecApprovalEvaluation static func handle(_ request: ExecHostRequest) async -> ExecHostResponse { let command = request.command.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } @@ -419,7 +392,7 @@ private enum ExecHostExecutor { host: "node", security: context.security.rawValue, ask: context.ask.rawValue, - agentId: context.trimmedAgent, + agentId: context.agentId, resolvedPath: context.resolution?.resolvedPath, sessionKey: request.sessionKey)) @@ -440,7 +413,7 @@ private enum ExecHostExecutor { self.persistAllowlistEntry(decision: approvalDecision, context: context) if context.security == .allowlist, - context.allowlistMatch == nil, + !context.allowlistSatisfied, !context.skillAllow, !approvedByAsk { @@ -450,12 +423,21 @@ private enum ExecHostExecutor { reason: "allowlist-miss") } - if let match = context.allowlistMatch { - ExecApprovalsStore.recordAllowlistUse( - agentId: context.trimmedAgent, - pattern: match.pattern, - command: context.displayCommand, - resolvedPath: context.resolution?.resolvedPath) + if context.allowlistSatisfied { + var seenPatterns = Set() + for (idx, match) in context.allowlistMatches.enumerated() { + if !seenPatterns.insert(match.pattern).inserted { + continue + } + let resolvedPath = idx < context.allowlistResolutions.count + ? context.allowlistResolutions[idx].resolvedPath + : nil + ExecApprovalsStore.recordAllowlistUse( + agentId: context.agentId, + pattern: match.pattern, + command: context.displayCommand, + resolvedPath: resolvedPath) + } } if let errorResponse = await self.ensureScreenRecordingAccess(request.needsScreenRecording) { @@ -470,43 +452,12 @@ private enum ExecHostExecutor { } private static func buildContext(request: ExecHostRequest, command: [String]) async -> ExecApprovalContext { - let displayCommand = ExecCommandFormatter.displayString( - for: command, - rawCommand: request.rawCommand) - let agentId = request.agentId?.trimmingCharacters(in: .whitespacesAndNewlines) - let trimmedAgent = (agentId?.isEmpty == false) ? agentId : nil - let approvals = ExecApprovalsStore.resolve(agentId: trimmedAgent) - let security = approvals.agent.security - let ask = approvals.agent.ask - let autoAllowSkills = approvals.agent.autoAllowSkills - let env = self.sanitizedEnv(request.env) - let resolution = ExecCommandResolution.resolve( + await ExecApprovalEvaluator.evaluate( command: command, rawCommand: request.rawCommand, cwd: request.cwd, - env: env) - let allowlistMatch = security == .allowlist - ? ExecAllowlistMatcher.match(entries: approvals.allowlist, resolution: resolution) - : nil - let skillAllow: Bool - if autoAllowSkills, let name = resolution?.executableName { - let bins = await SkillBinsCache.shared.currentBins() - skillAllow = bins.contains(name) - } else { - skillAllow = false - } - return ExecApprovalContext( - command: command, - displayCommand: displayCommand, - trimmedAgent: trimmedAgent, - approvals: approvals, - security: security, - ask: ask, - autoAllowSkills: autoAllowSkills, - env: env, - resolution: resolution, - allowlistMatch: allowlistMatch, - skillAllow: skillAllow) + envOverrides: request.env, + agentId: request.agentId) } private static func persistAllowlistEntry( @@ -514,13 +465,18 @@ private enum ExecHostExecutor { context: ExecApprovalContext) { guard decision == .allowAlways, context.security == .allowlist else { return } - guard let pattern = ExecApprovalHelpers.allowlistPattern( - command: context.command, - resolution: context.resolution) - else { - return + var seenPatterns = Set() + for candidate in context.allowlistResolutions { + guard let pattern = ExecApprovalHelpers.allowlistPattern( + command: context.command, + resolution: candidate) + else { + continue + } + if seenPatterns.insert(pattern).inserted { + ExecApprovalsStore.addAllowlistEntry(agentId: context.agentId, pattern: pattern) + } } - ExecApprovalsStore.addAllowlistEntry(agentId: context.trimmedAgent, pattern: pattern) } private static func ensureScreenRecordingAccess(_ needsScreenRecording: Bool?) async -> ExecHostResponse? { @@ -579,20 +535,6 @@ private enum ExecHostExecutor { payload: payload, error: nil) } - - private static func sanitizedEnv(_ overrides: [String: String]?) -> [String: String]? { - guard let overrides else { return nil } - var merged = ProcessInfo.processInfo.environment - for (rawKey, value) in overrides { - let key = rawKey.trimmingCharacters(in: .whitespacesAndNewlines) - guard !key.isEmpty else { continue } - let upper = key.uppercased() - if self.blockedEnvKeys.contains(upper) { continue } - if self.blockedEnvPrefixes.contains(where: { upper.hasPrefix($0) }) { continue } - merged[key] = value - } - return merged - } } private final class ExecApprovalsSocketServer: @unchecked Sendable { diff --git a/apps/macos/Sources/OpenClaw/ExecCommandResolution.swift b/apps/macos/Sources/OpenClaw/ExecCommandResolution.swift new file mode 100644 index 00000000000..843062b2470 --- /dev/null +++ b/apps/macos/Sources/OpenClaw/ExecCommandResolution.swift @@ -0,0 +1,265 @@ +import Foundation + +struct ExecCommandResolution: Sendable { + let rawExecutable: String + let resolvedPath: String? + let executableName: String + let cwd: String? + + static func resolve( + command: [String], + rawCommand: String?, + cwd: String?, + env: [String: String]?) -> ExecCommandResolution? + { + let trimmedRaw = rawCommand?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + if !trimmedRaw.isEmpty, let token = self.parseFirstToken(trimmedRaw) { + return self.resolveExecutable(rawExecutable: token, cwd: cwd, env: env) + } + return self.resolve(command: command, cwd: cwd, env: env) + } + + static func resolveForAllowlist( + command: [String], + rawCommand: String?, + cwd: String?, + env: [String: String]?) -> [ExecCommandResolution] + { + let shell = ExecShellWrapperParser.extract(command: command, rawCommand: rawCommand) + if shell.isWrapper { + guard let shellCommand = shell.command, + let segments = self.splitShellCommandChain(shellCommand) + else { + // Fail closed: if we cannot safely parse a shell wrapper payload, + // treat this as an allowlist miss and require approval. + return [] + } + var resolutions: [ExecCommandResolution] = [] + resolutions.reserveCapacity(segments.count) + for segment in segments { + guard let token = self.parseFirstToken(segment), + let resolution = self.resolveExecutable(rawExecutable: token, cwd: cwd, env: env) + else { + return [] + } + resolutions.append(resolution) + } + return resolutions + } + + guard let resolution = self.resolve(command: command, rawCommand: rawCommand, cwd: cwd, env: env) else { + return [] + } + return [resolution] + } + + static func resolve(command: [String], cwd: String?, env: [String: String]?) -> ExecCommandResolution? { + let effective = ExecEnvInvocationUnwrapper.unwrapDispatchWrappersForResolution(command) + guard let raw = effective.first?.trimmingCharacters(in: .whitespacesAndNewlines), !raw.isEmpty else { + return nil + } + return self.resolveExecutable(rawExecutable: raw, cwd: cwd, env: env) + } + + private static func resolveExecutable( + rawExecutable: String, + cwd: String?, + env: [String: String]?) -> ExecCommandResolution? + { + let expanded = rawExecutable.hasPrefix("~") ? (rawExecutable as NSString).expandingTildeInPath : rawExecutable + let hasPathSeparator = expanded.contains("/") || expanded.contains("\\") + let resolvedPath: String? = { + if hasPathSeparator { + if expanded.hasPrefix("/") { + return expanded + } + let base = cwd?.trimmingCharacters(in: .whitespacesAndNewlines) + let root = (base?.isEmpty == false) ? base! : FileManager().currentDirectoryPath + return URL(fileURLWithPath: root).appendingPathComponent(expanded).path + } + let searchPaths = self.searchPaths(from: env) + return CommandResolver.findExecutable(named: expanded, searchPaths: searchPaths) + }() + let name = resolvedPath.map { URL(fileURLWithPath: $0).lastPathComponent } ?? expanded + return ExecCommandResolution( + rawExecutable: expanded, + resolvedPath: resolvedPath, + executableName: name, + cwd: cwd) + } + + private static func parseFirstToken(_ command: String) -> String? { + let trimmed = command.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return nil } + guard let first = trimmed.first else { return nil } + if first == "\"" || first == "'" { + let rest = trimmed.dropFirst() + if let end = rest.firstIndex(of: first) { + return String(rest[..", next: "("), + ], + .doubleQuoted: [ + ShellFailClosedRule(token: "`", next: nil), + ShellFailClosedRule(token: "$", next: "("), + ], + ] + + private static func splitShellCommandChain(_ command: String) -> [String]? { + let trimmed = command.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return nil } + + var segments: [String] = [] + var current = "" + var inSingle = false + var inDouble = false + var escaped = false + let chars = Array(trimmed) + var idx = 0 + + func appendCurrent() -> Bool { + let segment = current.trimmingCharacters(in: .whitespacesAndNewlines) + guard !segment.isEmpty else { return false } + segments.append(segment) + current.removeAll(keepingCapacity: true) + return true + } + + while idx < chars.count { + let ch = chars[idx] + let next: Character? = idx + 1 < chars.count ? chars[idx + 1] : nil + + if escaped { + current.append(ch) + escaped = false + idx += 1 + continue + } + + if ch == "\\", !inSingle { + current.append(ch) + escaped = true + idx += 1 + continue + } + + if ch == "'", !inDouble { + inSingle.toggle() + current.append(ch) + idx += 1 + continue + } + + if ch == "\"", !inSingle { + inDouble.toggle() + current.append(ch) + idx += 1 + continue + } + + if !inSingle, self.shouldFailClosedForShell(ch: ch, next: next, inDouble: inDouble) { + // Fail closed on command/process substitution in allowlist mode, + // including command substitution inside double-quoted shell strings. + return nil + } + + if !inSingle, !inDouble { + let prev: Character? = idx > 0 ? chars[idx - 1] : nil + if let delimiterStep = self.chainDelimiterStep(ch: ch, prev: prev, next: next) { + guard appendCurrent() else { return nil } + idx += delimiterStep + continue + } + } + + current.append(ch) + idx += 1 + } + + if escaped || inSingle || inDouble { return nil } + guard appendCurrent() else { return nil } + return segments + } + + private static func shouldFailClosedForShell(ch: Character, next: Character?, inDouble: Bool) -> Bool { + let context: ShellTokenContext = inDouble ? .doubleQuoted : .unquoted + guard let rules = self.shellFailClosedRules[context] else { + return false + } + for rule in rules { + if ch == rule.token, rule.next == nil || next == rule.next { + return true + } + } + return false + } + + private static func chainDelimiterStep(ch: Character, prev: Character?, next: Character?) -> Int? { + if ch == ";" || ch == "\n" { + return 1 + } + if ch == "&" { + if next == "&" { + return 2 + } + // Keep fd redirections like 2>&1 or &>file intact. + let prevIsRedirect = prev == ">" + let nextIsRedirect = next == ">" + return (!prevIsRedirect && !nextIsRedirect) ? 1 : nil + } + if ch == "|" { + if next == "|" || next == "&" { + return 2 + } + return 1 + } + return nil + } + + private static func searchPaths(from env: [String: String]?) -> [String] { + let raw = env?["PATH"] + if let raw, !raw.isEmpty { + return raw.split(separator: ":").map(String.init) + } + return CommandResolver.preferredPaths() + } +} + +enum ExecCommandFormatter { + static func displayString(for argv: [String]) -> String { + argv.map { arg in + let trimmed = arg.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return "\"\"" } + let needsQuotes = trimmed.contains { $0.isWhitespace || $0 == "\"" } + if !needsQuotes { return trimmed } + let escaped = trimmed.replacingOccurrences(of: "\"", with: "\\\"") + return "\"\(escaped)\"" + }.joined(separator: " ") + } + + static func displayString(for argv: [String], rawCommand: String?) -> String { + let trimmed = rawCommand?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + if !trimmed.isEmpty { return trimmed } + return self.displayString(for: argv) + } +} diff --git a/apps/macos/Sources/OpenClaw/ExecEnvInvocationUnwrapper.swift b/apps/macos/Sources/OpenClaw/ExecEnvInvocationUnwrapper.swift new file mode 100644 index 00000000000..ebb8965e755 --- /dev/null +++ b/apps/macos/Sources/OpenClaw/ExecEnvInvocationUnwrapper.swift @@ -0,0 +1,108 @@ +import Foundation + +enum ExecCommandToken { + static func basenameLower(_ token: String) -> String { + let trimmed = token.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return "" } + let normalized = trimmed.replacingOccurrences(of: "\\", with: "/") + return normalized.split(separator: "/").last.map { String($0).lowercased() } ?? normalized.lowercased() + } +} + +enum ExecEnvInvocationUnwrapper { + static let maxWrapperDepth = 4 + + private static let optionsWithValue = Set([ + "-u", + "--unset", + "-c", + "--chdir", + "-s", + "--split-string", + "--default-signal", + "--ignore-signal", + "--block-signal", + ]) + private static let flagOptions = Set(["-i", "--ignore-environment", "-0", "--null"]) + + private static func isEnvAssignment(_ token: String) -> Bool { + let pattern = #"^[A-Za-z_][A-Za-z0-9_]*=.*"# + return token.range(of: pattern, options: .regularExpression) != nil + } + + static func unwrap(_ command: [String]) -> [String]? { + var idx = 1 + var expectsOptionValue = false + while idx < command.count { + let token = command[idx].trimmingCharacters(in: .whitespacesAndNewlines) + if token.isEmpty { + idx += 1 + continue + } + if expectsOptionValue { + expectsOptionValue = false + idx += 1 + continue + } + if token == "--" || token == "-" { + idx += 1 + break + } + if self.isEnvAssignment(token) { + idx += 1 + continue + } + if token.hasPrefix("-"), token != "-" { + let lower = token.lowercased() + let flag = lower.split(separator: "=", maxSplits: 1).first.map(String.init) ?? lower + if self.flagOptions.contains(flag) { + idx += 1 + continue + } + if self.optionsWithValue.contains(flag) { + if !lower.contains("=") { + expectsOptionValue = true + } + idx += 1 + continue + } + if lower.hasPrefix("-u") || + lower.hasPrefix("-c") || + lower.hasPrefix("-s") || + lower.hasPrefix("--unset=") || + lower.hasPrefix("--chdir=") || + lower.hasPrefix("--split-string=") || + lower.hasPrefix("--default-signal=") || + lower.hasPrefix("--ignore-signal=") || + lower.hasPrefix("--block-signal=") + { + idx += 1 + continue + } + return nil + } + break + } + guard idx < command.count else { return nil } + return Array(command[idx...]) + } + + static func unwrapDispatchWrappersForResolution(_ command: [String]) -> [String] { + var current = command + var depth = 0 + while depth < self.maxWrapperDepth { + guard let token = current.first?.trimmingCharacters(in: .whitespacesAndNewlines), !token.isEmpty else { + break + } + guard ExecCommandToken.basenameLower(token) == "env" else { + break + } + guard let unwrapped = self.unwrap(current), !unwrapped.isEmpty else { + break + } + current = unwrapped + depth += 1 + } + return current + } +} diff --git a/apps/macos/Sources/OpenClaw/ExecShellWrapperParser.swift b/apps/macos/Sources/OpenClaw/ExecShellWrapperParser.swift new file mode 100644 index 00000000000..ca6a934adb5 --- /dev/null +++ b/apps/macos/Sources/OpenClaw/ExecShellWrapperParser.swift @@ -0,0 +1,106 @@ +import Foundation + +enum ExecShellWrapperParser { + struct ParsedShellWrapper { + let isWrapper: Bool + let command: String? + + static let notWrapper = ParsedShellWrapper(isWrapper: false, command: nil) + } + + private enum Kind { + case posix + case cmd + case powershell + } + + private struct WrapperSpec { + let kind: Kind + let names: Set + } + + private static let posixInlineFlags = Set(["-lc", "-c", "--command"]) + private static let powershellInlineFlags = Set(["-c", "-command", "--command"]) + + private static let wrapperSpecs: [WrapperSpec] = [ + WrapperSpec(kind: .posix, names: ["ash", "sh", "bash", "zsh", "dash", "ksh", "fish"]), + WrapperSpec(kind: .cmd, names: ["cmd.exe", "cmd"]), + WrapperSpec(kind: .powershell, names: ["powershell", "powershell.exe", "pwsh", "pwsh.exe"]), + ] + + static func extract(command: [String], rawCommand: String?) -> ParsedShellWrapper { + let trimmedRaw = rawCommand?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + let preferredRaw = trimmedRaw.isEmpty ? nil : trimmedRaw + return self.extract(command: command, preferredRaw: preferredRaw, depth: 0) + } + + private static func extract(command: [String], preferredRaw: String?, depth: Int) -> ParsedShellWrapper { + guard depth < ExecEnvInvocationUnwrapper.maxWrapperDepth else { + return .notWrapper + } + guard let token0 = command.first?.trimmingCharacters(in: .whitespacesAndNewlines), !token0.isEmpty else { + return .notWrapper + } + + let base0 = ExecCommandToken.basenameLower(token0) + if base0 == "env" { + guard let unwrapped = ExecEnvInvocationUnwrapper.unwrap(command) else { + return .notWrapper + } + return self.extract(command: unwrapped, preferredRaw: preferredRaw, depth: depth + 1) + } + + guard let spec = self.wrapperSpecs.first(where: { $0.names.contains(base0) }) else { + return .notWrapper + } + guard let payload = self.extractPayload(command: command, spec: spec) else { + return .notWrapper + } + let normalized = preferredRaw ?? payload + return ParsedShellWrapper(isWrapper: true, command: normalized) + } + + private static func extractPayload(command: [String], spec: WrapperSpec) -> String? { + switch spec.kind { + case .posix: + return self.extractPosixInlineCommand(command) + case .cmd: + return self.extractCmdInlineCommand(command) + case .powershell: + return self.extractPowerShellInlineCommand(command) + } + } + + private static func extractPosixInlineCommand(_ command: [String]) -> String? { + let flag = command.count > 1 ? command[1].trimmingCharacters(in: .whitespacesAndNewlines) : "" + guard self.posixInlineFlags.contains(flag.lowercased()) else { + return nil + } + let payload = command.count > 2 ? command[2].trimmingCharacters(in: .whitespacesAndNewlines) : "" + return payload.isEmpty ? nil : payload + } + + private static func extractCmdInlineCommand(_ command: [String]) -> String? { + guard let idx = command.firstIndex(where: { $0.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() == "/c" }) else { + return nil + } + let tail = command.suffix(from: command.index(after: idx)).joined(separator: " ") + let payload = tail.trimmingCharacters(in: .whitespacesAndNewlines) + return payload.isEmpty ? nil : payload + } + + private static func extractPowerShellInlineCommand(_ command: [String]) -> String? { + for idx in 1.. String? { - let host = self.sanitizedTailnetHost(gateway.tailnetDns) ?? gateway.lanHost + static func resolvedServiceHost( + for gateway: GatewayDiscoveryModel.DiscoveredGateway) -> String? + { + self.resolvedServiceHost(gateway.serviceHost) + } + + static func resolvedServiceHost(_ host: String?) -> String? { guard let host = self.trimmed(host), !host.isEmpty else { return nil } + return host + } + + static func serviceEndpoint( + for gateway: GatewayDiscoveryModel.DiscoveredGateway) -> (host: String, port: Int)? + { + self.serviceEndpoint(serviceHost: gateway.serviceHost, servicePort: gateway.servicePort) + } + + static func serviceEndpoint( + serviceHost: String?, + servicePort: Int?) -> (host: String, port: Int)? + { + guard let host = self.resolvedServiceHost(serviceHost) else { return nil } + guard let port = servicePort, port > 0, port <= 65535 else { return nil } + return (host, port) + } + + static func sshTarget(for gateway: GatewayDiscoveryModel.DiscoveredGateway) -> String? { + guard let host = self.resolvedServiceHost(for: gateway) else { return nil } let user = NSUserName() var target = "\(user)@\(host)" if gateway.sshPort != 22 { @@ -16,42 +41,37 @@ enum GatewayDiscoveryHelpers { static func directUrl(for gateway: GatewayDiscoveryModel.DiscoveredGateway) -> String? { self.directGatewayUrl( serviceHost: gateway.serviceHost, - servicePort: gateway.servicePort, - lanHost: gateway.lanHost, - gatewayPort: gateway.gatewayPort) + servicePort: gateway.servicePort) } static func directGatewayUrl( serviceHost: String?, - servicePort: Int?, - lanHost: String?, - gatewayPort: Int?) -> String? + servicePort: Int?) -> String? { // Security: do not route using unauthenticated TXT hints (tailnetDns/lanHost/gatewayPort). // Prefer the resolved service endpoint (SRV + A/AAAA). - if let host = self.trimmed(serviceHost), !host.isEmpty, - let port = servicePort, port > 0 - { - let scheme = port == 443 ? "wss" : "ws" - let portSuffix = port == 443 ? "" : ":\(port)" - return "\(scheme)://\(host)\(portSuffix)" - } - - // Legacy fallback (best-effort): keep existing behavior when we couldn't resolve SRV. - guard let lanHost = self.trimmed(lanHost), !lanHost.isEmpty else { return nil } - let port = gatewayPort ?? 18789 - return "ws://\(lanHost):\(port)" - } - - static func sanitizedTailnetHost(_ host: String?) -> String? { - guard let host = self.trimmed(host), !host.isEmpty else { return nil } - if host.hasSuffix(".internal.") || host.hasSuffix(".internal") { + guard let endpoint = self.serviceEndpoint(serviceHost: serviceHost, servicePort: servicePort) else { return nil } - return host + // Security: for non-loopback hosts, force TLS to avoid plaintext credential/session leakage. + let scheme = self.isLoopbackHost(endpoint.host) ? "ws" : "wss" + let portSuffix = endpoint.port == 443 ? "" : ":\(endpoint.port)" + return "\(scheme)://\(endpoint.host)\(portSuffix)" } private static func trimmed(_ value: String?) -> String? { value?.trimmingCharacters(in: .whitespacesAndNewlines) } + + private static func isLoopbackHost(_ rawHost: String) -> Bool { + let host = rawHost.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + guard !host.isEmpty else { return false } + if host == "localhost" || host == "::1" || host == "0:0:0:0:0:0:0:1" { + return true + } + if host.hasPrefix("::ffff:127.") { + return true + } + return host.hasPrefix("127.") + } } diff --git a/apps/macos/Sources/OpenClaw/GatewayRemoteConfig.swift b/apps/macos/Sources/OpenClaw/GatewayRemoteConfig.swift index 0b8ab35159d..64a6f92db8f 100644 --- a/apps/macos/Sources/OpenClaw/GatewayRemoteConfig.swift +++ b/apps/macos/Sources/OpenClaw/GatewayRemoteConfig.swift @@ -1,6 +1,41 @@ import Foundation +import Network enum GatewayRemoteConfig { + private static func isLoopbackHost(_ rawHost: String) -> Bool { + var host = rawHost + .trimmingCharacters(in: .whitespacesAndNewlines) + .lowercased() + .trimmingCharacters(in: CharacterSet(charactersIn: "[]")) + if host.hasSuffix(".") { + host.removeLast() + } + if let zoneIndex = host.firstIndex(of: "%") { + host = String(host[.. AppState.RemoteTransport { guard let gateway = root["gateway"] as? [String: Any], let remote = gateway["remote"] as? [String: Any], @@ -39,6 +74,9 @@ enum GatewayRemoteConfig { guard scheme == "ws" || scheme == "wss" else { return nil } let host = url.host?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" guard !host.isEmpty else { return nil } + if scheme == "ws", !self.isLoopbackHost(host) { + return nil + } if scheme == "ws", url.port == nil { guard var components = URLComponents(url: url, resolvingAgainstBaseURL: false) else { return url diff --git a/apps/macos/Sources/OpenClaw/GeneralSettings.swift b/apps/macos/Sources/OpenClaw/GeneralSettings.swift index d55f7c1b015..60cfdfb1d73 100644 --- a/apps/macos/Sources/OpenClaw/GeneralSettings.swift +++ b/apps/macos/Sources/OpenClaw/GeneralSettings.swift @@ -303,7 +303,9 @@ struct GeneralSettings: View { .disabled(self.remoteStatus == .checking || self.state.remoteUrl .trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) } - Text("Direct mode requires a ws:// or wss:// URL (Tailscale Serve uses wss://).") + Text( + "Direct mode requires wss:// for remote hosts. ws:// is only allowed for localhost/127.0.0.1." + ) .font(.caption) .foregroundStyle(.secondary) .padding(.leading, self.remoteLabelWidth + 10) @@ -546,7 +548,9 @@ extension GeneralSettings { return } guard Self.isValidWsUrl(trimmedUrl) else { - self.remoteStatus = .failed("Gateway URL must start with ws:// or wss://") + self.remoteStatus = .failed( + "Gateway URL must use wss:// for remote hosts (ws:// only for localhost)" + ) return } } else { @@ -603,11 +607,7 @@ extension GeneralSettings { } private static func isValidWsUrl(_ raw: String) -> Bool { - guard let url = URL(string: raw.trimmingCharacters(in: .whitespacesAndNewlines)) else { return false } - let scheme = url.scheme?.lowercased() ?? "" - guard scheme == "ws" || scheme == "wss" else { return false } - let host = url.host?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" - return !host.isEmpty + GatewayRemoteConfig.normalizeGatewayUrl(raw) != nil } private static func sshCheckCommand(target: String, identity: String) -> [String]? { @@ -675,22 +675,17 @@ extension GeneralSettings { private func applyDiscoveredGateway(_ gateway: GatewayDiscoveryModel.DiscoveredGateway) { MacNodeModeCoordinator.shared.setPreferredGatewayStableID(gateway.stableID) - let host = gateway.tailnetDns ?? gateway.lanHost - guard let host else { return } - let user = NSUserName() if self.state.remoteTransport == .direct { - if let url = GatewayDiscoveryHelpers.directUrl(for: gateway) { - self.state.remoteUrl = url - } + self.state.remoteUrl = GatewayDiscoveryHelpers.directUrl(for: gateway) ?? "" } else { - self.state.remoteTarget = GatewayDiscoveryModel.buildSSHTarget( - user: user, - host: host, - port: gateway.sshPort) - self.state.remoteCliPath = gateway.cliPath ?? "" + self.state.remoteTarget = GatewayDiscoveryHelpers.sshTarget(for: gateway) ?? "" + } + if let endpoint = GatewayDiscoveryHelpers.serviceEndpoint(for: gateway) { OpenClawConfigFile.setRemoteGatewayUrl( - host: gateway.serviceHost ?? host, - port: gateway.servicePort ?? gateway.gatewayPort) + host: endpoint.host, + port: endpoint.port) + } else { + OpenClawConfigFile.clearRemoteGatewayUrl() } } } diff --git a/apps/macos/Sources/OpenClaw/HostEnvSanitizer.swift b/apps/macos/Sources/OpenClaw/HostEnvSanitizer.swift new file mode 100644 index 00000000000..b9b993299a9 --- /dev/null +++ b/apps/macos/Sources/OpenClaw/HostEnvSanitizer.swift @@ -0,0 +1,91 @@ +import Foundation + +enum HostEnvSanitizer { + /// Keep in sync with src/infra/host-env-security-policy.json. + /// Parity is validated by src/infra/host-env-security.policy-parity.test.ts. + private static let blockedKeys: Set = [ + "NODE_OPTIONS", + "NODE_PATH", + "PYTHONHOME", + "PYTHONPATH", + "PERL5LIB", + "PERL5OPT", + "RUBYLIB", + "RUBYOPT", + "BASH_ENV", + "ENV", + "SHELL", + "SHELLOPTS", + "PS4", + "GCONV_PATH", + "IFS", + "SSLKEYLOGFILE", + ] + + private static let blockedPrefixes: [String] = [ + "DYLD_", + "LD_", + "BASH_FUNC_", + ] + private static let blockedOverrideKeys: Set = [ + "HOME", + "ZDOTDIR", + ] + private static let shellWrapperAllowedOverrideKeys: Set = [ + "TERM", + "LANG", + "LC_ALL", + "LC_CTYPE", + "LC_MESSAGES", + "COLORTERM", + "NO_COLOR", + "FORCE_COLOR", + ] + + private static func isBlocked(_ upperKey: String) -> Bool { + if self.blockedKeys.contains(upperKey) { return true } + return self.blockedPrefixes.contains(where: { upperKey.hasPrefix($0) }) + } + + private static func filterOverridesForShellWrapper(_ overrides: [String: String]?) -> [String: String]? { + guard let overrides else { return nil } + var filtered: [String: String] = [:] + for (rawKey, value) in overrides { + let key = rawKey.trimmingCharacters(in: .whitespacesAndNewlines) + guard !key.isEmpty else { continue } + if self.shellWrapperAllowedOverrideKeys.contains(key.uppercased()) { + filtered[key] = value + } + } + return filtered.isEmpty ? nil : filtered + } + + static func sanitize(overrides: [String: String]?, shellWrapper: Bool = false) -> [String: String] { + var merged: [String: String] = [:] + for (rawKey, value) in ProcessInfo.processInfo.environment { + let key = rawKey.trimmingCharacters(in: .whitespacesAndNewlines) + guard !key.isEmpty else { continue } + let upper = key.uppercased() + if self.isBlocked(upper) { continue } + merged[key] = value + } + + let effectiveOverrides = shellWrapper + ? self.filterOverridesForShellWrapper(overrides) + : overrides + + guard let effectiveOverrides else { return merged } + for (rawKey, value) in effectiveOverrides { + let key = rawKey.trimmingCharacters(in: .whitespacesAndNewlines) + guard !key.isEmpty else { continue } + let upper = key.uppercased() + // PATH is part of the security boundary (command resolution + safe-bin checks). Never + // allow request-scoped PATH overrides from agents/gateways. + if upper == "PATH" { continue } + if self.blockedOverrideKeys.contains(upper) { continue } + if self.isBlocked(upper) { continue } + merged[key] = value + } + return merged + } +} diff --git a/apps/macos/Sources/OpenClaw/NodeMode/MacNodeRuntime.swift b/apps/macos/Sources/OpenClaw/NodeMode/MacNodeRuntime.swift index 60bd95f2894..cda8ca6057c 100644 --- a/apps/macos/Sources/OpenClaw/NodeMode/MacNodeRuntime.swift +++ b/apps/macos/Sources/OpenClaw/NodeMode/MacNodeRuntime.swift @@ -441,43 +441,25 @@ actor MacNodeRuntime { guard !command.isEmpty else { return Self.errorResponse(req, code: .invalidRequest, message: "INVALID_REQUEST: command required") } - let displayCommand = ExecCommandFormatter.displayString(for: command, rawCommand: params.rawCommand) - - let trimmedAgent = params.agentId?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" - let agentId = trimmedAgent.isEmpty ? nil : trimmedAgent - let approvals = ExecApprovalsStore.resolve(agentId: agentId) - let security = approvals.agent.security - let ask = approvals.agent.ask - let autoAllowSkills = approvals.agent.autoAllowSkills let sessionKey = (params.sessionKey?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false) ? params.sessionKey!.trimmingCharacters(in: .whitespacesAndNewlines) : self.mainSessionKey let runId = UUID().uuidString - let env = Self.sanitizedEnv(params.env) - let resolution = ExecCommandResolution.resolve( + let evaluation = await ExecApprovalEvaluator.evaluate( command: command, rawCommand: params.rawCommand, cwd: params.cwd, - env: env) - let allowlistMatch = security == .allowlist - ? ExecAllowlistMatcher.match(entries: approvals.allowlist, resolution: resolution) - : nil - let skillAllow: Bool - if autoAllowSkills, let name = resolution?.executableName { - let bins = await SkillBinsCache.shared.currentBins() - skillAllow = bins.contains(name) - } else { - skillAllow = false - } + envOverrides: params.env, + agentId: params.agentId) - if security == .deny { + if evaluation.security == .deny { await self.emitExecEvent( "exec.denied", payload: ExecEventPayload( sessionKey: sessionKey, runId: runId, host: "node", - command: displayCommand, + command: evaluation.displayCommand, reason: "security=deny")) return Self.errorResponse( req, @@ -489,32 +471,33 @@ actor MacNodeRuntime { req: req, params: params, context: ExecRunContext( - displayCommand: displayCommand, - security: security, - ask: ask, - agentId: agentId, - resolution: resolution, - allowlistMatch: allowlistMatch, - skillAllow: skillAllow, + displayCommand: evaluation.displayCommand, + security: evaluation.security, + ask: evaluation.ask, + agentId: evaluation.agentId, + resolution: evaluation.resolution, + allowlistMatch: evaluation.allowlistMatch, + skillAllow: evaluation.skillAllow, sessionKey: sessionKey, runId: runId)) if let response = approval.response { return response } let approvedByAsk = approval.approvedByAsk let persistAllowlist = approval.persistAllowlist - if persistAllowlist, security == .allowlist, - let pattern = ExecApprovalHelpers.allowlistPattern(command: command, resolution: resolution) - { - ExecApprovalsStore.addAllowlistEntry(agentId: agentId, pattern: pattern) - } + self.persistAllowlistPatterns( + persistAllowlist: persistAllowlist, + security: evaluation.security, + agentId: evaluation.agentId, + command: command, + allowlistResolutions: evaluation.allowlistResolutions) - if security == .allowlist, allowlistMatch == nil, !skillAllow, !approvedByAsk { + if evaluation.security == .allowlist, !evaluation.allowlistSatisfied, !evaluation.skillAllow, !approvedByAsk { await self.emitExecEvent( "exec.denied", payload: ExecEventPayload( sessionKey: sessionKey, runId: runId, host: "node", - command: displayCommand, + command: evaluation.displayCommand, reason: "allowlist-miss")) return Self.errorResponse( req, @@ -522,79 +505,32 @@ actor MacNodeRuntime { message: "SYSTEM_RUN_DENIED: allowlist miss") } - if let match = allowlistMatch { - ExecApprovalsStore.recordAllowlistUse( - agentId: agentId, - pattern: match.pattern, - command: displayCommand, - resolvedPath: resolution?.resolvedPath) + self.recordAllowlistMatches( + security: evaluation.security, + allowlistSatisfied: evaluation.allowlistSatisfied, + agentId: evaluation.agentId, + allowlistMatches: evaluation.allowlistMatches, + allowlistResolutions: evaluation.allowlistResolutions, + displayCommand: evaluation.displayCommand) + + if let permissionResponse = await self.validateScreenRecordingIfNeeded( + req: req, + needsScreenRecording: params.needsScreenRecording, + sessionKey: sessionKey, + runId: runId, + displayCommand: evaluation.displayCommand) + { + return permissionResponse } - if params.needsScreenRecording == true { - let authorized = await PermissionManager - .status([.screenRecording])[.screenRecording] ?? false - if !authorized { - await self.emitExecEvent( - "exec.denied", - payload: ExecEventPayload( - sessionKey: sessionKey, - runId: runId, - host: "node", - command: displayCommand, - reason: "permission:screenRecording")) - return Self.errorResponse( - req, - code: .unavailable, - message: "PERMISSION_MISSING: screenRecording") - } - } - - let timeoutSec = params.timeoutMs.flatMap { Double($0) / 1000.0 } - await self.emitExecEvent( - "exec.started", - payload: ExecEventPayload( - sessionKey: sessionKey, - runId: runId, - host: "node", - command: displayCommand)) - let result = await ShellExecutor.runDetailed( + return try await self.executeSystemRun( + req: req, + params: params, command: command, - cwd: params.cwd, - env: env, - timeout: timeoutSec) - let combined = [result.stdout, result.stderr, result.errorMessage] - .compactMap(\.self) - .filter { !$0.isEmpty } - .joined(separator: "\n") - await self.emitExecEvent( - "exec.finished", - payload: ExecEventPayload( - sessionKey: sessionKey, - runId: runId, - host: "node", - command: displayCommand, - exitCode: result.exitCode, - timedOut: result.timedOut, - success: result.success, - output: ExecEventPayload.truncateOutput(combined))) - - struct RunPayload: Encodable { - var exitCode: Int? - var timedOut: Bool - var success: Bool - var stdout: String - var stderr: String - var error: String? - } - - let payload = try Self.encodePayload(RunPayload( - exitCode: result.exitCode, - timedOut: result.timedOut, - success: result.success, - stdout: result.stdout, - stderr: result.stderr, - error: result.errorMessage)) - return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: payload) + env: evaluation.env, + sessionKey: sessionKey, + runId: runId, + displayCommand: evaluation.displayCommand) } private func handleSystemWhich(_ req: BridgeInvokeRequest) async throws -> BridgeInvokeResponse { @@ -835,6 +771,132 @@ actor MacNodeRuntime { } extension MacNodeRuntime { + private func persistAllowlistPatterns( + persistAllowlist: Bool, + security: ExecSecurity, + agentId: String?, + command: [String], + allowlistResolutions: [ExecCommandResolution]) + { + guard persistAllowlist, security == .allowlist else { return } + var seenPatterns = Set() + for candidate in allowlistResolutions { + guard let pattern = ExecApprovalHelpers.allowlistPattern(command: command, resolution: candidate) else { + continue + } + if seenPatterns.insert(pattern).inserted { + ExecApprovalsStore.addAllowlistEntry(agentId: agentId, pattern: pattern) + } + } + } + + private func recordAllowlistMatches( + security: ExecSecurity, + allowlistSatisfied: Bool, + agentId: String?, + allowlistMatches: [ExecAllowlistEntry], + allowlistResolutions: [ExecCommandResolution], + displayCommand: String) + { + guard security == .allowlist, allowlistSatisfied else { return } + var seenPatterns = Set() + for (idx, match) in allowlistMatches.enumerated() { + if !seenPatterns.insert(match.pattern).inserted { + continue + } + let resolvedPath = idx < allowlistResolutions.count ? allowlistResolutions[idx].resolvedPath : nil + ExecApprovalsStore.recordAllowlistUse( + agentId: agentId, + pattern: match.pattern, + command: displayCommand, + resolvedPath: resolvedPath) + } + } + + private func validateScreenRecordingIfNeeded( + req: BridgeInvokeRequest, + needsScreenRecording: Bool?, + sessionKey: String, + runId: String, + displayCommand: String) async -> BridgeInvokeResponse? + { + guard needsScreenRecording == true else { return nil } + let authorized = await PermissionManager + .status([.screenRecording])[.screenRecording] ?? false + if authorized { + return nil + } + await self.emitExecEvent( + "exec.denied", + payload: ExecEventPayload( + sessionKey: sessionKey, + runId: runId, + host: "node", + command: displayCommand, + reason: "permission:screenRecording")) + return Self.errorResponse( + req, + code: .unavailable, + message: "PERMISSION_MISSING: screenRecording") + } + + private func executeSystemRun( + req: BridgeInvokeRequest, + params: OpenClawSystemRunParams, + command: [String], + env: [String: String], + sessionKey: String, + runId: String, + displayCommand: String) async throws -> BridgeInvokeResponse + { + let timeoutSec = params.timeoutMs.flatMap { Double($0) / 1000.0 } + await self.emitExecEvent( + "exec.started", + payload: ExecEventPayload( + sessionKey: sessionKey, + runId: runId, + host: "node", + command: displayCommand)) + let result = await ShellExecutor.runDetailed( + command: command, + cwd: params.cwd, + env: env, + timeout: timeoutSec) + let combined = [result.stdout, result.stderr, result.errorMessage] + .compactMap(\.self) + .filter { !$0.isEmpty } + .joined(separator: "\n") + await self.emitExecEvent( + "exec.finished", + payload: ExecEventPayload( + sessionKey: sessionKey, + runId: runId, + host: "node", + command: displayCommand, + exitCode: result.exitCode, + timedOut: result.timedOut, + success: result.success, + output: ExecEventPayload.truncateOutput(combined))) + + struct RunPayload: Encodable { + var exitCode: Int? + var timedOut: Bool + var success: Bool + var stdout: String + var stderr: String + var error: String? + } + let runPayload = RunPayload( + exitCode: result.exitCode, + timedOut: result.timedOut, + success: result.success, + stdout: result.stdout, + stderr: result.stderr, + error: result.errorMessage) + let payload = try Self.encodePayload(runPayload) + return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: payload) + } + private static func decodeParams(_ type: T.Type, from json: String?) throws -> T { guard let json, let data = json.data(using: .utf8) else { throw NSError(domain: "Gateway", code: 20, userInfo: [ @@ -862,35 +924,6 @@ extension MacNodeRuntime { UserDefaults.standard.object(forKey: cameraEnabledKey) as? Bool ?? false } - private static let blockedEnvKeys: Set = [ - "PATH", - "NODE_OPTIONS", - "PYTHONHOME", - "PYTHONPATH", - "PERL5LIB", - "PERL5OPT", - "RUBYOPT", - ] - - private static let blockedEnvPrefixes: [String] = [ - "DYLD_", - "LD_", - ] - - private static func sanitizedEnv(_ overrides: [String: String]?) -> [String: String]? { - guard let overrides else { return nil } - var merged = ProcessInfo.processInfo.environment - for (rawKey, value) in overrides { - let key = rawKey.trimmingCharacters(in: .whitespacesAndNewlines) - guard !key.isEmpty else { continue } - let upper = key.uppercased() - if self.blockedEnvKeys.contains(upper) { continue } - if self.blockedEnvPrefixes.contains(where: { upper.hasPrefix($0) }) { continue } - merged[key] = value - } - return merged - } - private nonisolated static func locationMode() -> OpenClawLocationMode { let raw = UserDefaults.standard.string(forKey: locationModeKey) ?? "off" return OpenClawLocationMode(rawValue: raw) ?? .off diff --git a/apps/macos/Sources/OpenClaw/NodePairingApprovalPrompter.swift b/apps/macos/Sources/OpenClaw/NodePairingApprovalPrompter.swift index ee994b38f65..10598d7f4be 100644 --- a/apps/macos/Sources/OpenClaw/NodePairingApprovalPrompter.swift +++ b/apps/macos/Sources/OpenClaw/NodePairingApprovalPrompter.swift @@ -520,11 +520,12 @@ final class NodePairingApprovalPrompter { let preferred = GatewayDiscoveryPreferences.preferredStableID() let gateway = model.gateways.first { $0.stableID == preferred } ?? model.gateways.first guard let gateway else { return nil } - let host = (gateway.tailnetDns?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty ?? - gateway.lanHost?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty) - guard let host, !host.isEmpty else { return nil } - let port = gateway.sshPort > 0 ? gateway.sshPort : 22 - return SSHTarget(host: host, port: port) + guard let target = GatewayDiscoveryHelpers.sshTarget(for: gateway), + let parsed = CommandResolver.parseSSHTarget(target) + else { + return nil + } + return SSHTarget(host: parsed.host, port: parsed.port) } private static func probeSSH(user: String, host: String, port: Int) async -> Bool { diff --git a/apps/macos/Sources/OpenClaw/OnboardingView+Actions.swift b/apps/macos/Sources/OpenClaw/OnboardingView+Actions.swift index ba43424aa9a..bcd5bd6d44d 100644 --- a/apps/macos/Sources/OpenClaw/OnboardingView+Actions.swift +++ b/apps/macos/Sources/OpenClaw/OnboardingView+Actions.swift @@ -26,20 +26,17 @@ extension OnboardingView { GatewayDiscoveryPreferences.setPreferredStableID(gateway.stableID) if self.state.remoteTransport == .direct { - if let url = GatewayDiscoveryHelpers.directUrl(for: gateway) { - self.state.remoteUrl = url - } - } else if let host = GatewayDiscoveryHelpers.sanitizedTailnetHost(gateway.tailnetDns) ?? gateway.lanHost { - let user = NSUserName() - self.state.remoteTarget = GatewayDiscoveryModel.buildSSHTarget( - user: user, - host: host, - port: gateway.sshPort) - OpenClawConfigFile.setRemoteGatewayUrl( - host: gateway.serviceHost ?? host, - port: gateway.servicePort ?? gateway.gatewayPort) + self.state.remoteUrl = GatewayDiscoveryHelpers.directUrl(for: gateway) ?? "" + } else { + self.state.remoteTarget = GatewayDiscoveryHelpers.sshTarget(for: gateway) ?? "" + } + if let endpoint = GatewayDiscoveryHelpers.serviceEndpoint(for: gateway) { + OpenClawConfigFile.setRemoteGatewayUrl( + host: endpoint.host, + port: endpoint.port) + } else { + OpenClawConfigFile.clearRemoteGatewayUrl() } - self.state.remoteCliPath = gateway.cliPath ?? "" self.state.connectionMode = .remote MacNodeModeCoordinator.shared.setPreferredGatewayStableID(gateway.stableID) diff --git a/apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift b/apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift index 5760bfff8c2..5b05ab164c2 100644 --- a/apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift +++ b/apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift @@ -265,9 +265,11 @@ extension OnboardingView { if self.state.remoteTransport == .direct { return GatewayDiscoveryHelpers.directUrl(for: gateway) ?? "Gateway pairing only" } - if let host = GatewayDiscoveryHelpers.sanitizedTailnetHost(gateway.tailnetDns) ?? gateway.lanHost { - let portSuffix = gateway.sshPort != 22 ? " · ssh \(gateway.sshPort)" : "" - return "\(host)\(portSuffix)" + if let target = GatewayDiscoveryHelpers.sshTarget(for: gateway), + let parsed = CommandResolver.parseSSHTarget(target) + { + let portSuffix = parsed.port != 22 ? " · ssh \(parsed.port)" : "" + return "\(parsed.host)\(portSuffix)" } return "Gateway pairing only" } diff --git a/apps/macos/Sources/OpenClaw/OpenClawConfigFile.swift b/apps/macos/Sources/OpenClaw/OpenClawConfigFile.swift index f49f2b7e0d4..35744baeda5 100644 --- a/apps/macos/Sources/OpenClaw/OpenClawConfigFile.swift +++ b/apps/macos/Sources/OpenClaw/OpenClawConfigFile.swift @@ -223,6 +223,19 @@ enum OpenClawConfigFile { } } + static func clearRemoteGatewayUrl() { + self.updateGatewayDict { gateway in + guard var remote = gateway["remote"] as? [String: Any] else { return } + guard remote["url"] != nil else { return } + remote.removeValue(forKey: "url") + if remote.isEmpty { + gateway.removeValue(forKey: "remote") + } else { + gateway["remote"] = remote + } + } + } + private static func remoteGatewayUrl() -> URL? { let root = self.loadDict() guard let gateway = root["gateway"] as? [String: Any], diff --git a/apps/macos/Sources/OpenClaw/Resources/Info.plist b/apps/macos/Sources/OpenClaw/Resources/Info.plist index 37c85b6f3dd..3a425368d09 100644 --- a/apps/macos/Sources/OpenClaw/Resources/Info.plist +++ b/apps/macos/Sources/OpenClaw/Resources/Info.plist @@ -15,9 +15,9 @@ CFBundlePackageType APPL CFBundleShortVersionString - 2026.2.16 + 2026.2.23 CFBundleVersion - 202602160 + 202602230 CFBundleIconFile OpenClaw CFBundleURLTypes diff --git a/apps/macos/Sources/OpenClaw/SystemRunSettingsView.swift b/apps/macos/Sources/OpenClaw/SystemRunSettingsView.swift index b9bd6bd0c8c..a6d81f50bca 100644 --- a/apps/macos/Sources/OpenClaw/SystemRunSettingsView.swift +++ b/apps/macos/Sources/OpenClaw/SystemRunSettingsView.swift @@ -105,16 +105,24 @@ struct SystemRunSettingsView: View { .foregroundStyle(.secondary) } else { HStack(spacing: 8) { - TextField("Add allowlist pattern (case-insensitive globs)", text: self.$newPattern) + TextField("Add allowlist path pattern (case-insensitive globs)", text: self.$newPattern) .textFieldStyle(.roundedBorder) Button("Add") { - let pattern = self.newPattern.trimmingCharacters(in: .whitespacesAndNewlines) - guard !pattern.isEmpty else { return } - self.model.addEntry(pattern) - self.newPattern = "" + if self.model.addEntry(self.newPattern) == nil { + self.newPattern = "" + } } .buttonStyle(.bordered) - .disabled(self.newPattern.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) + .disabled(!self.model.isPathPattern(self.newPattern)) + } + + Text("Path patterns only. Basename entries like \"echo\" are ignored.") + .font(.footnote) + .foregroundStyle(.secondary) + if let validationMessage = self.model.allowlistValidationMessage { + Text(validationMessage) + .font(.footnote) + .foregroundStyle(.orange) } if self.model.entries.isEmpty { @@ -234,6 +242,7 @@ final class ExecApprovalsSettingsModel { var autoAllowSkills = false var entries: [ExecAllowlistEntry] = [] var skillBins: [String] = [] + var allowlistValidationMessage: String? var agentPickerIds: [String] { [Self.defaultsScopeId] + self.agentIds @@ -289,6 +298,7 @@ final class ExecApprovalsSettingsModel { func selectAgent(_ id: String) { self.selectedAgentId = id + self.allowlistValidationMessage = nil self.loadSettings(for: id) Task { await self.refreshSkillBins() } } @@ -301,6 +311,7 @@ final class ExecApprovalsSettingsModel { self.askFallback = defaults.askFallback self.autoAllowSkills = defaults.autoAllowSkills self.entries = [] + self.allowlistValidationMessage = nil return } let resolved = ExecApprovalsStore.resolve(agentId: agentId) @@ -310,6 +321,7 @@ final class ExecApprovalsSettingsModel { self.autoAllowSkills = resolved.agent.autoAllowSkills self.entries = resolved.allowlist .sorted { $0.pattern.localizedCaseInsensitiveCompare($1.pattern) == .orderedAscending } + self.allowlistValidationMessage = nil } func setSecurity(_ security: ExecSecurity) { @@ -367,32 +379,55 @@ final class ExecApprovalsSettingsModel { Task { await self.refreshSkillBins(force: enabled) } } - func addEntry(_ pattern: String) { - guard !self.isDefaultsScope else { return } - let trimmed = pattern.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty else { return } - self.entries.append(ExecAllowlistEntry(pattern: trimmed, lastUsedAt: nil)) - ExecApprovalsStore.updateAllowlist(agentId: self.selectedAgentId, allowlist: self.entries) + @discardableResult + func addEntry(_ pattern: String) -> ExecAllowlistPatternValidationReason? { + guard !self.isDefaultsScope else { return nil } + switch ExecApprovalHelpers.validateAllowlistPattern(pattern) { + case .valid(let normalizedPattern): + self.entries.append(ExecAllowlistEntry(pattern: normalizedPattern, lastUsedAt: nil)) + let rejected = ExecApprovalsStore.updateAllowlist(agentId: self.selectedAgentId, allowlist: self.entries) + self.allowlistValidationMessage = rejected.first?.reason.message + return rejected.first?.reason + case .invalid(let reason): + self.allowlistValidationMessage = reason.message + return reason + } } - func updateEntry(_ entry: ExecAllowlistEntry, id: UUID) { - guard !self.isDefaultsScope else { return } - guard let index = self.entries.firstIndex(where: { $0.id == id }) else { return } - self.entries[index] = entry - ExecApprovalsStore.updateAllowlist(agentId: self.selectedAgentId, allowlist: self.entries) + @discardableResult + func updateEntry(_ entry: ExecAllowlistEntry, id: UUID) -> ExecAllowlistPatternValidationReason? { + guard !self.isDefaultsScope else { return nil } + guard let index = self.entries.firstIndex(where: { $0.id == id }) else { return nil } + var next = entry + switch ExecApprovalHelpers.validateAllowlistPattern(next.pattern) { + case .valid(let normalizedPattern): + next.pattern = normalizedPattern + case .invalid(let reason): + self.allowlistValidationMessage = reason.message + return reason + } + self.entries[index] = next + let rejected = ExecApprovalsStore.updateAllowlist(agentId: self.selectedAgentId, allowlist: self.entries) + self.allowlistValidationMessage = rejected.first?.reason.message + return rejected.first?.reason } func removeEntry(id: UUID) { guard !self.isDefaultsScope else { return } guard let index = self.entries.firstIndex(where: { $0.id == id }) else { return } self.entries.remove(at: index) - ExecApprovalsStore.updateAllowlist(agentId: self.selectedAgentId, allowlist: self.entries) + let rejected = ExecApprovalsStore.updateAllowlist(agentId: self.selectedAgentId, allowlist: self.entries) + self.allowlistValidationMessage = rejected.first?.reason.message } func entry(for id: UUID) -> ExecAllowlistEntry? { self.entries.first(where: { $0.id == id }) } + func isPathPattern(_ pattern: String) -> Bool { + ExecApprovalHelpers.isPathPattern(pattern) + } + func refreshSkillBins(force: Bool = false) async { guard self.autoAllowSkills else { self.skillBins = [] diff --git a/apps/macos/Sources/OpenClawDiscovery/TailscaleNetwork.swift b/apps/macos/Sources/OpenClawDiscovery/TailscaleNetwork.swift index 60b11306d05..ef78e6f400f 100644 --- a/apps/macos/Sources/OpenClawDiscovery/TailscaleNetwork.swift +++ b/apps/macos/Sources/OpenClawDiscovery/TailscaleNetwork.swift @@ -44,4 +44,3 @@ public enum TailscaleNetwork { return nil } } - diff --git a/apps/macos/Sources/OpenClawMacCLI/ConnectCommand.swift b/apps/macos/Sources/OpenClawMacCLI/ConnectCommand.swift index 0989164a01e..151b7fdda94 100644 --- a/apps/macos/Sources/OpenClawMacCLI/ConnectCommand.swift +++ b/apps/macos/Sources/OpenClawMacCLI/ConnectCommand.swift @@ -15,7 +15,7 @@ struct ConnectOptions { var clientMode: String = "ui" var displayName: String? var role: String = "operator" - var scopes: [String] = ["operator.admin", "operator.approvals", "operator.pairing"] + var scopes: [String] = defaultOperatorConnectScopes var help: Bool = false static func parse(_ args: [String]) -> ConnectOptions { diff --git a/apps/macos/Sources/OpenClawMacCLI/GatewayScopes.swift b/apps/macos/Sources/OpenClawMacCLI/GatewayScopes.swift new file mode 100644 index 00000000000..479c176d5d8 --- /dev/null +++ b/apps/macos/Sources/OpenClawMacCLI/GatewayScopes.swift @@ -0,0 +1,7 @@ +let defaultOperatorConnectScopes: [String] = [ + "operator.admin", + "operator.read", + "operator.write", + "operator.approvals", + "operator.pairing", +] diff --git a/apps/macos/Sources/OpenClawMacCLI/WizardCommand.swift b/apps/macos/Sources/OpenClawMacCLI/WizardCommand.swift index 0a73fc2108c..ebe3e8ae626 100644 --- a/apps/macos/Sources/OpenClawMacCLI/WizardCommand.swift +++ b/apps/macos/Sources/OpenClawMacCLI/WizardCommand.swift @@ -251,7 +251,7 @@ actor GatewayWizardClient { let clientMode = "ui" let role = "operator" // Explicit scopes; gateway no longer defaults empty scopes to admin. - let scopes: [String] = ["operator.admin", "operator.approvals", "operator.pairing"] + let scopes = defaultOperatorConnectScopes let client: [String: ProtoAnyCodable] = [ "id": ProtoAnyCodable(clientId), "displayName": ProtoAnyCodable(Host.current().localizedName ?? "OpenClaw macOS Wizard CLI"), @@ -281,8 +281,8 @@ actor GatewayWizardClient { let identity = DeviceIdentityStore.loadOrCreate() let signedAtMs = Int(Date().timeIntervalSince1970 * 1000) let scopesValue = scopes.joined(separator: ",") - var payloadParts = [ - connectNonce == nil ? "v1" : "v2", + let payloadParts = [ + "v2", identity.deviceId, clientId, clientMode, @@ -290,23 +290,19 @@ actor GatewayWizardClient { scopesValue, String(signedAtMs), self.token ?? "", + connectNonce, ] - if let connectNonce { - payloadParts.append(connectNonce) - } let payload = payloadParts.joined(separator: "|") if let signature = DeviceIdentityStore.signPayload(payload, identity: identity), let publicKey = DeviceIdentityStore.publicKeyBase64Url(identity) { - var device: [String: ProtoAnyCodable] = [ + let device: [String: ProtoAnyCodable] = [ "id": ProtoAnyCodable(identity.deviceId), "publicKey": ProtoAnyCodable(publicKey), "signature": ProtoAnyCodable(signature), "signedAt": ProtoAnyCodable(signedAtMs), + "nonce": ProtoAnyCodable(connectNonce), ] - if let connectNonce { - device["nonce"] = ProtoAnyCodable(connectNonce) - } params["device"] = ProtoAnyCodable(device) } @@ -333,29 +329,24 @@ actor GatewayWizardClient { } } - private func waitForConnectChallenge() async throws -> String? { - guard let task = self.task else { return nil } - do { - return try await AsyncTimeout.withTimeout( - seconds: self.connectChallengeTimeoutSeconds, - onTimeout: { ConnectChallengeError.timeout }, - operation: { - while true { - let message = try await task.receive() - let frame = try await self.decodeFrame(message) - if case let .event(evt) = frame, evt.event == "connect.challenge" { - if let payload = evt.payload?.value as? [String: ProtoAnyCodable], - let nonce = payload["nonce"]?.value as? String - { - return nonce - } - } + private func waitForConnectChallenge() async throws -> String { + guard let task = self.task else { throw ConnectChallengeError.timeout } + return try await AsyncTimeout.withTimeout( + seconds: self.connectChallengeTimeoutSeconds, + onTimeout: { ConnectChallengeError.timeout }, + operation: { + while true { + let message = try await task.receive() + let frame = try await self.decodeFrame(message) + if case let .event(evt) = frame, evt.event == "connect.challenge", + let payload = evt.payload?.value as? [String: ProtoAnyCodable], + let nonce = payload["nonce"]?.value as? String, + nonce.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false + { + return nonce } - }) - } catch { - if error is ConnectChallengeError { return nil } - throw error - } + } + }) } } diff --git a/apps/macos/Sources/OpenClawProtocol/GatewayModels.swift b/apps/macos/Sources/OpenClawProtocol/GatewayModels.swift index 8486e4c4551..4e766514def 100644 --- a/apps/macos/Sources/OpenClawProtocol/GatewayModels.swift +++ b/apps/macos/Sources/OpenClawProtocol/GatewayModels.swift @@ -40,8 +40,8 @@ public struct ConnectParams: Codable, Sendable { device: [String: AnyCodable]?, auth: [String: AnyCodable]?, locale: String?, - useragent: String? - ) { + useragent: String?) + { self.minprotocol = minprotocol self.maxprotocol = maxprotocol self.client = client @@ -56,6 +56,7 @@ public struct ConnectParams: Codable, Sendable { self.locale = locale self.useragent = useragent } + private enum CodingKeys: String, CodingKey { case minprotocol = "minProtocol" case maxprotocol = "maxProtocol" @@ -91,8 +92,8 @@ public struct HelloOk: Codable, Sendable { snapshot: Snapshot, canvashosturl: String?, auth: [String: AnyCodable]?, - policy: [String: AnyCodable] - ) { + policy: [String: AnyCodable]) + { self.type = type self._protocol = _protocol self.server = server @@ -102,6 +103,7 @@ public struct HelloOk: Codable, Sendable { self.auth = auth self.policy = policy } + private enum CodingKeys: String, CodingKey { case type case _protocol = "protocol" @@ -124,13 +126,14 @@ public struct RequestFrame: Codable, Sendable { type: String, id: String, method: String, - params: AnyCodable? - ) { + params: AnyCodable?) + { self.type = type self.id = id self.method = method self.params = params } + private enum CodingKeys: String, CodingKey { case type case id @@ -151,14 +154,15 @@ public struct ResponseFrame: Codable, Sendable { id: String, ok: Bool, payload: AnyCodable?, - error: [String: AnyCodable]? - ) { + error: [String: AnyCodable]?) + { self.type = type self.id = id self.ok = ok self.payload = payload self.error = error } + private enum CodingKeys: String, CodingKey { case type case id @@ -180,14 +184,15 @@ public struct EventFrame: Codable, Sendable { event: String, payload: AnyCodable?, seq: Int?, - stateversion: [String: AnyCodable]? - ) { + stateversion: [String: AnyCodable]?) + { self.type = type self.event = event self.payload = payload self.seq = seq self.stateversion = stateversion } + private enum CodingKeys: String, CodingKey { case type case event @@ -231,8 +236,8 @@ public struct PresenceEntry: Codable, Sendable { deviceid: String?, roles: [String]?, scopes: [String]?, - instanceid: String? - ) { + instanceid: String?) + { self.host = host self.ip = ip self.version = version @@ -250,6 +255,7 @@ public struct PresenceEntry: Codable, Sendable { self.scopes = scopes self.instanceid = instanceid } + private enum CodingKeys: String, CodingKey { case host case ip @@ -276,11 +282,12 @@ public struct StateVersion: Codable, Sendable { public init( presence: Int, - health: Int - ) { + health: Int) + { self.presence = presence self.health = health } + private enum CodingKeys: String, CodingKey { case presence case health @@ -296,6 +303,7 @@ public struct Snapshot: Codable, Sendable { public let statedir: String? public let sessiondefaults: [String: AnyCodable]? public let authmode: AnyCodable? + public let updateavailable: [String: AnyCodable]? public init( presence: [PresenceEntry], @@ -305,8 +313,9 @@ public struct Snapshot: Codable, Sendable { configpath: String?, statedir: String?, sessiondefaults: [String: AnyCodable]?, - authmode: AnyCodable? - ) { + authmode: AnyCodable?, + updateavailable: [String: AnyCodable]?) + { self.presence = presence self.health = health self.stateversion = stateversion @@ -315,7 +324,9 @@ public struct Snapshot: Codable, Sendable { self.statedir = statedir self.sessiondefaults = sessiondefaults self.authmode = authmode + self.updateavailable = updateavailable } + private enum CodingKeys: String, CodingKey { case presence case health @@ -325,6 +336,7 @@ public struct Snapshot: Codable, Sendable { case statedir = "stateDir" case sessiondefaults = "sessionDefaults" case authmode = "authMode" + case updateavailable = "updateAvailable" } } @@ -340,14 +352,15 @@ public struct ErrorShape: Codable, Sendable { message: String, details: AnyCodable?, retryable: Bool?, - retryafterms: Int? - ) { + retryafterms: Int?) + { self.code = code self.message = message self.details = details self.retryable = retryable self.retryafterms = retryafterms } + private enum CodingKeys: String, CodingKey { case code case message @@ -369,14 +382,15 @@ public struct AgentEvent: Codable, Sendable { seq: Int, stream: String, ts: Int, - data: [String: AnyCodable] - ) { + data: [String: AnyCodable]) + { self.runid = runid self.seq = seq self.stream = stream self.ts = ts self.data = data } + private enum CodingKeys: String, CodingKey { case runid = "runId" case seq @@ -394,6 +408,7 @@ public struct SendParams: Codable, Sendable { public let gifplayback: Bool? public let channel: String? public let accountid: String? + public let threadid: String? public let sessionkey: String? public let idempotencykey: String @@ -405,9 +420,10 @@ public struct SendParams: Codable, Sendable { gifplayback: Bool?, channel: String?, accountid: String?, + threadid: String?, sessionkey: String?, - idempotencykey: String - ) { + idempotencykey: String) + { self.to = to self.message = message self.mediaurl = mediaurl @@ -415,9 +431,11 @@ public struct SendParams: Codable, Sendable { self.gifplayback = gifplayback self.channel = channel self.accountid = accountid + self.threadid = threadid self.sessionkey = sessionkey self.idempotencykey = idempotencykey } + private enum CodingKeys: String, CodingKey { case to case message @@ -426,6 +444,7 @@ public struct SendParams: Codable, Sendable { case gifplayback = "gifPlayback" case channel case accountid = "accountId" + case threadid = "threadId" case sessionkey = "sessionKey" case idempotencykey = "idempotencyKey" } @@ -457,8 +476,8 @@ public struct PollParams: Codable, Sendable { threadid: String?, channel: String?, accountid: String?, - idempotencykey: String - ) { + idempotencykey: String) + { self.to = to self.question = question self.options = options @@ -472,6 +491,7 @@ public struct PollParams: Codable, Sendable { self.accountid = accountid self.idempotencykey = idempotencykey } + private enum CodingKeys: String, CodingKey { case to case question @@ -507,6 +527,7 @@ public struct AgentParams: Codable, Sendable { public let groupchannel: String? public let groupspace: String? public let timeout: Int? + public let besteffortdeliver: Bool? public let lane: String? public let extrasystemprompt: String? public let inputprovenance: [String: AnyCodable]? @@ -533,13 +554,14 @@ public struct AgentParams: Codable, Sendable { groupchannel: String?, groupspace: String?, timeout: Int?, + besteffortdeliver: Bool?, lane: String?, extrasystemprompt: String?, inputprovenance: [String: AnyCodable]?, idempotencykey: String, label: String?, - spawnedby: String? - ) { + spawnedby: String?) + { self.message = message self.agentid = agentid self.to = to @@ -558,6 +580,7 @@ public struct AgentParams: Codable, Sendable { self.groupchannel = groupchannel self.groupspace = groupspace self.timeout = timeout + self.besteffortdeliver = besteffortdeliver self.lane = lane self.extrasystemprompt = extrasystemprompt self.inputprovenance = inputprovenance @@ -565,6 +588,7 @@ public struct AgentParams: Codable, Sendable { self.label = label self.spawnedby = spawnedby } + private enum CodingKeys: String, CodingKey { case message case agentid = "agentId" @@ -584,6 +608,7 @@ public struct AgentParams: Codable, Sendable { case groupchannel = "groupChannel" case groupspace = "groupSpace" case timeout + case besteffortdeliver = "bestEffortDeliver" case lane case extrasystemprompt = "extraSystemPrompt" case inputprovenance = "inputProvenance" @@ -599,11 +624,12 @@ public struct AgentIdentityParams: Codable, Sendable { public init( agentid: String?, - sessionkey: String? - ) { + sessionkey: String?) + { self.agentid = agentid self.sessionkey = sessionkey } + private enum CodingKeys: String, CodingKey { case agentid = "agentId" case sessionkey = "sessionKey" @@ -620,13 +646,14 @@ public struct AgentIdentityResult: Codable, Sendable { agentid: String, name: String?, avatar: String?, - emoji: String? - ) { + emoji: String?) + { self.agentid = agentid self.name = name self.avatar = avatar self.emoji = emoji } + private enum CodingKeys: String, CodingKey { case agentid = "agentId" case name @@ -641,11 +668,12 @@ public struct AgentWaitParams: Codable, Sendable { public init( runid: String, - timeoutms: Int? - ) { + timeoutms: Int?) + { self.runid = runid self.timeoutms = timeoutms } + private enum CodingKeys: String, CodingKey { case runid = "runId" case timeoutms = "timeoutMs" @@ -658,11 +686,12 @@ public struct WakeParams: Codable, Sendable { public init( mode: AnyCodable, - text: String - ) { + text: String) + { self.mode = mode self.text = text } + private enum CodingKeys: String, CodingKey { case mode case text @@ -695,8 +724,8 @@ public struct NodePairRequestParams: Codable, Sendable { caps: [String]?, commands: [String]?, remoteip: String?, - silent: Bool? - ) { + silent: Bool?) + { self.nodeid = nodeid self.displayname = displayname self.platform = platform @@ -710,6 +739,7 @@ public struct NodePairRequestParams: Codable, Sendable { self.remoteip = remoteip self.silent = silent } + private enum CodingKeys: String, CodingKey { case nodeid = "nodeId" case displayname = "displayName" @@ -726,17 +756,17 @@ public struct NodePairRequestParams: Codable, Sendable { } } -public struct NodePairListParams: Codable, Sendable { -} +public struct NodePairListParams: Codable, Sendable {} public struct NodePairApproveParams: Codable, Sendable { public let requestid: String public init( - requestid: String - ) { + requestid: String) + { self.requestid = requestid } + private enum CodingKeys: String, CodingKey { case requestid = "requestId" } @@ -746,10 +776,11 @@ public struct NodePairRejectParams: Codable, Sendable { public let requestid: String public init( - requestid: String - ) { + requestid: String) + { self.requestid = requestid } + private enum CodingKeys: String, CodingKey { case requestid = "requestId" } @@ -761,11 +792,12 @@ public struct NodePairVerifyParams: Codable, Sendable { public init( nodeid: String, - token: String - ) { + token: String) + { self.nodeid = nodeid self.token = token } + private enum CodingKeys: String, CodingKey { case nodeid = "nodeId" case token @@ -778,28 +810,29 @@ public struct NodeRenameParams: Codable, Sendable { public init( nodeid: String, - displayname: String - ) { + displayname: String) + { self.nodeid = nodeid self.displayname = displayname } + private enum CodingKeys: String, CodingKey { case nodeid = "nodeId" case displayname = "displayName" } } -public struct NodeListParams: Codable, Sendable { -} +public struct NodeListParams: Codable, Sendable {} public struct NodeDescribeParams: Codable, Sendable { public let nodeid: String public init( - nodeid: String - ) { + nodeid: String) + { self.nodeid = nodeid } + private enum CodingKeys: String, CodingKey { case nodeid = "nodeId" } @@ -817,14 +850,15 @@ public struct NodeInvokeParams: Codable, Sendable { command: String, params: AnyCodable?, timeoutms: Int?, - idempotencykey: String - ) { + idempotencykey: String) + { self.nodeid = nodeid self.command = command self.params = params self.timeoutms = timeoutms self.idempotencykey = idempotencykey } + private enum CodingKeys: String, CodingKey { case nodeid = "nodeId" case command @@ -848,8 +882,8 @@ public struct NodeInvokeResultParams: Codable, Sendable { ok: Bool, payload: AnyCodable?, payloadjson: String?, - error: [String: AnyCodable]? - ) { + error: [String: AnyCodable]?) + { self.id = id self.nodeid = nodeid self.ok = ok @@ -857,6 +891,7 @@ public struct NodeInvokeResultParams: Codable, Sendable { self.payloadjson = payloadjson self.error = error } + private enum CodingKeys: String, CodingKey { case id case nodeid = "nodeId" @@ -875,12 +910,13 @@ public struct NodeEventParams: Codable, Sendable { public init( event: String, payload: AnyCodable?, - payloadjson: String? - ) { + payloadjson: String?) + { self.event = event self.payload = payload self.payloadjson = payloadjson } + private enum CodingKeys: String, CodingKey { case event case payload @@ -902,8 +938,8 @@ public struct NodeInvokeRequestEvent: Codable, Sendable { command: String, paramsjson: String?, timeoutms: Int?, - idempotencykey: String? - ) { + idempotencykey: String?) + { self.id = id self.nodeid = nodeid self.command = command @@ -911,6 +947,7 @@ public struct NodeInvokeRequestEvent: Codable, Sendable { self.timeoutms = timeoutms self.idempotencykey = idempotencykey } + private enum CodingKeys: String, CodingKey { case id case nodeid = "nodeId" @@ -921,6 +958,70 @@ public struct NodeInvokeRequestEvent: Codable, Sendable { } } +public struct PushTestParams: Codable, Sendable { + public let nodeid: String + public let title: String? + public let body: String? + public let environment: String? + + public init( + nodeid: String, + title: String?, + body: String?, + environment: String?) + { + self.nodeid = nodeid + self.title = title + self.body = body + self.environment = environment + } + + private enum CodingKeys: String, CodingKey { + case nodeid = "nodeId" + case title + case body + case environment + } +} + +public struct PushTestResult: Codable, Sendable { + public let ok: Bool + public let status: Int + public let apnsid: String? + public let reason: String? + public let tokensuffix: String + public let topic: String + public let environment: String + + public init( + ok: Bool, + status: Int, + apnsid: String?, + reason: String?, + tokensuffix: String, + topic: String, + environment: String) + { + self.ok = ok + self.status = status + self.apnsid = apnsid + self.reason = reason + self.tokensuffix = tokensuffix + self.topic = topic + self.environment = environment + } + + private enum CodingKeys: String, CodingKey { + case ok + case status + case apnsid = "apnsId" + case reason + case tokensuffix = "tokenSuffix" + case topic + case environment + } +} + public struct SessionsListParams: Codable, Sendable { public let limit: Int? public let activeminutes: Int? @@ -943,8 +1044,8 @@ public struct SessionsListParams: Codable, Sendable { label: String?, spawnedby: String?, agentid: String?, - search: String? - ) { + search: String?) + { self.limit = limit self.activeminutes = activeminutes self.includeglobal = includeglobal @@ -956,6 +1057,7 @@ public struct SessionsListParams: Codable, Sendable { self.agentid = agentid self.search = search } + private enum CodingKeys: String, CodingKey { case limit case activeminutes = "activeMinutes" @@ -978,12 +1080,13 @@ public struct SessionsPreviewParams: Codable, Sendable { public init( keys: [String], limit: Int?, - maxchars: Int? - ) { + maxchars: Int?) + { self.keys = keys self.limit = limit self.maxchars = maxchars } + private enum CodingKeys: String, CodingKey { case keys case limit @@ -1007,8 +1110,8 @@ public struct SessionsResolveParams: Codable, Sendable { agentid: String?, spawnedby: String?, includeglobal: Bool?, - includeunknown: Bool? - ) { + includeunknown: Bool?) + { self.key = key self.sessionid = sessionid self.label = label @@ -1017,6 +1120,7 @@ public struct SessionsResolveParams: Codable, Sendable { self.includeglobal = includeglobal self.includeunknown = includeunknown } + private enum CodingKeys: String, CodingKey { case key case sessionid = "sessionId" @@ -1062,8 +1166,8 @@ public struct SessionsPatchParams: Codable, Sendable { spawnedby: AnyCodable?, spawndepth: AnyCodable?, sendpolicy: AnyCodable?, - groupactivation: AnyCodable? - ) { + groupactivation: AnyCodable?) + { self.key = key self.label = label self.thinkinglevel = thinkinglevel @@ -1081,6 +1185,7 @@ public struct SessionsPatchParams: Codable, Sendable { self.sendpolicy = sendpolicy self.groupactivation = groupactivation } + private enum CodingKeys: String, CodingKey { case key case label @@ -1107,11 +1212,12 @@ public struct SessionsResetParams: Codable, Sendable { public init( key: String, - reason: AnyCodable? - ) { + reason: AnyCodable?) + { self.key = key self.reason = reason } + private enum CodingKeys: String, CodingKey { case key case reason @@ -1121,17 +1227,22 @@ public struct SessionsResetParams: Codable, Sendable { public struct SessionsDeleteParams: Codable, Sendable { public let key: String public let deletetranscript: Bool? + public let emitlifecyclehooks: Bool? public init( key: String, - deletetranscript: Bool? - ) { + deletetranscript: Bool?, + emitlifecyclehooks: Bool?) + { self.key = key self.deletetranscript = deletetranscript + self.emitlifecyclehooks = emitlifecyclehooks } + private enum CodingKeys: String, CodingKey { case key case deletetranscript = "deleteTranscript" + case emitlifecyclehooks = "emitLifecycleHooks" } } @@ -1141,11 +1252,12 @@ public struct SessionsCompactParams: Codable, Sendable { public init( key: String, - maxlines: Int? - ) { + maxlines: Int?) + { self.key = key self.maxlines = maxlines } + private enum CodingKeys: String, CodingKey { case key case maxlines = "maxLines" @@ -1156,6 +1268,8 @@ public struct SessionsUsageParams: Codable, Sendable { public let key: String? public let startdate: String? public let enddate: String? + public let mode: AnyCodable? + public let utcoffset: String? public let limit: Int? public let includecontextweight: Bool? @@ -1163,26 +1277,32 @@ public struct SessionsUsageParams: Codable, Sendable { key: String?, startdate: String?, enddate: String?, + mode: AnyCodable?, + utcoffset: String?, limit: Int?, - includecontextweight: Bool? - ) { + includecontextweight: Bool?) + { self.key = key self.startdate = startdate self.enddate = enddate + self.mode = mode + self.utcoffset = utcoffset self.limit = limit self.includecontextweight = includecontextweight } + private enum CodingKeys: String, CodingKey { case key case startdate = "startDate" case enddate = "endDate" + case mode + case utcoffset = "utcOffset" case limit case includecontextweight = "includeContextWeight" } } -public struct ConfigGetParams: Codable, Sendable { -} +public struct ConfigGetParams: Codable, Sendable {} public struct ConfigSetParams: Codable, Sendable { public let raw: String @@ -1190,11 +1310,12 @@ public struct ConfigSetParams: Codable, Sendable { public init( raw: String, - basehash: String? - ) { + basehash: String?) + { self.raw = raw self.basehash = basehash } + private enum CodingKeys: String, CodingKey { case raw case basehash = "baseHash" @@ -1213,14 +1334,15 @@ public struct ConfigApplyParams: Codable, Sendable { basehash: String?, sessionkey: String?, note: String?, - restartdelayms: Int? - ) { + restartdelayms: Int?) + { self.raw = raw self.basehash = basehash self.sessionkey = sessionkey self.note = note self.restartdelayms = restartdelayms } + private enum CodingKeys: String, CodingKey { case raw case basehash = "baseHash" @@ -1242,14 +1364,15 @@ public struct ConfigPatchParams: Codable, Sendable { basehash: String?, sessionkey: String?, note: String?, - restartdelayms: Int? - ) { + restartdelayms: Int?) + { self.raw = raw self.basehash = basehash self.sessionkey = sessionkey self.note = note self.restartdelayms = restartdelayms } + private enum CodingKeys: String, CodingKey { case raw case basehash = "baseHash" @@ -1259,8 +1382,7 @@ public struct ConfigPatchParams: Codable, Sendable { } } -public struct ConfigSchemaParams: Codable, Sendable { -} +public struct ConfigSchemaParams: Codable, Sendable {} public struct ConfigSchemaResponse: Codable, Sendable { public let schema: AnyCodable @@ -1272,13 +1394,14 @@ public struct ConfigSchemaResponse: Codable, Sendable { schema: AnyCodable, uihints: [String: AnyCodable], version: String, - generatedat: String - ) { + generatedat: String) + { self.schema = schema self.uihints = uihints self.version = version self.generatedat = generatedat } + private enum CodingKeys: String, CodingKey { case schema case uihints = "uiHints" @@ -1293,11 +1416,12 @@ public struct WizardStartParams: Codable, Sendable { public init( mode: AnyCodable?, - workspace: String? - ) { + workspace: String?) + { self.mode = mode self.workspace = workspace } + private enum CodingKeys: String, CodingKey { case mode case workspace @@ -1310,11 +1434,12 @@ public struct WizardNextParams: Codable, Sendable { public init( sessionid: String, - answer: [String: AnyCodable]? - ) { + answer: [String: AnyCodable]?) + { self.sessionid = sessionid self.answer = answer } + private enum CodingKeys: String, CodingKey { case sessionid = "sessionId" case answer @@ -1325,10 +1450,11 @@ public struct WizardCancelParams: Codable, Sendable { public let sessionid: String public init( - sessionid: String - ) { + sessionid: String) + { self.sessionid = sessionid } + private enum CodingKeys: String, CodingKey { case sessionid = "sessionId" } @@ -1338,10 +1464,11 @@ public struct WizardStatusParams: Codable, Sendable { public let sessionid: String public init( - sessionid: String - ) { + sessionid: String) + { self.sessionid = sessionid } + private enum CodingKeys: String, CodingKey { case sessionid = "sessionId" } @@ -1367,8 +1494,8 @@ public struct WizardStep: Codable, Sendable { initialvalue: AnyCodable?, placeholder: String?, sensitive: Bool?, - executor: AnyCodable? - ) { + executor: AnyCodable?) + { self.id = id self.type = type self.title = title @@ -1379,6 +1506,7 @@ public struct WizardStep: Codable, Sendable { self.sensitive = sensitive self.executor = executor } + private enum CodingKeys: String, CodingKey { case id case type @@ -1402,13 +1530,14 @@ public struct WizardNextResult: Codable, Sendable { done: Bool, step: [String: AnyCodable]?, status: AnyCodable?, - error: String? - ) { + error: String?) + { self.done = done self.step = step self.status = status self.error = error } + private enum CodingKeys: String, CodingKey { case done case step @@ -1429,14 +1558,15 @@ public struct WizardStartResult: Codable, Sendable { done: Bool, step: [String: AnyCodable]?, status: AnyCodable?, - error: String? - ) { + error: String?) + { self.sessionid = sessionid self.done = done self.step = step self.status = status self.error = error } + private enum CodingKeys: String, CodingKey { case sessionid = "sessionId" case done @@ -1452,11 +1582,12 @@ public struct WizardStatusResult: Codable, Sendable { public init( status: AnyCodable, - error: String? - ) { + error: String?) + { self.status = status self.error = error } + private enum CodingKeys: String, CodingKey { case status case error @@ -1469,11 +1600,12 @@ public struct TalkModeParams: Codable, Sendable { public init( enabled: Bool, - phase: String? - ) { + phase: String?) + { self.enabled = enabled self.phase = phase } + private enum CodingKeys: String, CodingKey { case enabled case phase @@ -1484,10 +1616,11 @@ public struct TalkConfigParams: Codable, Sendable { public let includesecrets: Bool? public init( - includesecrets: Bool? - ) { + includesecrets: Bool?) + { self.includesecrets = includesecrets } + private enum CodingKeys: String, CodingKey { case includesecrets = "includeSecrets" } @@ -1497,10 +1630,11 @@ public struct TalkConfigResult: Codable, Sendable { public let config: [String: AnyCodable] public init( - config: [String: AnyCodable] - ) { + config: [String: AnyCodable]) + { self.config = config } + private enum CodingKeys: String, CodingKey { case config } @@ -1512,11 +1646,12 @@ public struct ChannelsStatusParams: Codable, Sendable { public init( probe: Bool?, - timeoutms: Int? - ) { + timeoutms: Int?) + { self.probe = probe self.timeoutms = timeoutms } + private enum CodingKeys: String, CodingKey { case probe case timeoutms = "timeoutMs" @@ -1543,8 +1678,8 @@ public struct ChannelsStatusResult: Codable, Sendable { channelmeta: [[String: AnyCodable]]?, channels: [String: AnyCodable], channelaccounts: [String: AnyCodable], - channeldefaultaccountid: [String: AnyCodable] - ) { + channeldefaultaccountid: [String: AnyCodable]) + { self.ts = ts self.channelorder = channelorder self.channellabels = channellabels @@ -1555,6 +1690,7 @@ public struct ChannelsStatusResult: Codable, Sendable { self.channelaccounts = channelaccounts self.channeldefaultaccountid = channeldefaultaccountid } + private enum CodingKeys: String, CodingKey { case ts case channelorder = "channelOrder" @@ -1574,11 +1710,12 @@ public struct ChannelsLogoutParams: Codable, Sendable { public init( channel: String, - accountid: String? - ) { + accountid: String?) + { self.channel = channel self.accountid = accountid } + private enum CodingKeys: String, CodingKey { case channel case accountid = "accountId" @@ -1595,13 +1732,14 @@ public struct WebLoginStartParams: Codable, Sendable { force: Bool?, timeoutms: Int?, verbose: Bool?, - accountid: String? - ) { + accountid: String?) + { self.force = force self.timeoutms = timeoutms self.verbose = verbose self.accountid = accountid } + private enum CodingKeys: String, CodingKey { case force case timeoutms = "timeoutMs" @@ -1616,11 +1754,12 @@ public struct WebLoginWaitParams: Codable, Sendable { public init( timeoutms: Int?, - accountid: String? - ) { + accountid: String?) + { self.timeoutms = timeoutms self.accountid = accountid } + private enum CodingKeys: String, CodingKey { case timeoutms = "timeoutMs" case accountid = "accountId" @@ -1635,12 +1774,13 @@ public struct AgentSummary: Codable, Sendable { public init( id: String, name: String?, - identity: [String: AnyCodable]? - ) { + identity: [String: AnyCodable]?) + { self.id = id self.name = name self.identity = identity } + private enum CodingKeys: String, CodingKey { case id case name @@ -1658,13 +1798,14 @@ public struct AgentsCreateParams: Codable, Sendable { name: String, workspace: String, emoji: String?, - avatar: String? - ) { + avatar: String?) + { self.name = name self.workspace = workspace self.emoji = emoji self.avatar = avatar } + private enum CodingKeys: String, CodingKey { case name case workspace @@ -1683,13 +1824,14 @@ public struct AgentsCreateResult: Codable, Sendable { ok: Bool, agentid: String, name: String, - workspace: String - ) { + workspace: String) + { self.ok = ok self.agentid = agentid self.name = name self.workspace = workspace } + private enum CodingKeys: String, CodingKey { case ok case agentid = "agentId" @@ -1710,14 +1852,15 @@ public struct AgentsUpdateParams: Codable, Sendable { name: String?, workspace: String?, model: String?, - avatar: String? - ) { + avatar: String?) + { self.agentid = agentid self.name = name self.workspace = workspace self.model = model self.avatar = avatar } + private enum CodingKeys: String, CodingKey { case agentid = "agentId" case name @@ -1733,11 +1876,12 @@ public struct AgentsUpdateResult: Codable, Sendable { public init( ok: Bool, - agentid: String - ) { + agentid: String) + { self.ok = ok self.agentid = agentid } + private enum CodingKeys: String, CodingKey { case ok case agentid = "agentId" @@ -1750,11 +1894,12 @@ public struct AgentsDeleteParams: Codable, Sendable { public init( agentid: String, - deletefiles: Bool? - ) { + deletefiles: Bool?) + { self.agentid = agentid self.deletefiles = deletefiles } + private enum CodingKeys: String, CodingKey { case agentid = "agentId" case deletefiles = "deleteFiles" @@ -1769,12 +1914,13 @@ public struct AgentsDeleteResult: Codable, Sendable { public init( ok: Bool, agentid: String, - removedbindings: Int - ) { + removedbindings: Int) + { self.ok = ok self.agentid = agentid self.removedbindings = removedbindings } + private enum CodingKeys: String, CodingKey { case ok case agentid = "agentId" @@ -1796,8 +1942,8 @@ public struct AgentsFileEntry: Codable, Sendable { missing: Bool, size: Int?, updatedatms: Int?, - content: String? - ) { + content: String?) + { self.name = name self.path = path self.missing = missing @@ -1805,6 +1951,7 @@ public struct AgentsFileEntry: Codable, Sendable { self.updatedatms = updatedatms self.content = content } + private enum CodingKeys: String, CodingKey { case name case path @@ -1819,10 +1966,11 @@ public struct AgentsFilesListParams: Codable, Sendable { public let agentid: String public init( - agentid: String - ) { + agentid: String) + { self.agentid = agentid } + private enum CodingKeys: String, CodingKey { case agentid = "agentId" } @@ -1836,12 +1984,13 @@ public struct AgentsFilesListResult: Codable, Sendable { public init( agentid: String, workspace: String, - files: [AgentsFileEntry] - ) { + files: [AgentsFileEntry]) + { self.agentid = agentid self.workspace = workspace self.files = files } + private enum CodingKeys: String, CodingKey { case agentid = "agentId" case workspace @@ -1855,11 +2004,12 @@ public struct AgentsFilesGetParams: Codable, Sendable { public init( agentid: String, - name: String - ) { + name: String) + { self.agentid = agentid self.name = name } + private enum CodingKeys: String, CodingKey { case agentid = "agentId" case name @@ -1874,12 +2024,13 @@ public struct AgentsFilesGetResult: Codable, Sendable { public init( agentid: String, workspace: String, - file: AgentsFileEntry - ) { + file: AgentsFileEntry) + { self.agentid = agentid self.workspace = workspace self.file = file } + private enum CodingKeys: String, CodingKey { case agentid = "agentId" case workspace @@ -1895,12 +2046,13 @@ public struct AgentsFilesSetParams: Codable, Sendable { public init( agentid: String, name: String, - content: String - ) { + content: String) + { self.agentid = agentid self.name = name self.content = content } + private enum CodingKeys: String, CodingKey { case agentid = "agentId" case name @@ -1918,13 +2070,14 @@ public struct AgentsFilesSetResult: Codable, Sendable { ok: Bool, agentid: String, workspace: String, - file: AgentsFileEntry - ) { + file: AgentsFileEntry) + { self.ok = ok self.agentid = agentid self.workspace = workspace self.file = file } + private enum CodingKeys: String, CodingKey { case ok case agentid = "agentId" @@ -1933,8 +2086,7 @@ public struct AgentsFilesSetResult: Codable, Sendable { } } -public struct AgentsListParams: Codable, Sendable { -} +public struct AgentsListParams: Codable, Sendable {} public struct AgentsListResult: Codable, Sendable { public let defaultid: String @@ -1946,13 +2098,14 @@ public struct AgentsListResult: Codable, Sendable { defaultid: String, mainkey: String, scope: AnyCodable, - agents: [AgentSummary] - ) { + agents: [AgentSummary]) + { self.defaultid = defaultid self.mainkey = mainkey self.scope = scope self.agents = agents } + private enum CodingKeys: String, CodingKey { case defaultid = "defaultId" case mainkey = "mainKey" @@ -1973,14 +2126,15 @@ public struct ModelChoice: Codable, Sendable { name: String, provider: String, contextwindow: Int?, - reasoning: Bool? - ) { + reasoning: Bool?) + { self.id = id self.name = name self.provider = provider self.contextwindow = contextwindow self.reasoning = reasoning } + private enum CodingKeys: String, CodingKey { case id case name @@ -1990,17 +2144,17 @@ public struct ModelChoice: Codable, Sendable { } } -public struct ModelsListParams: Codable, Sendable { -} +public struct ModelsListParams: Codable, Sendable {} public struct ModelsListResult: Codable, Sendable { public let models: [ModelChoice] public init( - models: [ModelChoice] - ) { + models: [ModelChoice]) + { self.models = models } + private enum CodingKeys: String, CodingKey { case models } @@ -2010,26 +2164,153 @@ public struct SkillsStatusParams: Codable, Sendable { public let agentid: String? public init( - agentid: String? - ) { + agentid: String?) + { self.agentid = agentid } + private enum CodingKeys: String, CodingKey { case agentid = "agentId" } } -public struct SkillsBinsParams: Codable, Sendable { +public struct ToolsCatalogParams: Codable, Sendable { + public let agentid: String? + public let includeplugins: Bool? + + public init( + agentid: String?, + includeplugins: Bool?) + { + self.agentid = agentid + self.includeplugins = includeplugins + } + + private enum CodingKeys: String, CodingKey { + case agentid = "agentId" + case includeplugins = "includePlugins" + } } +public struct ToolCatalogProfile: Codable, Sendable { + public let id: AnyCodable + public let label: String + + public init( + id: AnyCodable, + label: String) + { + self.id = id + self.label = label + } + + private enum CodingKeys: String, CodingKey { + case id + case label + } +} + +public struct ToolCatalogEntry: Codable, Sendable { + public let id: String + public let label: String + public let description: String + public let source: AnyCodable + public let pluginid: String? + public let optional: Bool? + public let defaultprofiles: [AnyCodable] + + public init( + id: String, + label: String, + description: String, + source: AnyCodable, + pluginid: String?, + optional: Bool?, + defaultprofiles: [AnyCodable]) + { + self.id = id + self.label = label + self.description = description + self.source = source + self.pluginid = pluginid + self.optional = optional + self.defaultprofiles = defaultprofiles + } + + private enum CodingKeys: String, CodingKey { + case id + case label + case description + case source + case pluginid = "pluginId" + case optional + case defaultprofiles = "defaultProfiles" + } +} + +public struct ToolCatalogGroup: Codable, Sendable { + public let id: String + public let label: String + public let source: AnyCodable + public let pluginid: String? + public let tools: [ToolCatalogEntry] + + public init( + id: String, + label: String, + source: AnyCodable, + pluginid: String?, + tools: [ToolCatalogEntry]) + { + self.id = id + self.label = label + self.source = source + self.pluginid = pluginid + self.tools = tools + } + + private enum CodingKeys: String, CodingKey { + case id + case label + case source + case pluginid = "pluginId" + case tools + } +} + +public struct ToolsCatalogResult: Codable, Sendable { + public let agentid: String + public let profiles: [ToolCatalogProfile] + public let groups: [ToolCatalogGroup] + + public init( + agentid: String, + profiles: [ToolCatalogProfile], + groups: [ToolCatalogGroup]) + { + self.agentid = agentid + self.profiles = profiles + self.groups = groups + } + + private enum CodingKeys: String, CodingKey { + case agentid = "agentId" + case profiles + case groups + } +} + +public struct SkillsBinsParams: Codable, Sendable {} + public struct SkillsBinsResult: Codable, Sendable { public let bins: [String] public init( - bins: [String] - ) { + bins: [String]) + { self.bins = bins } + private enum CodingKeys: String, CodingKey { case bins } @@ -2043,12 +2324,13 @@ public struct SkillsInstallParams: Codable, Sendable { public init( name: String, installid: String, - timeoutms: Int? - ) { + timeoutms: Int?) + { self.name = name self.installid = installid self.timeoutms = timeoutms } + private enum CodingKeys: String, CodingKey { case name case installid = "installId" @@ -2066,13 +2348,14 @@ public struct SkillsUpdateParams: Codable, Sendable { skillkey: String, enabled: Bool?, apikey: String?, - env: [String: AnyCodable]? - ) { + env: [String: AnyCodable]?) + { self.skillkey = skillkey self.enabled = enabled self.apikey = apikey self.env = env } + private enum CodingKeys: String, CodingKey { case skillkey = "skillKey" case enabled @@ -2084,6 +2367,7 @@ public struct SkillsUpdateParams: Codable, Sendable { public struct CronJob: Codable, Sendable { public let id: String public let agentid: String? + public let sessionkey: String? public let name: String public let description: String? public let enabled: Bool @@ -2100,6 +2384,7 @@ public struct CronJob: Codable, Sendable { public init( id: String, agentid: String?, + sessionkey: String?, name: String, description: String?, enabled: Bool, @@ -2111,10 +2396,11 @@ public struct CronJob: Codable, Sendable { wakemode: AnyCodable, payload: AnyCodable, delivery: AnyCodable?, - state: [String: AnyCodable] - ) { + state: [String: AnyCodable]) + { self.id = id self.agentid = agentid + self.sessionkey = sessionkey self.name = name self.description = description self.enabled = enabled @@ -2128,9 +2414,11 @@ public struct CronJob: Codable, Sendable { self.delivery = delivery self.state = state } + private enum CodingKeys: String, CodingKey { case id case agentid = "agentId" + case sessionkey = "sessionKey" case name case description case enabled @@ -2148,23 +2436,48 @@ public struct CronJob: Codable, Sendable { public struct CronListParams: Codable, Sendable { public let includedisabled: Bool? + public let limit: Int? + public let offset: Int? + public let query: String? + public let enabled: AnyCodable? + public let sortby: AnyCodable? + public let sortdir: AnyCodable? public init( - includedisabled: Bool? - ) { + includedisabled: Bool?, + limit: Int?, + offset: Int?, + query: String?, + enabled: AnyCodable?, + sortby: AnyCodable?, + sortdir: AnyCodable?) + { self.includedisabled = includedisabled + self.limit = limit + self.offset = offset + self.query = query + self.enabled = enabled + self.sortby = sortby + self.sortdir = sortdir } + private enum CodingKeys: String, CodingKey { case includedisabled = "includeDisabled" + case limit + case offset + case query + case enabled + case sortby = "sortBy" + case sortdir = "sortDir" } } -public struct CronStatusParams: Codable, Sendable { -} +public struct CronStatusParams: Codable, Sendable {} public struct CronAddParams: Codable, Sendable { public let name: String public let agentid: AnyCodable? + public let sessionkey: AnyCodable? public let description: String? public let enabled: Bool? public let deleteafterrun: Bool? @@ -2177,6 +2490,7 @@ public struct CronAddParams: Codable, Sendable { public init( name: String, agentid: AnyCodable?, + sessionkey: AnyCodable?, description: String?, enabled: Bool?, deleteafterrun: Bool?, @@ -2184,10 +2498,11 @@ public struct CronAddParams: Codable, Sendable { sessiontarget: AnyCodable, wakemode: AnyCodable, payload: AnyCodable, - delivery: AnyCodable? - ) { + delivery: AnyCodable?) + { self.name = name self.agentid = agentid + self.sessionkey = sessionkey self.description = description self.enabled = enabled self.deleteafterrun = deleteafterrun @@ -2197,9 +2512,11 @@ public struct CronAddParams: Codable, Sendable { self.payload = payload self.delivery = delivery } + private enum CodingKeys: String, CodingKey { case name case agentid = "agentId" + case sessionkey = "sessionKey" case description case enabled case deleteafterrun = "deleteAfterRun" @@ -2211,6 +2528,60 @@ public struct CronAddParams: Codable, Sendable { } } +public struct CronRunsParams: Codable, Sendable { + public let scope: AnyCodable? + public let id: String? + public let jobid: String? + public let limit: Int? + public let offset: Int? + public let statuses: [AnyCodable]? + public let status: AnyCodable? + public let deliverystatuses: [AnyCodable]? + public let deliverystatus: AnyCodable? + public let query: String? + public let sortdir: AnyCodable? + + public init( + scope: AnyCodable?, + id: String?, + jobid: String?, + limit: Int?, + offset: Int?, + statuses: [AnyCodable]?, + status: AnyCodable?, + deliverystatuses: [AnyCodable]?, + deliverystatus: AnyCodable?, + query: String?, + sortdir: AnyCodable?) + { + self.scope = scope + self.id = id + self.jobid = jobid + self.limit = limit + self.offset = offset + self.statuses = statuses + self.status = status + self.deliverystatuses = deliverystatuses + self.deliverystatus = deliverystatus + self.query = query + self.sortdir = sortdir + } + + private enum CodingKeys: String, CodingKey { + case scope + case id + case jobid = "jobId" + case limit + case offset + case statuses + case status + case deliverystatuses = "deliveryStatuses" + case deliverystatus = "deliveryStatus" + case query + case sortdir = "sortDir" + } +} + public struct CronRunLogEntry: Codable, Sendable { public let ts: Int public let jobid: String @@ -2218,11 +2589,18 @@ public struct CronRunLogEntry: Codable, Sendable { public let status: AnyCodable? public let error: String? public let summary: String? + public let delivered: Bool? + public let deliverystatus: AnyCodable? + public let deliveryerror: String? public let sessionid: String? public let sessionkey: String? public let runatms: Int? public let durationms: Int? public let nextrunatms: Int? + public let model: String? + public let provider: String? + public let usage: [String: AnyCodable]? + public let jobname: String? public init( ts: Int, @@ -2231,24 +2609,39 @@ public struct CronRunLogEntry: Codable, Sendable { status: AnyCodable?, error: String?, summary: String?, + delivered: Bool?, + deliverystatus: AnyCodable?, + deliveryerror: String?, sessionid: String?, sessionkey: String?, runatms: Int?, durationms: Int?, - nextrunatms: Int? - ) { + nextrunatms: Int?, + model: String?, + provider: String?, + usage: [String: AnyCodable]?, + jobname: String?) + { self.ts = ts self.jobid = jobid self.action = action self.status = status self.error = error self.summary = summary + self.delivered = delivered + self.deliverystatus = deliverystatus + self.deliveryerror = deliveryerror self.sessionid = sessionid self.sessionkey = sessionkey self.runatms = runatms self.durationms = durationms self.nextrunatms = nextrunatms + self.model = model + self.provider = provider + self.usage = usage + self.jobname = jobname } + private enum CodingKeys: String, CodingKey { case ts case jobid = "jobId" @@ -2256,11 +2649,18 @@ public struct CronRunLogEntry: Codable, Sendable { case status case error case summary + case delivered + case deliverystatus = "deliveryStatus" + case deliveryerror = "deliveryError" case sessionid = "sessionId" case sessionkey = "sessionKey" case runatms = "runAtMs" case durationms = "durationMs" case nextrunatms = "nextRunAtMs" + case model + case provider + case usage + case jobname = "jobName" } } @@ -2272,12 +2672,13 @@ public struct LogsTailParams: Codable, Sendable { public init( cursor: Int?, limit: Int?, - maxbytes: Int? - ) { + maxbytes: Int?) + { self.cursor = cursor self.limit = limit self.maxbytes = maxbytes } + private enum CodingKeys: String, CodingKey { case cursor case limit @@ -2299,8 +2700,8 @@ public struct LogsTailResult: Codable, Sendable { size: Int, lines: [String], truncated: Bool?, - reset: Bool? - ) { + reset: Bool?) + { self.file = file self.cursor = cursor self.size = size @@ -2308,6 +2709,7 @@ public struct LogsTailResult: Codable, Sendable { self.truncated = truncated self.reset = reset } + private enum CodingKeys: String, CodingKey { case file case cursor @@ -2318,8 +2720,7 @@ public struct LogsTailResult: Codable, Sendable { } } -public struct ExecApprovalsGetParams: Codable, Sendable { -} +public struct ExecApprovalsGetParams: Codable, Sendable {} public struct ExecApprovalsSetParams: Codable, Sendable { public let file: [String: AnyCodable] @@ -2327,11 +2728,12 @@ public struct ExecApprovalsSetParams: Codable, Sendable { public init( file: [String: AnyCodable], - basehash: String? - ) { + basehash: String?) + { self.file = file self.basehash = basehash } + private enum CodingKeys: String, CodingKey { case file case basehash = "baseHash" @@ -2342,10 +2744,11 @@ public struct ExecApprovalsNodeGetParams: Codable, Sendable { public let nodeid: String public init( - nodeid: String - ) { + nodeid: String) + { self.nodeid = nodeid } + private enum CodingKeys: String, CodingKey { case nodeid = "nodeId" } @@ -2359,12 +2762,13 @@ public struct ExecApprovalsNodeSetParams: Codable, Sendable { public init( nodeid: String, file: [String: AnyCodable], - basehash: String? - ) { + basehash: String?) + { self.nodeid = nodeid self.file = file self.basehash = basehash } + private enum CodingKeys: String, CodingKey { case nodeid = "nodeId" case file @@ -2382,13 +2786,14 @@ public struct ExecApprovalsSnapshot: Codable, Sendable { path: String, exists: Bool, hash: String, - file: [String: AnyCodable] - ) { + file: [String: AnyCodable]) + { self.path = path self.exists = exists self.hash = hash self.file = file } + private enum CodingKeys: String, CodingKey { case path case exists @@ -2401,6 +2806,7 @@ public struct ExecApprovalRequestParams: Codable, Sendable { public let id: String? public let command: String public let cwd: AnyCodable? + public let nodeid: AnyCodable? public let host: AnyCodable? public let security: AnyCodable? public let ask: AnyCodable? @@ -2414,6 +2820,7 @@ public struct ExecApprovalRequestParams: Codable, Sendable { id: String?, command: String, cwd: AnyCodable?, + nodeid: AnyCodable?, host: AnyCodable?, security: AnyCodable?, ask: AnyCodable?, @@ -2421,11 +2828,12 @@ public struct ExecApprovalRequestParams: Codable, Sendable { resolvedpath: AnyCodable?, sessionkey: AnyCodable?, timeoutms: Int?, - twophase: Bool? - ) { + twophase: Bool?) + { self.id = id self.command = command self.cwd = cwd + self.nodeid = nodeid self.host = host self.security = security self.ask = ask @@ -2435,10 +2843,12 @@ public struct ExecApprovalRequestParams: Codable, Sendable { self.timeoutms = timeoutms self.twophase = twophase } + private enum CodingKeys: String, CodingKey { case id case command case cwd + case nodeid = "nodeId" case host case security case ask @@ -2456,28 +2866,29 @@ public struct ExecApprovalResolveParams: Codable, Sendable { public init( id: String, - decision: String - ) { + decision: String) + { self.id = id self.decision = decision } + private enum CodingKeys: String, CodingKey { case id case decision } } -public struct DevicePairListParams: Codable, Sendable { -} +public struct DevicePairListParams: Codable, Sendable {} public struct DevicePairApproveParams: Codable, Sendable { public let requestid: String public init( - requestid: String - ) { + requestid: String) + { self.requestid = requestid } + private enum CodingKeys: String, CodingKey { case requestid = "requestId" } @@ -2487,15 +2898,30 @@ public struct DevicePairRejectParams: Codable, Sendable { public let requestid: String public init( - requestid: String - ) { + requestid: String) + { self.requestid = requestid } + private enum CodingKeys: String, CodingKey { case requestid = "requestId" } } +public struct DevicePairRemoveParams: Codable, Sendable { + public let deviceid: String + + public init( + deviceid: String) + { + self.deviceid = deviceid + } + + private enum CodingKeys: String, CodingKey { + case deviceid = "deviceId" + } +} + public struct DeviceTokenRotateParams: Codable, Sendable { public let deviceid: String public let role: String @@ -2504,12 +2930,13 @@ public struct DeviceTokenRotateParams: Codable, Sendable { public init( deviceid: String, role: String, - scopes: [String]? - ) { + scopes: [String]?) + { self.deviceid = deviceid self.role = role self.scopes = scopes } + private enum CodingKeys: String, CodingKey { case deviceid = "deviceId" case role @@ -2523,11 +2950,12 @@ public struct DeviceTokenRevokeParams: Codable, Sendable { public init( deviceid: String, - role: String - ) { + role: String) + { self.deviceid = deviceid self.role = role } + private enum CodingKeys: String, CodingKey { case deviceid = "deviceId" case role @@ -2564,8 +2992,8 @@ public struct DevicePairRequestedEvent: Codable, Sendable { remoteip: String?, silent: Bool?, isrepair: Bool?, - ts: Int - ) { + ts: Int) + { self.requestid = requestid self.deviceid = deviceid self.publickey = publickey @@ -2581,6 +3009,7 @@ public struct DevicePairRequestedEvent: Codable, Sendable { self.isrepair = isrepair self.ts = ts } + private enum CodingKeys: String, CodingKey { case requestid = "requestId" case deviceid = "deviceId" @@ -2609,13 +3038,14 @@ public struct DevicePairResolvedEvent: Codable, Sendable { requestid: String, deviceid: String, decision: String, - ts: Int - ) { + ts: Int) + { self.requestid = requestid self.deviceid = deviceid self.decision = decision self.ts = ts } + private enum CodingKeys: String, CodingKey { case requestid = "requestId" case deviceid = "deviceId" @@ -2630,11 +3060,12 @@ public struct ChatHistoryParams: Codable, Sendable { public init( sessionkey: String, - limit: Int? - ) { + limit: Int?) + { self.sessionkey = sessionkey self.limit = limit } + private enum CodingKeys: String, CodingKey { case sessionkey = "sessionKey" case limit @@ -2657,8 +3088,8 @@ public struct ChatSendParams: Codable, Sendable { deliver: Bool?, attachments: [AnyCodable]?, timeoutms: Int?, - idempotencykey: String - ) { + idempotencykey: String) + { self.sessionkey = sessionkey self.message = message self.thinking = thinking @@ -2667,6 +3098,7 @@ public struct ChatSendParams: Codable, Sendable { self.timeoutms = timeoutms self.idempotencykey = idempotencykey } + private enum CodingKeys: String, CodingKey { case sessionkey = "sessionKey" case message @@ -2684,11 +3116,12 @@ public struct ChatAbortParams: Codable, Sendable { public init( sessionkey: String, - runid: String? - ) { + runid: String?) + { self.sessionkey = sessionkey self.runid = runid } + private enum CodingKeys: String, CodingKey { case sessionkey = "sessionKey" case runid = "runId" @@ -2703,12 +3136,13 @@ public struct ChatInjectParams: Codable, Sendable { public init( sessionkey: String, message: String, - label: String? - ) { + label: String?) + { self.sessionkey = sessionkey self.message = message self.label = label } + private enum CodingKeys: String, CodingKey { case sessionkey = "sessionKey" case message @@ -2734,8 +3168,8 @@ public struct ChatEvent: Codable, Sendable { message: AnyCodable?, errormessage: String?, usage: AnyCodable?, - stopreason: String? - ) { + stopreason: String?) + { self.runid = runid self.sessionkey = sessionkey self.seq = seq @@ -2745,6 +3179,7 @@ public struct ChatEvent: Codable, Sendable { self.usage = usage self.stopreason = stopreason } + private enum CodingKeys: String, CodingKey { case runid = "runId" case sessionkey = "sessionKey" @@ -2767,13 +3202,14 @@ public struct UpdateRunParams: Codable, Sendable { sessionkey: String?, note: String?, restartdelayms: Int?, - timeoutms: Int? - ) { + timeoutms: Int?) + { self.sessionkey = sessionkey self.note = note self.restartdelayms = restartdelayms self.timeoutms = timeoutms } + private enum CodingKeys: String, CodingKey { case sessionkey = "sessionKey" case note @@ -2786,10 +3222,11 @@ public struct TickEvent: Codable, Sendable { public let ts: Int public init( - ts: Int - ) { + ts: Int) + { self.ts = ts } + private enum CodingKeys: String, CodingKey { case ts } @@ -2801,11 +3238,12 @@ public struct ShutdownEvent: Codable, Sendable { public init( reason: String, - restartexpectedms: Int? - ) { + restartexpectedms: Int?) + { self.reason = reason self.restartexpectedms = restartexpectedms } + private enum CodingKeys: String, CodingKey { case reason case restartexpectedms = "restartExpectedMs" @@ -2827,11 +3265,11 @@ public enum GatewayFrame: Codable, Sendable { let type = try typeContainer.decode(String.self, forKey: .type) switch type { case "req": - self = .req(try RequestFrame(from: decoder)) + self = try .req(RequestFrame(from: decoder)) case "res": - self = .res(try ResponseFrame(from: decoder)) + self = try .res(ResponseFrame(from: decoder)) case "event": - self = .event(try EventFrame(from: decoder)) + self = try .event(EventFrame(from: decoder)) default: let container = try decoder.singleValueContainer() let raw = try container.decode([String: AnyCodable].self) @@ -2841,13 +3279,15 @@ public enum GatewayFrame: Codable, Sendable { public func encode(to encoder: Encoder) throws { switch self { - case .req(let v): try v.encode(to: encoder) - case .res(let v): try v.encode(to: encoder) - case .event(let v): try v.encode(to: encoder) - case .unknown(_, let raw): + case let .req(v): + try v.encode(to: encoder) + case let .res(v): + try v.encode(to: encoder) + case let .event(v): + try v.encode(to: encoder) + case let .unknown(_, raw): var container = encoder.singleValueContainer() try container.encode(raw) } } - } diff --git a/apps/macos/Tests/OpenClawIPCTests/ExecAllowlistTests.swift b/apps/macos/Tests/OpenClawIPCTests/ExecAllowlistTests.swift index 7da886ea794..3b27740d066 100644 --- a/apps/macos/Tests/OpenClawIPCTests/ExecAllowlistTests.swift +++ b/apps/macos/Tests/OpenClawIPCTests/ExecAllowlistTests.swift @@ -2,7 +2,55 @@ import Foundation import Testing @testable import OpenClaw +/// These cases cover optional `security=allowlist` behavior. +/// Default install posture remains deny-by-default for exec on macOS node-host. struct ExecAllowlistTests { + private struct ShellParserParityFixture: Decodable { + struct Case: Decodable { + let id: String + let command: String + let ok: Bool + let executables: [String] + } + + let cases: [Case] + } + + private struct WrapperResolutionParityFixture: Decodable { + struct Case: Decodable { + let id: String + let argv: [String] + let expectedRawExecutable: String? + } + + let cases: [Case] + } + + private static func loadShellParserParityCases() throws -> [ShellParserParityFixture.Case] { + let fixtureURL = self.fixtureURL(filename: "exec-allowlist-shell-parser-parity.json") + let data = try Data(contentsOf: fixtureURL) + let fixture = try JSONDecoder().decode(ShellParserParityFixture.self, from: data) + return fixture.cases + } + + private static func loadWrapperResolutionParityCases() throws -> [WrapperResolutionParityFixture.Case] { + let fixtureURL = self.fixtureURL(filename: "exec-wrapper-resolution-parity.json") + let data = try Data(contentsOf: fixtureURL) + let fixture = try JSONDecoder().decode(WrapperResolutionParityFixture.self, from: data) + return fixture.cases + } + + private static func fixtureURL(filename: String) -> URL { + var repoRoot = URL(fileURLWithPath: #filePath) + for _ in 0..<5 { + repoRoot.deleteLastPathComponent() + } + return repoRoot + .appendingPathComponent("test") + .appendingPathComponent("fixtures") + .appendingPathComponent(filename) + } + @Test func matchUsesResolvedPath() { let entry = ExecAllowlistEntry(pattern: "/opt/homebrew/bin/rg") let resolution = ExecCommandResolution( @@ -14,7 +62,7 @@ struct ExecAllowlistTests { #expect(match?.pattern == entry.pattern) } - @Test func matchUsesBasenameForSimplePattern() { + @Test func matchIgnoresBasenamePattern() { let entry = ExecAllowlistEntry(pattern: "rg") let resolution = ExecCommandResolution( rawExecutable: "rg", @@ -22,11 +70,22 @@ struct ExecAllowlistTests { executableName: "rg", cwd: nil) let match = ExecAllowlistMatcher.match(entries: [entry], resolution: resolution) - #expect(match?.pattern == entry.pattern) + #expect(match == nil) + } + + @Test func matchIgnoresBasenameForRelativeExecutable() { + let entry = ExecAllowlistEntry(pattern: "echo") + let resolution = ExecCommandResolution( + rawExecutable: "./echo", + resolvedPath: "/tmp/oc-basename/echo", + executableName: "echo", + cwd: "/tmp/oc-basename") + let match = ExecAllowlistMatcher.match(entries: [entry], resolution: resolution) + #expect(match == nil) } @Test func matchIsCaseInsensitive() { - let entry = ExecAllowlistEntry(pattern: "RG") + let entry = ExecAllowlistEntry(pattern: "/OPT/HOMEBREW/BIN/RG") let resolution = ExecCommandResolution( rawExecutable: "rg", resolvedPath: "/opt/homebrew/bin/rg", @@ -46,4 +105,145 @@ struct ExecAllowlistTests { let match = ExecAllowlistMatcher.match(entries: [entry], resolution: resolution) #expect(match?.pattern == entry.pattern) } + + @Test func resolveForAllowlistSplitsShellChains() { + let command = ["/bin/sh", "-lc", "echo allowlisted && /usr/bin/touch /tmp/openclaw-allowlist-test"] + let resolutions = ExecCommandResolution.resolveForAllowlist( + command: command, + rawCommand: "echo allowlisted && /usr/bin/touch /tmp/openclaw-allowlist-test", + cwd: nil, + env: ["PATH": "/usr/bin:/bin"]) + #expect(resolutions.count == 2) + #expect(resolutions[0].executableName == "echo") + #expect(resolutions[1].executableName == "touch") + } + + @Test func resolveForAllowlistKeepsQuotedOperatorsInSingleSegment() { + let command = ["/bin/sh", "-lc", "echo \"a && b\""] + let resolutions = ExecCommandResolution.resolveForAllowlist( + command: command, + rawCommand: "echo \"a && b\"", + cwd: nil, + env: ["PATH": "/usr/bin:/bin"]) + #expect(resolutions.count == 1) + #expect(resolutions[0].executableName == "echo") + } + + @Test func resolveForAllowlistFailsClosedOnCommandSubstitution() { + let command = ["/bin/sh", "-lc", "echo $(/usr/bin/touch /tmp/openclaw-allowlist-test-subst)"] + let resolutions = ExecCommandResolution.resolveForAllowlist( + command: command, + rawCommand: "echo $(/usr/bin/touch /tmp/openclaw-allowlist-test-subst)", + cwd: nil, + env: ["PATH": "/usr/bin:/bin"]) + #expect(resolutions.isEmpty) + } + + @Test func resolveForAllowlistFailsClosedOnQuotedCommandSubstitution() { + let command = ["/bin/sh", "-lc", "echo \"ok $(/usr/bin/touch /tmp/openclaw-allowlist-test-quoted-subst)\""] + let resolutions = ExecCommandResolution.resolveForAllowlist( + command: command, + rawCommand: "echo \"ok $(/usr/bin/touch /tmp/openclaw-allowlist-test-quoted-subst)\"", + cwd: nil, + env: ["PATH": "/usr/bin:/bin"]) + #expect(resolutions.isEmpty) + } + + @Test func resolveForAllowlistFailsClosedOnQuotedBackticks() { + let command = ["/bin/sh", "-lc", "echo \"ok `/usr/bin/id`\""] + let resolutions = ExecCommandResolution.resolveForAllowlist( + command: command, + rawCommand: "echo \"ok `/usr/bin/id`\"", + cwd: nil, + env: ["PATH": "/usr/bin:/bin"]) + #expect(resolutions.isEmpty) + } + + @Test func resolveForAllowlistMatchesSharedShellParserFixture() throws { + let fixtures = try Self.loadShellParserParityCases() + for fixture in fixtures { + let resolutions = ExecCommandResolution.resolveForAllowlist( + command: ["/bin/sh", "-lc", fixture.command], + rawCommand: fixture.command, + cwd: nil, + env: ["PATH": "/usr/bin:/bin"]) + + #expect(!resolutions.isEmpty == fixture.ok) + if fixture.ok { + let executables = resolutions.map { $0.executableName.lowercased() } + let expected = fixture.executables.map { $0.lowercased() } + #expect(executables == expected) + } + } + } + + @Test func resolveMatchesSharedWrapperResolutionFixture() throws { + let fixtures = try Self.loadWrapperResolutionParityCases() + for fixture in fixtures { + let resolution = ExecCommandResolution.resolve( + command: fixture.argv, + cwd: nil, + env: ["PATH": "/usr/bin:/bin"]) + #expect(resolution?.rawExecutable == fixture.expectedRawExecutable) + } + } + + @Test func resolveForAllowlistTreatsPlainShInvocationAsDirectExec() { + let command = ["/bin/sh", "./script.sh"] + let resolutions = ExecCommandResolution.resolveForAllowlist( + command: command, + rawCommand: nil, + cwd: "/tmp", + env: ["PATH": "/usr/bin:/bin"]) + #expect(resolutions.count == 1) + #expect(resolutions[0].executableName == "sh") + } + + @Test func resolveForAllowlistUnwrapsEnvShellWrapperChains() { + let command = ["/usr/bin/env", "/bin/sh", "-lc", "echo allowlisted && /usr/bin/touch /tmp/openclaw-allowlist-test"] + let resolutions = ExecCommandResolution.resolveForAllowlist( + command: command, + rawCommand: nil, + cwd: nil, + env: ["PATH": "/usr/bin:/bin"]) + #expect(resolutions.count == 2) + #expect(resolutions[0].executableName == "echo") + #expect(resolutions[1].executableName == "touch") + } + + @Test func resolveForAllowlistUnwrapsEnvToEffectiveDirectExecutable() { + let command = ["/usr/bin/env", "FOO=bar", "/usr/bin/printf", "ok"] + let resolutions = ExecCommandResolution.resolveForAllowlist( + command: command, + rawCommand: nil, + cwd: nil, + env: ["PATH": "/usr/bin:/bin"]) + #expect(resolutions.count == 1) + #expect(resolutions[0].resolvedPath == "/usr/bin/printf") + #expect(resolutions[0].executableName == "printf") + } + + @Test func matchAllRequiresEverySegmentToMatch() { + let first = ExecCommandResolution( + rawExecutable: "echo", + resolvedPath: "/usr/bin/echo", + executableName: "echo", + cwd: nil) + let second = ExecCommandResolution( + rawExecutable: "/usr/bin/touch", + resolvedPath: "/usr/bin/touch", + executableName: "touch", + cwd: nil) + let resolutions = [first, second] + + let partial = ExecAllowlistMatcher.matchAll( + entries: [ExecAllowlistEntry(pattern: "/usr/bin/echo")], + resolutions: resolutions) + #expect(partial.isEmpty) + + let full = ExecAllowlistMatcher.matchAll( + entries: [ExecAllowlistEntry(pattern: "/USR/BIN/ECHO"), ExecAllowlistEntry(pattern: "/usr/bin/touch")], + resolutions: resolutions) + #expect(full.count == 2) + } } diff --git a/apps/macos/Tests/OpenClawIPCTests/ExecApprovalHelpersTests.swift b/apps/macos/Tests/OpenClawIPCTests/ExecApprovalHelpersTests.swift index 760d6c9178e..455b4296753 100644 --- a/apps/macos/Tests/OpenClawIPCTests/ExecApprovalHelpersTests.swift +++ b/apps/macos/Tests/OpenClawIPCTests/ExecApprovalHelpersTests.swift @@ -29,6 +29,24 @@ import Testing #expect(ExecApprovalHelpers.allowlistPattern(command: [], resolution: nil) == nil) } + @Test func validateAllowlistPatternReturnsReasons() { + #expect(ExecApprovalHelpers.isPathPattern("/usr/bin/rg")) + #expect(ExecApprovalHelpers.isPathPattern(" ~/bin/rg ")) + #expect(!ExecApprovalHelpers.isPathPattern("rg")) + + if case .invalid(let reason) = ExecApprovalHelpers.validateAllowlistPattern(" ") { + #expect(reason == .empty) + } else { + Issue.record("Expected empty pattern rejection") + } + + if case .invalid(let reason) = ExecApprovalHelpers.validateAllowlistPattern("echo") { + #expect(reason == .missingPathComponent) + } else { + Issue.record("Expected basename pattern rejection") + } + } + @Test func requiresAskMatchesPolicy() { let entry = ExecAllowlistEntry(pattern: "/bin/ls", lastUsedAt: nil, lastUsedCommand: nil, lastResolvedPath: nil) #expect(ExecApprovalHelpers.requiresAsk( diff --git a/apps/macos/Tests/OpenClawIPCTests/ExecApprovalsStoreRefactorTests.swift b/apps/macos/Tests/OpenClawIPCTests/ExecApprovalsStoreRefactorTests.swift new file mode 100644 index 00000000000..fa9eef87881 --- /dev/null +++ b/apps/macos/Tests/OpenClawIPCTests/ExecApprovalsStoreRefactorTests.swift @@ -0,0 +1,75 @@ +import Foundation +import Testing +@testable import OpenClaw + +@Suite(.serialized) +struct ExecApprovalsStoreRefactorTests { + @Test + func ensureFileSkipsRewriteWhenUnchanged() async throws { + let stateDir = FileManager().temporaryDirectory + .appendingPathComponent("openclaw-state-\(UUID().uuidString)", isDirectory: true) + defer { try? FileManager().removeItem(at: stateDir) } + + try await TestIsolation.withEnvValues(["OPENCLAW_STATE_DIR": stateDir.path]) { + _ = ExecApprovalsStore.ensureFile() + let url = ExecApprovalsStore.fileURL() + let firstWriteDate = try Self.modificationDate(at: url) + + try await Task.sleep(nanoseconds: 1_100_000_000) + _ = ExecApprovalsStore.ensureFile() + let secondWriteDate = try Self.modificationDate(at: url) + + #expect(firstWriteDate == secondWriteDate) + } + } + + @Test + func updateAllowlistReportsRejectedBasenamePattern() async throws { + let stateDir = FileManager().temporaryDirectory + .appendingPathComponent("openclaw-state-\(UUID().uuidString)", isDirectory: true) + defer { try? FileManager().removeItem(at: stateDir) } + + await TestIsolation.withEnvValues(["OPENCLAW_STATE_DIR": stateDir.path]) { + let rejected = ExecApprovalsStore.updateAllowlist( + agentId: "main", + allowlist: [ + ExecAllowlistEntry(pattern: "echo"), + ExecAllowlistEntry(pattern: "/bin/echo"), + ]) + #expect(rejected.count == 1) + #expect(rejected.first?.reason == .missingPathComponent) + #expect(rejected.first?.pattern == "echo") + + let resolved = ExecApprovalsStore.resolve(agentId: "main") + #expect(resolved.allowlist.map(\.pattern) == ["/bin/echo"]) + } + } + + @Test + func updateAllowlistMigratesLegacyPatternFromResolvedPath() async throws { + let stateDir = FileManager().temporaryDirectory + .appendingPathComponent("openclaw-state-\(UUID().uuidString)", isDirectory: true) + defer { try? FileManager().removeItem(at: stateDir) } + + await TestIsolation.withEnvValues(["OPENCLAW_STATE_DIR": stateDir.path]) { + let rejected = ExecApprovalsStore.updateAllowlist( + agentId: "main", + allowlist: [ + ExecAllowlistEntry(pattern: "echo", lastUsedAt: nil, lastUsedCommand: nil, lastResolvedPath: " /usr/bin/echo "), + ]) + #expect(rejected.isEmpty) + + let resolved = ExecApprovalsStore.resolve(agentId: "main") + #expect(resolved.allowlist.map(\.pattern) == ["/usr/bin/echo"]) + } + } + + private static func modificationDate(at url: URL) throws -> Date { + let attributes = try FileManager().attributesOfItem(atPath: url.path) + guard let date = attributes[.modificationDate] as? Date else { + struct MissingDateError: Error {} + throw MissingDateError() + } + return date + } +} diff --git a/apps/macos/Tests/OpenClawIPCTests/GatewayChannelConfigureTests.swift b/apps/macos/Tests/OpenClawIPCTests/GatewayChannelConfigureTests.swift index 7200af03cdd..ec2caf6057c 100644 --- a/apps/macos/Tests/OpenClawIPCTests/GatewayChannelConfigureTests.swift +++ b/apps/macos/Tests/OpenClawIPCTests/GatewayChannelConfigureTests.swift @@ -45,12 +45,7 @@ import Testing // First send is the connect handshake request. Subsequent sends are request frames. if currentSendCount == 0 { - guard case let .data(data) = message else { return } - if let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any], - (obj["type"] as? String) == "req", - (obj["method"] as? String) == "connect", - let id = obj["id"] as? String - { + if let id = GatewayWebSocketTestSupport.connectRequestID(from: message) { self.connectRequestID.withLock { $0 = id } } return @@ -65,7 +60,7 @@ import Testing return } - let response = Self.responseData(id: id) + let response = GatewayWebSocketTestSupport.okResponseData(id: id) let handler = self.pendingReceiveHandler.withLock { $0 } handler?(Result.success(.data(response))) } @@ -75,7 +70,7 @@ import Testing try await Task.sleep(nanoseconds: UInt64(self.helloDelayMs) * 1_000_000) } let id = self.connectRequestID.withLock { $0 } ?? "connect" - return .data(Self.connectOkData(id: id)) + return .data(GatewayWebSocketTestSupport.connectOkData(id: id)) } func receive( @@ -89,41 +84,6 @@ import Testing handler?(Result.success(.data(data))) } - private static func connectOkData(id: String) -> Data { - let json = """ - { - "type": "res", - "id": "\(id)", - "ok": true, - "payload": { - "type": "hello-ok", - "protocol": 2, - "server": { "version": "test", "connId": "test" }, - "features": { "methods": [], "events": [] }, - "snapshot": { - "presence": [ { "ts": 1 } ], - "health": {}, - "stateVersion": { "presence": 0, "health": 0 }, - "uptimeMs": 0 - }, - "policy": { "maxPayload": 1, "maxBufferedBytes": 1, "tickIntervalMs": 30000 } - } - } - """ - return Data(json.utf8) - } - - private static func responseData(id: String) -> Data { - let json = """ - { - "type": "res", - "id": "\(id)", - "ok": true, - "payload": { "ok": true } - } - """ - return Data(json.utf8) - } } private final class FakeWebSocketSession: WebSocketSessioning, @unchecked Sendable { diff --git a/apps/macos/Tests/OpenClawIPCTests/GatewayChannelConnectTests.swift b/apps/macos/Tests/OpenClawIPCTests/GatewayChannelConnectTests.swift index bda06e9cf56..afe9dea9e2c 100644 --- a/apps/macos/Tests/OpenClawIPCTests/GatewayChannelConnectTests.swift +++ b/apps/macos/Tests/OpenClawIPCTests/GatewayChannelConnectTests.swift @@ -38,17 +38,7 @@ import Testing } func send(_ message: URLSessionWebSocketTask.Message) async throws { - let data: Data? = switch message { - case let .data(d): d - case let .string(s): s.data(using: .utf8) - @unknown default: nil - } - guard let data else { return } - if let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any], - obj["type"] as? String == "req", - obj["method"] as? String == "connect", - let id = obj["id"] as? String - { + if let id = GatewayWebSocketTestSupport.connectRequestID(from: message) { self.connectRequestID.withLock { $0 = id } } } @@ -60,7 +50,7 @@ import Testing case let .helloOk(ms): delayMs = ms let id = self.connectRequestID.withLock { $0 } ?? "connect" - msg = .data(Self.connectOkData(id: id)) + msg = .data(GatewayWebSocketTestSupport.connectOkData(id: id)) case let .invalid(ms): delayMs = ms msg = .string("not json") @@ -77,29 +67,6 @@ import Testing self.pendingReceiveHandler.withLock { $0 = completionHandler } } - private static func connectOkData(id: String) -> Data { - let json = """ - { - "type": "res", - "id": "\(id)", - "ok": true, - "payload": { - "type": "hello-ok", - "protocol": 2, - "server": { "version": "test", "connId": "test" }, - "features": { "methods": [], "events": [] }, - "snapshot": { - "presence": [ { "ts": 1 } ], - "health": {}, - "stateVersion": { "presence": 0, "health": 0 }, - "uptimeMs": 0 - }, - "policy": { "maxPayload": 1, "maxBufferedBytes": 1, "tickIntervalMs": 30000 } - } - } - """ - return Data(json.utf8) - } } private final class FakeWebSocketSession: WebSocketSessioning, @unchecked Sendable { diff --git a/apps/macos/Tests/OpenClawIPCTests/GatewayChannelRequestTests.swift b/apps/macos/Tests/OpenClawIPCTests/GatewayChannelRequestTests.swift index 94edb6ebf77..4c788a959f5 100644 --- a/apps/macos/Tests/OpenClawIPCTests/GatewayChannelRequestTests.swift +++ b/apps/macos/Tests/OpenClawIPCTests/GatewayChannelRequestTests.swift @@ -42,17 +42,7 @@ import Testing // First send is the connect handshake. Second send is the request frame. if currentSendCount == 0 { - let data: Data? = switch message { - case let .data(d): d - case let .string(s): s.data(using: .utf8) - @unknown default: nil - } - guard let data else { return } - if let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any], - obj["type"] as? String == "req", - obj["method"] as? String == "connect", - let id = obj["id"] as? String - { + if let id = GatewayWebSocketTestSupport.connectRequestID(from: message) { self.connectRequestID.withLock { $0 = id } } } @@ -64,7 +54,7 @@ import Testing func receive() async throws -> URLSessionWebSocketTask.Message { let id = self.connectRequestID.withLock { $0 } ?? "connect" - return .data(Self.connectOkData(id: id)) + return .data(GatewayWebSocketTestSupport.connectOkData(id: id)) } func receive( @@ -73,29 +63,6 @@ import Testing self.pendingReceiveHandler.withLock { $0 = completionHandler } } - private static func connectOkData(id: String) -> Data { - let json = """ - { - "type": "res", - "id": "\(id)", - "ok": true, - "payload": { - "type": "hello-ok", - "protocol": 2, - "server": { "version": "test", "connId": "test" }, - "features": { "methods": [], "events": [] }, - "snapshot": { - "presence": [ { "ts": 1 } ], - "health": {}, - "stateVersion": { "presence": 0, "health": 0 }, - "uptimeMs": 0 - }, - "policy": { "maxPayload": 1, "maxBufferedBytes": 1, "tickIntervalMs": 30000 } - } - } - """ - return Data(json.utf8) - } } private final class FakeWebSocketSession: WebSocketSessioning, @unchecked Sendable { diff --git a/apps/macos/Tests/OpenClawIPCTests/GatewayChannelShutdownTests.swift b/apps/macos/Tests/OpenClawIPCTests/GatewayChannelShutdownTests.swift index eea7774adf2..5f995cd394a 100644 --- a/apps/macos/Tests/OpenClawIPCTests/GatewayChannelShutdownTests.swift +++ b/apps/macos/Tests/OpenClawIPCTests/GatewayChannelShutdownTests.swift @@ -32,24 +32,14 @@ import Testing } func send(_ message: URLSessionWebSocketTask.Message) async throws { - let data: Data? = switch message { - case let .data(d): d - case let .string(s): s.data(using: .utf8) - @unknown default: nil - } - guard let data else { return } - if let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any], - obj["type"] as? String == "req", - obj["method"] as? String == "connect", - let id = obj["id"] as? String - { + if let id = GatewayWebSocketTestSupport.connectRequestID(from: message) { self.connectRequestID.withLock { $0 = id } } } func receive() async throws -> URLSessionWebSocketTask.Message { let id = self.connectRequestID.withLock { $0 } ?? "connect" - return .data(Self.connectOkData(id: id)) + return .data(GatewayWebSocketTestSupport.connectOkData(id: id)) } func receive( @@ -63,29 +53,6 @@ import Testing handler?(Result.failure(URLError(.networkConnectionLost))) } - private static func connectOkData(id: String) -> Data { - let json = """ - { - "type": "res", - "id": "\(id)", - "ok": true, - "payload": { - "type": "hello-ok", - "protocol": 2, - "server": { "version": "test", "connId": "test" }, - "features": { "methods": [], "events": [] }, - "snapshot": { - "presence": [ { "ts": 1 } ], - "health": {}, - "stateVersion": { "presence": 0, "health": 0 }, - "uptimeMs": 0 - }, - "policy": { "maxPayload": 1, "maxBufferedBytes": 1, "tickIntervalMs": 30000 } - } - } - """ - return Data(json.utf8) - } } private final class FakeWebSocketSession: WebSocketSessioning, @unchecked Sendable { diff --git a/apps/macos/Tests/OpenClawIPCTests/GatewayDiscoveryHelpersTests.swift b/apps/macos/Tests/OpenClawIPCTests/GatewayDiscoveryHelpersTests.swift new file mode 100644 index 00000000000..17ffec07d46 --- /dev/null +++ b/apps/macos/Tests/OpenClawIPCTests/GatewayDiscoveryHelpersTests.swift @@ -0,0 +1,98 @@ +import Foundation +import OpenClawDiscovery +import Testing +@testable import OpenClaw + +@Suite +struct GatewayDiscoveryHelpersTests { + private func makeGateway( + serviceHost: String?, + servicePort: Int?, + lanHost: String? = "txt-host.local", + tailnetDns: String? = "txt-host.ts.net", + sshPort: Int = 22, + gatewayPort: Int? = 18789) -> GatewayDiscoveryModel.DiscoveredGateway + { + GatewayDiscoveryModel.DiscoveredGateway( + displayName: "Gateway", + serviceHost: serviceHost, + servicePort: servicePort, + lanHost: lanHost, + tailnetDns: tailnetDns, + sshPort: sshPort, + gatewayPort: gatewayPort, + cliPath: "/tmp/openclaw", + stableID: UUID().uuidString, + debugID: UUID().uuidString, + isLocal: false) + } + + @Test func sshTargetUsesResolvedServiceHostOnly() { + let gateway = self.makeGateway( + serviceHost: "resolved.example.ts.net", + servicePort: 18789, + sshPort: 2201) + + guard let target = GatewayDiscoveryHelpers.sshTarget(for: gateway) else { + Issue.record("expected ssh target") + return + } + let parsed = CommandResolver.parseSSHTarget(target) + #expect(parsed?.host == "resolved.example.ts.net") + #expect(parsed?.port == 2201) + } + + @Test func sshTargetAllowsMissingResolvedServicePort() { + let gateway = self.makeGateway( + serviceHost: "resolved.example.ts.net", + servicePort: nil, + sshPort: 2201) + + guard let target = GatewayDiscoveryHelpers.sshTarget(for: gateway) else { + Issue.record("expected ssh target") + return + } + let parsed = CommandResolver.parseSSHTarget(target) + #expect(parsed?.host == "resolved.example.ts.net") + #expect(parsed?.port == 2201) + } + + @Test func sshTargetRejectsTxtOnlyGateways() { + let gateway = self.makeGateway( + serviceHost: nil, + servicePort: nil, + lanHost: "txt-only.local", + tailnetDns: "txt-only.ts.net", + sshPort: 2222) + + #expect(GatewayDiscoveryHelpers.sshTarget(for: gateway) == nil) + } + + @Test func directUrlUsesResolvedServiceEndpointOnly() { + let tlsGateway = self.makeGateway( + serviceHost: "resolved.example.ts.net", + servicePort: 443) + #expect(GatewayDiscoveryHelpers.directUrl(for: tlsGateway) == "wss://resolved.example.ts.net") + + let wsGateway = self.makeGateway( + serviceHost: "resolved.example.ts.net", + servicePort: 18789) + #expect(GatewayDiscoveryHelpers.directUrl(for: wsGateway) == "wss://resolved.example.ts.net:18789") + + let localGateway = self.makeGateway( + serviceHost: "127.0.0.1", + servicePort: 18789) + #expect(GatewayDiscoveryHelpers.directUrl(for: localGateway) == "ws://127.0.0.1:18789") + } + + @Test func directUrlRejectsTxtOnlyFallback() { + let gateway = self.makeGateway( + serviceHost: nil, + servicePort: nil, + lanHost: "txt-only.local", + tailnetDns: "txt-only.ts.net", + gatewayPort: 22222) + + #expect(GatewayDiscoveryHelpers.directUrl(for: gateway) == nil) + } +} diff --git a/apps/macos/Tests/OpenClawIPCTests/GatewayEndpointStoreTests.swift b/apps/macos/Tests/OpenClawIPCTests/GatewayEndpointStoreTests.swift index 44c464c449f..bb969aeaec9 100644 --- a/apps/macos/Tests/OpenClawIPCTests/GatewayEndpointStoreTests.swift +++ b/apps/macos/Tests/OpenClawIPCTests/GatewayEndpointStoreTests.swift @@ -218,9 +218,19 @@ import Testing #expect(url.absoluteString == "https://gateway.example:443/remote-ui/") } - @Test func normalizeGatewayUrlAddsDefaultPortForWs() { - let url = GatewayRemoteConfig.normalizeGatewayUrl("ws://gateway") + @Test func normalizeGatewayUrlAddsDefaultPortForLoopbackWs() { + let url = GatewayRemoteConfig.normalizeGatewayUrl("ws://127.0.0.1") #expect(url?.port == 18789) - #expect(url?.absoluteString == "ws://gateway:18789") + #expect(url?.absoluteString == "ws://127.0.0.1:18789") + } + + @Test func normalizeGatewayUrlRejectsNonLoopbackWs() { + let url = GatewayRemoteConfig.normalizeGatewayUrl("ws://gateway.example:18789") + #expect(url == nil) + } + + @Test func normalizeGatewayUrlRejectsPrefixBypassLoopbackHost() { + let url = GatewayRemoteConfig.normalizeGatewayUrl("ws://127.attacker.example") + #expect(url == nil) } } diff --git a/apps/macos/Tests/OpenClawIPCTests/GatewayProcessManagerTests.swift b/apps/macos/Tests/OpenClawIPCTests/GatewayProcessManagerTests.swift index f8b226ab277..dabb15f8bf1 100644 --- a/apps/macos/Tests/OpenClawIPCTests/GatewayProcessManagerTests.swift +++ b/apps/macos/Tests/OpenClawIPCTests/GatewayProcessManagerTests.swift @@ -39,12 +39,7 @@ struct GatewayProcessManagerTests { } if currentSendCount == 0 { - guard case let .data(data) = message else { return } - if let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any], - (obj["type"] as? String) == "req", - (obj["method"] as? String) == "connect", - let id = obj["id"] as? String - { + if let id = GatewayWebSocketTestSupport.connectRequestID(from: message) { self.connectRequestID.withLock { $0 = id } } return @@ -59,14 +54,14 @@ struct GatewayProcessManagerTests { return } - let response = Self.responseData(id: id) + let response = GatewayWebSocketTestSupport.okResponseData(id: id) let handler = self.pendingReceiveHandler.withLock { $0 } handler?(Result.success(.data(response))) } func receive() async throws -> URLSessionWebSocketTask.Message { let id = self.connectRequestID.withLock { $0 } ?? "connect" - return .data(Self.connectOkData(id: id)) + return .data(GatewayWebSocketTestSupport.connectOkData(id: id)) } func receive( @@ -75,41 +70,6 @@ struct GatewayProcessManagerTests { self.pendingReceiveHandler.withLock { $0 = completionHandler } } - private static func connectOkData(id: String) -> Data { - let json = """ - { - "type": "res", - "id": "\(id)", - "ok": true, - "payload": { - "type": "hello-ok", - "protocol": 2, - "server": { "version": "test", "connId": "test" }, - "features": { "methods": [], "events": [] }, - "snapshot": { - "presence": [ { "ts": 1 } ], - "health": {}, - "stateVersion": { "presence": 0, "health": 0 }, - "uptimeMs": 0 - }, - "policy": { "maxPayload": 1, "maxBufferedBytes": 1, "tickIntervalMs": 30000 } - } - } - """ - return Data(json.utf8) - } - - private static func responseData(id: String) -> Data { - let json = """ - { - "type": "res", - "id": "\(id)", - "ok": true, - "payload": { "ok": true } - } - """ - return Data(json.utf8) - } } private final class FakeWebSocketSession: WebSocketSessioning, @unchecked Sendable { diff --git a/apps/macos/Tests/OpenClawIPCTests/GatewayWebSocketTestSupport.swift b/apps/macos/Tests/OpenClawIPCTests/GatewayWebSocketTestSupport.swift new file mode 100644 index 00000000000..0ba41f2806b --- /dev/null +++ b/apps/macos/Tests/OpenClawIPCTests/GatewayWebSocketTestSupport.swift @@ -0,0 +1,63 @@ +import OpenClawKit +import Foundation + +extension WebSocketTasking { + // Keep unit-test doubles resilient to protocol additions. + func sendPing(pongReceiveHandler: @escaping @Sendable (Error?) -> Void) { + pongReceiveHandler(nil) + } +} + +enum GatewayWebSocketTestSupport { + static func connectRequestID(from message: URLSessionWebSocketTask.Message) -> String? { + let data: Data? = switch message { + case let .data(d): d + case let .string(s): s.data(using: .utf8) + @unknown default: nil + } + guard let data else { return nil } + guard let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { + return nil + } + guard (obj["type"] as? String) == "req", (obj["method"] as? String) == "connect" else { + return nil + } + return obj["id"] as? String + } + + static func connectOkData(id: String) -> Data { + let json = """ + { + "type": "res", + "id": "\(id)", + "ok": true, + "payload": { + "type": "hello-ok", + "protocol": 2, + "server": { "version": "test", "connId": "test" }, + "features": { "methods": [], "events": [] }, + "snapshot": { + "presence": [ { "ts": 1 } ], + "health": {}, + "stateVersion": { "presence": 0, "health": 0 }, + "uptimeMs": 0 + }, + "policy": { "maxPayload": 1, "maxBufferedBytes": 1, "tickIntervalMs": 30000 } + } + } + """ + return Data(json.utf8) + } + + static func okResponseData(id: String) -> Data { + let json = """ + { + "type": "res", + "id": "\(id)", + "ok": true, + "payload": { "ok": true } + } + """ + return Data(json.utf8) + } +} diff --git a/apps/macos/Tests/OpenClawIPCTests/HostEnvSanitizerTests.swift b/apps/macos/Tests/OpenClawIPCTests/HostEnvSanitizerTests.swift new file mode 100644 index 00000000000..7ee15107f40 --- /dev/null +++ b/apps/macos/Tests/OpenClawIPCTests/HostEnvSanitizerTests.swift @@ -0,0 +1,36 @@ +import Testing +@testable import OpenClaw + +struct HostEnvSanitizerTests { + @Test func sanitizeBlocksShellTraceVariables() { + let env = HostEnvSanitizer.sanitize(overrides: [ + "SHELLOPTS": "xtrace", + "PS4": "$(touch /tmp/pwned)", + "OPENCLAW_TEST": "1", + ]) + #expect(env["SHELLOPTS"] == nil) + #expect(env["PS4"] == nil) + #expect(env["OPENCLAW_TEST"] == "1") + } + + @Test func sanitizeShellWrapperAllowsOnlyExplicitOverrideKeys() { + let env = HostEnvSanitizer.sanitize( + overrides: [ + "LANG": "C", + "LC_ALL": "C", + "OPENCLAW_TOKEN": "secret", + "PS4": "$(touch /tmp/pwned)", + ], + shellWrapper: true) + + #expect(env["LANG"] == "C") + #expect(env["LC_ALL"] == "C") + #expect(env["OPENCLAW_TOKEN"] == nil) + #expect(env["PS4"] == nil) + } + + @Test func sanitizeNonShellWrapperKeepsRegularOverrides() { + let env = HostEnvSanitizer.sanitize(overrides: ["OPENCLAW_TOKEN": "secret"]) + #expect(env["OPENCLAW_TOKEN"] == "secret") + } +} diff --git a/apps/macos/Tests/OpenClawIPCTests/MacGatewayChatTransportMappingTests.swift b/apps/macos/Tests/OpenClawIPCTests/MacGatewayChatTransportMappingTests.swift index 661382dda69..2d26b7c0538 100644 --- a/apps/macos/Tests/OpenClawIPCTests/MacGatewayChatTransportMappingTests.swift +++ b/apps/macos/Tests/OpenClawIPCTests/MacGatewayChatTransportMappingTests.swift @@ -13,7 +13,8 @@ import Testing configpath: nil, statedir: nil, sessiondefaults: nil, - authmode: nil) + authmode: nil, + updateavailable: nil) let hello = HelloOk( type: "hello", diff --git a/apps/macos/Tests/OpenClawIPCTests/OnboardingViewSmokeTests.swift b/apps/macos/Tests/OpenClawIPCTests/OnboardingViewSmokeTests.swift index 57912eb412d..b824b2b0835 100644 --- a/apps/macos/Tests/OpenClawIPCTests/OnboardingViewSmokeTests.swift +++ b/apps/macos/Tests/OpenClawIPCTests/OnboardingViewSmokeTests.swift @@ -1,3 +1,4 @@ +import Foundation import OpenClawDiscovery import SwiftUI import Testing @@ -25,4 +26,36 @@ struct OnboardingViewSmokeTests { let order = OnboardingView.pageOrder(for: .local, showOnboardingChat: false) #expect(!order.contains(8)) } + + @Test func selectRemoteGatewayClearsStaleSshTargetWhenEndpointUnresolved() async { + let override = FileManager().temporaryDirectory + .appendingPathComponent("openclaw-config-\(UUID().uuidString)") + .appendingPathComponent("openclaw.json") + .path + + await TestIsolation.withEnvValues(["OPENCLAW_CONFIG_PATH": override]) { + let state = AppState(preview: true) + state.remoteTransport = .ssh + state.remoteTarget = "user@old-host:2222" + let view = OnboardingView( + state: state, + permissionMonitor: PermissionMonitor.shared, + discoveryModel: GatewayDiscoveryModel(localDisplayName: InstanceIdentity.displayName)) + let gateway = GatewayDiscoveryModel.DiscoveredGateway( + displayName: "Unresolved", + serviceHost: nil, + servicePort: nil, + lanHost: "txt-host.local", + tailnetDns: "txt-host.ts.net", + sshPort: 22, + gatewayPort: 18789, + cliPath: "/tmp/openclaw", + stableID: UUID().uuidString, + debugID: UUID().uuidString, + isLocal: false) + + view.selectRemoteGateway(gateway) + #expect(state.remoteTarget.isEmpty) + } + } } diff --git a/apps/macos/Tests/OpenClawIPCTests/OpenClawConfigFileTests.swift b/apps/macos/Tests/OpenClawIPCTests/OpenClawConfigFileTests.swift index 98e4e8046d3..2cd9d6432e2 100644 --- a/apps/macos/Tests/OpenClawIPCTests/OpenClawConfigFileTests.swift +++ b/apps/macos/Tests/OpenClawIPCTests/OpenClawConfigFileTests.swift @@ -62,6 +62,31 @@ struct OpenClawConfigFileTests { } } + @MainActor + @Test + func clearRemoteGatewayUrlRemovesOnlyUrlField() async { + let override = FileManager().temporaryDirectory + .appendingPathComponent("openclaw-config-\(UUID().uuidString)") + .appendingPathComponent("openclaw.json") + .path + + await TestIsolation.withEnvValues(["OPENCLAW_CONFIG_PATH": override]) { + OpenClawConfigFile.saveDict([ + "gateway": [ + "remote": [ + "url": "wss://old-host:111", + "token": "tok", + ], + ], + ]) + OpenClawConfigFile.clearRemoteGatewayUrl() + let root = OpenClawConfigFile.loadDict() + let remote = ((root["gateway"] as? [String: Any])?["remote"] as? [String: Any]) ?? [:] + #expect((remote["url"] as? String) == nil) + #expect((remote["token"] as? String) == "tok") + } + } + @Test func stateDirOverrideSetsConfigPath() async { let dir = FileManager().temporaryDirectory diff --git a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatComposer.swift b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatComposer.swift index 95a5ac3e584..145e17f3b7b 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatComposer.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatComposer.swift @@ -180,10 +180,12 @@ struct OpenClawChatComposer: View { VStack(alignment: .leading, spacing: 8) { self.editorOverlay - Rectangle() - .fill(OpenClawChatTheme.divider) - .frame(height: 1) - .padding(.horizontal, 2) + if !self.isComposerCompacted { + Rectangle() + .fill(OpenClawChatTheme.divider) + .frame(height: 1) + .padding(.horizontal, 2) + } HStack(alignment: .center, spacing: 8) { if self.showsConnectionPill { @@ -308,7 +310,7 @@ struct OpenClawChatComposer: View { } private var showsToolbar: Bool { - self.style == .standard + self.style == .standard && !self.isComposerCompacted } private var showsAttachments: Bool { @@ -316,15 +318,15 @@ struct OpenClawChatComposer: View { } private var showsConnectionPill: Bool { - self.style == .standard + self.style == .standard && !self.isComposerCompacted } private var composerPadding: CGFloat { - self.style == .onboarding ? 5 : 6 + self.style == .onboarding ? 5 : (self.isComposerCompacted ? 4 : 6) } private var editorPadding: CGFloat { - self.style == .onboarding ? 5 : 6 + self.style == .onboarding ? 5 : (self.isComposerCompacted ? 4 : 6) } private var textMinHeight: CGFloat { @@ -335,6 +337,14 @@ struct OpenClawChatComposer: View { self.style == .onboarding ? 52 : 64 } + private var isComposerCompacted: Bool { + #if os(macOS) + false + #else + self.style == .standard && self.isFocused + #endif + } + #if os(macOS) private func pickFilesMac() { let panel = NSOpenPanel() diff --git a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatMarkdownPreprocessor.swift b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatMarkdownPreprocessor.swift index f435eab2dca..a96e288d7f4 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatMarkdownPreprocessor.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatMarkdownPreprocessor.swift @@ -1,6 +1,18 @@ import Foundation enum ChatMarkdownPreprocessor { + // Keep in sync with `src/auto-reply/reply/strip-inbound-meta.ts` + // (`INBOUND_META_SENTINELS`), and extend parser expectations in + // `ChatMarkdownPreprocessorTests` when sentinels change. + private static let inboundContextHeaders = [ + "Conversation info (untrusted metadata):", + "Sender (untrusted metadata):", + "Thread starter (untrusted, for context):", + "Replied message (untrusted, for context):", + "Forwarded message context (untrusted metadata):", + "Chat history since last reply (untrusted, for context):", + ] + struct InlineImage: Identifiable { let id = UUID() let label: String @@ -13,17 +25,21 @@ enum ChatMarkdownPreprocessor { } static func preprocess(markdown raw: String) -> Result { + let withoutContextBlocks = self.stripInboundContextBlocks(raw) + let withoutTimestamps = self.stripPrefixedTimestamps(withoutContextBlocks) let pattern = #"!\[([^\]]*)\]\((data:image\/[^;]+;base64,[^)]+)\)"# guard let re = try? NSRegularExpression(pattern: pattern) else { - return Result(cleaned: raw, images: []) + return Result(cleaned: self.normalize(withoutTimestamps), images: []) } - let ns = raw as NSString - let matches = re.matches(in: raw, range: NSRange(location: 0, length: ns.length)) - if matches.isEmpty { return Result(cleaned: raw, images: []) } + let ns = withoutTimestamps as NSString + let matches = re.matches( + in: withoutTimestamps, + range: NSRange(location: 0, length: ns.length)) + if matches.isEmpty { return Result(cleaned: self.normalize(withoutTimestamps), images: []) } var images: [InlineImage] = [] - var cleaned = raw + var cleaned = withoutTimestamps for match in matches.reversed() { guard match.numberOfRanges >= 3 else { continue } @@ -43,9 +59,65 @@ enum ChatMarkdownPreprocessor { cleaned.replaceSubrange(start.. String { + guard self.inboundContextHeaders.contains(where: raw.contains) else { + return raw + } + + let normalized = raw.replacingOccurrences(of: "\r\n", with: "\n") + var outputLines: [String] = [] + var inMetaBlock = false + var inFencedJson = false + + for line in normalized.split(separator: "\n", omittingEmptySubsequences: false) { + let currentLine = String(line) + + if !inMetaBlock && self.inboundContextHeaders.contains(where: currentLine.hasPrefix) { + inMetaBlock = true + inFencedJson = false + continue + } + + if inMetaBlock { + if !inFencedJson && currentLine.trimmingCharacters(in: .whitespacesAndNewlines) == "```json" { + inFencedJson = true + continue + } + + if inFencedJson { + if currentLine.trimmingCharacters(in: .whitespacesAndNewlines) == "```" { + inMetaBlock = false + inFencedJson = false + } + continue + } + + if currentLine.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + continue + } + + inMetaBlock = false + } + + outputLines.append(currentLine) + } + + return outputLines.joined(separator: "\n").replacingOccurrences(of: #"^\n+"#, with: "", options: .regularExpression) + } + + private static func stripPrefixedTimestamps(_ raw: String) -> String { + let pattern = #"(?m)^\[[A-Za-z]{3}\s+\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}(?::\d{2})?\s+(?:GMT|UTC)[+-]?\d{0,2}\]\s*"# + return raw.replacingOccurrences(of: pattern, with: "", options: .regularExpression) + } + + private static func normalize(_ raw: String) -> String { + var output = raw + output = output.replacingOccurrences(of: "\r\n", with: "\n") + output = output.replacingOccurrences(of: "\n\n\n", with: "\n\n") + output = output.replacingOccurrences(of: "\n\n\n", with: "\n\n") + return output.trimmingCharacters(in: .whitespacesAndNewlines) } } diff --git a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatMessageViews.swift b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatMessageViews.swift index baa790dbf74..22f28517d64 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatMessageViews.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatMessageViews.swift @@ -173,7 +173,8 @@ private struct ChatMessageBody: View { ToolResultCard( title: self.toolResultTitle, text: text, - isUser: self.isUser) + isUser: self.isUser, + toolName: self.message.toolName) } } else if self.isUser { ChatMarkdownRenderer( @@ -207,7 +208,8 @@ private struct ChatMessageBody: View { ToolResultCard( title: "\(display.emoji) \(display.title)", text: toolResult.text ?? "", - isUser: self.isUser) + isUser: self.isUser, + toolName: toolResult.name) } } } @@ -402,47 +404,54 @@ private struct ToolResultCard: View { let title: String let text: String let isUser: Bool + let toolName: String? @State private var expanded = false var body: some View { - VStack(alignment: .leading, spacing: 8) { - HStack(spacing: 6) { - Text(self.title) - .font(.footnote.weight(.semibold)) - Spacer(minLength: 0) - } - - Text(self.displayText) - .font(.footnote.monospaced()) - .foregroundStyle(self.isUser ? OpenClawChatTheme.userText : OpenClawChatTheme.assistantText) - .lineLimit(self.expanded ? nil : Self.previewLineLimit) - - if self.shouldShowToggle { - Button(self.expanded ? "Show less" : "Show full output") { - self.expanded.toggle() + if !self.displayContent.isEmpty { + VStack(alignment: .leading, spacing: 8) { + HStack(spacing: 6) { + Text(self.title) + .font(.footnote.weight(.semibold)) + Spacer(minLength: 0) + } + + Text(self.displayText) + .font(.footnote.monospaced()) + .foregroundStyle(self.isUser ? OpenClawChatTheme.userText : OpenClawChatTheme.assistantText) + .lineLimit(self.expanded ? nil : Self.previewLineLimit) + + if self.shouldShowToggle { + Button(self.expanded ? "Show less" : "Show full output") { + self.expanded.toggle() + } + .buttonStyle(.plain) + .font(.caption) + .foregroundStyle(.secondary) } - .buttonStyle(.plain) - .font(.caption) - .foregroundStyle(.secondary) } + .padding(10) + .background( + RoundedRectangle(cornerRadius: 12, style: .continuous) + .fill(OpenClawChatTheme.subtleCard) + .overlay( + RoundedRectangle(cornerRadius: 12, style: .continuous) + .strokeBorder(Color.white.opacity(0.08), lineWidth: 1))) } - .padding(10) - .background( - RoundedRectangle(cornerRadius: 12, style: .continuous) - .fill(OpenClawChatTheme.subtleCard) - .overlay( - RoundedRectangle(cornerRadius: 12, style: .continuous) - .strokeBorder(Color.white.opacity(0.08), lineWidth: 1))) } private static let previewLineLimit = 8 + private var displayContent: String { + ToolResultTextFormatter.format(text: self.text, toolName: self.toolName) + } + private var lines: [Substring] { - self.text.components(separatedBy: .newlines).map { Substring($0) } + self.displayContent.components(separatedBy: .newlines).map { Substring($0) } } private var displayText: String { - guard !self.expanded, self.lines.count > Self.previewLineLimit else { return self.text } + guard !self.expanded, self.lines.count > Self.previewLineLimit else { return self.displayContent } return self.lines.prefix(Self.previewLineLimit).joined(separator: "\n") + "\n…" } @@ -458,12 +467,7 @@ struct ChatTypingIndicatorBubble: View { var body: some View { HStack(spacing: 10) { TypingDots() - if self.style == .standard { - Text("OpenClaw is thinking…") - .font(.subheadline) - .foregroundStyle(.secondary) - Spacer() - } + Spacer(minLength: 0) } .padding(.vertical, self.style == .standard ? 12 : 10) .padding(.horizontal, self.style == .standard ? 12 : 14) diff --git a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatView.swift b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatView.swift index 68f9ae2f311..0675ffc2139 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatView.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatView.swift @@ -1,4 +1,7 @@ import SwiftUI +#if canImport(UIKit) +import UIKit +#endif @MainActor public struct OpenClawChatView: View { @@ -105,6 +108,9 @@ public struct OpenClawChatView: View { .padding(.top, Layout.messageListPaddingTop) .padding(.horizontal, Layout.messageListPaddingHorizontal) } + #if !os(macOS) + .scrollDismissesKeyboard(.interactively) + #endif // Keep the scroll pinned to the bottom for new messages. .scrollPosition(id: self.$scrollPosition, anchor: .bottom) .onChange(of: self.scrollPosition) { _, position in @@ -123,6 +129,10 @@ public struct OpenClawChatView: View { // Ensure the message list claims vertical space on the first layout pass. .frame(maxHeight: .infinity, alignment: .top) .layoutPriority(1) + .simultaneousGesture( + TapGesture().onEnded { + self.dismissKeyboardIfNeeded() + }) .onChange(of: self.viewModel.isLoading) { _, isLoading in guard !isLoading, !self.hasPerformedInitialScroll else { return } self.scrollPosition = self.scrollerBottomID @@ -406,6 +416,16 @@ public struct OpenClawChatView: View { } return parts.joined(separator: "\n").trimmingCharacters(in: .whitespacesAndNewlines) } + + private func dismissKeyboardIfNeeded() { + #if canImport(UIKit) + UIApplication.shared.sendAction( + #selector(UIResponder.resignFirstResponder), + to: nil, + from: nil, + for: nil) + #endif + } } private struct ChatNoticeCard: View { diff --git a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatViewModel.swift b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatViewModel.swift index 5328a5b692f..62cb97a0e2f 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatViewModel.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatViewModel.swift @@ -170,7 +170,9 @@ public final class OpenClawChatViewModel { } let payload = try await self.transport.requestHistory(sessionKey: self.sessionKey) - self.messages = Self.decodeMessages(payload.messages ?? []) + self.messages = Self.reconcileMessageIDs( + previous: self.messages, + incoming: Self.decodeMessages(payload.messages ?? [])) self.sessionId = payload.sessionId if let level = payload.thinkingLevel, !level.isEmpty { self.thinkingLevel = level @@ -187,10 +189,107 @@ public final class OpenClawChatViewModel { private static func decodeMessages(_ raw: [AnyCodable]) -> [OpenClawChatMessage] { let decoded = raw.compactMap { item in (try? ChatPayloadDecoding.decode(item, as: OpenClawChatMessage.self)) + .map { Self.stripInboundMetadata(from: $0) } } return Self.dedupeMessages(decoded) } + private static func stripInboundMetadata(from message: OpenClawChatMessage) -> OpenClawChatMessage { + guard message.role.lowercased() == "user" else { + return message + } + + let sanitizedContent = message.content.map { content -> OpenClawChatMessageContent in + guard let text = content.text else { return content } + let cleaned = ChatMarkdownPreprocessor.preprocess(markdown: text).cleaned + return OpenClawChatMessageContent( + type: content.type, + text: cleaned, + thinking: content.thinking, + thinkingSignature: content.thinkingSignature, + mimeType: content.mimeType, + fileName: content.fileName, + content: content.content, + id: content.id, + name: content.name, + arguments: content.arguments) + } + + return OpenClawChatMessage( + id: message.id, + role: message.role, + content: sanitizedContent, + timestamp: message.timestamp, + toolCallId: message.toolCallId, + toolName: message.toolName, + usage: message.usage, + stopReason: message.stopReason) + } + + private static func messageIdentityKey(for message: OpenClawChatMessage) -> String? { + let role = message.role.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + guard !role.isEmpty else { return nil } + + let timestamp: String = { + guard let value = message.timestamp, value.isFinite else { return "" } + return String(format: "%.3f", value) + }() + + let contentFingerprint = message.content.map { item in + let type = (item.type ?? "text").trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + let text = (item.text ?? "").trimmingCharacters(in: .whitespacesAndNewlines) + let id = (item.id ?? "").trimmingCharacters(in: .whitespacesAndNewlines) + let name = (item.name ?? "").trimmingCharacters(in: .whitespacesAndNewlines) + let fileName = (item.fileName ?? "").trimmingCharacters(in: .whitespacesAndNewlines) + return [type, text, id, name, fileName].joined(separator: "\\u{001F}") + }.joined(separator: "\\u{001E}") + + let toolCallId = (message.toolCallId ?? "").trimmingCharacters(in: .whitespacesAndNewlines) + let toolName = (message.toolName ?? "").trimmingCharacters(in: .whitespacesAndNewlines) + if timestamp.isEmpty, contentFingerprint.isEmpty, toolCallId.isEmpty, toolName.isEmpty { + return nil + } + return [role, timestamp, toolCallId, toolName, contentFingerprint].joined(separator: "|") + } + + private static func reconcileMessageIDs( + previous: [OpenClawChatMessage], + incoming: [OpenClawChatMessage]) -> [OpenClawChatMessage] + { + guard !previous.isEmpty, !incoming.isEmpty else { return incoming } + + var idsByKey: [String: [UUID]] = [:] + for message in previous { + guard let key = Self.messageIdentityKey(for: message) else { continue } + idsByKey[key, default: []].append(message.id) + } + + return incoming.map { message in + guard let key = Self.messageIdentityKey(for: message), + var ids = idsByKey[key], + let reusedId = ids.first + else { + return message + } + ids.removeFirst() + if ids.isEmpty { + idsByKey.removeValue(forKey: key) + } else { + idsByKey[key] = ids + } + guard reusedId != message.id else { return message } + return OpenClawChatMessage( + id: reusedId, + role: message.role, + content: message.content, + timestamp: message.timestamp, + toolCallId: message.toolCallId, + toolName: message.toolName, + usage: message.usage, + stopReason: message.stopReason) + } + } + private static func dedupeMessages(_ messages: [OpenClawChatMessage]) -> [OpenClawChatMessage] { var result: [OpenClawChatMessage] = [] result.reserveCapacity(messages.count) @@ -369,17 +468,28 @@ public final class OpenClawChatViewModel { case let .agent(agent): self.handleAgentEvent(agent) case .seqGap: - self.errorText = "Event stream interrupted; try refreshing." + self.errorText = nil self.clearPendingRuns(reason: nil) + Task { + await self.refreshHistoryAfterRun() + await self.pollHealthIfNeeded(force: true) + } } } private func handleChatEvent(_ chat: OpenClawChatEventPayload) { - if let sessionKey = chat.sessionKey, sessionKey != self.sessionKey { + let isOurRun = chat.runId.flatMap { self.pendingRuns.contains($0) } ?? false + + // Gateway may publish canonical session keys (for example "agent:main:main") + // even when this view currently uses an alias key (for example "main"). + // Never drop events for our own pending run on key mismatch, or the UI can stay + // stuck at "thinking" until the user reopens and forces a history reload. + if let sessionKey = chat.sessionKey, + !Self.matchesCurrentSessionKey(incoming: sessionKey, current: self.sessionKey), + !isOurRun + { return } - - let isOurRun = chat.runId.flatMap { self.pendingRuns.contains($0) } ?? false if !isOurRun { // Keep multiple clients in sync: if another client finishes a run for our session, refresh history. switch chat.state { @@ -411,6 +521,21 @@ public final class OpenClawChatViewModel { } } + private static func matchesCurrentSessionKey(incoming: String, current: String) -> Bool { + let incomingNormalized = incoming.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + let currentNormalized = current.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + if incomingNormalized == currentNormalized { + return true + } + // Common alias pair in operator clients: UI uses "main" while gateway emits canonical. + if (incomingNormalized == "agent:main:main" && currentNormalized == "main") || + (incomingNormalized == "main" && currentNormalized == "agent:main:main") + { + return true + } + return false + } + private func handleAgentEvent(_ evt: OpenClawAgentEventPayload) { if let sessionId, evt.runId != sessionId { return @@ -444,7 +569,9 @@ public final class OpenClawChatViewModel { private func refreshHistoryAfterRun() async { do { let payload = try await self.transport.requestHistory(sessionKey: self.sessionKey) - self.messages = Self.decodeMessages(payload.messages ?? []) + self.messages = Self.reconcileMessageIDs( + previous: self.messages, + incoming: Self.decodeMessages(payload.messages ?? [])) self.sessionId = payload.sessionId if let level = payload.thinkingLevel, !level.isEmpty { self.thinkingLevel = level diff --git a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ToolResultTextFormatter.swift b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ToolResultTextFormatter.swift new file mode 100644 index 00000000000..719e82cdf15 --- /dev/null +++ b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ToolResultTextFormatter.swift @@ -0,0 +1,157 @@ +import Foundation + +enum ToolResultTextFormatter { + static func format(text: String, toolName: String?) -> String { + let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return "" } + + guard self.looksLikeJSON(trimmed), + let data = trimmed.data(using: .utf8), + let json = try? JSONSerialization.jsonObject(with: data) + else { + return trimmed + } + + let normalizedTool = toolName?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + return self.renderJSON(json, toolName: normalizedTool) + } + + private static func looksLikeJSON(_ value: String) -> Bool { + guard let first = value.first else { return false } + return first == "{" || first == "[" + } + + private static func renderJSON(_ json: Any, toolName: String?) -> String { + if let dict = json as? [String: Any] { + return self.renderDictionary(dict, toolName: toolName) + } + if let array = json as? [Any] { + if array.isEmpty { return "No items." } + return "\(array.count) item\(array.count == 1 ? "" : "s")." + } + return "" + } + + private static func renderDictionary(_ dict: [String: Any], toolName: String?) -> String { + let status = (dict["status"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) + let errorText = self.firstString(in: dict, keys: ["error", "reason"]) + let messageText = self.firstString(in: dict, keys: ["message", "result", "detail"]) + + if status?.lowercased() == "error" || errorText != nil { + if let errorText { + return "Error: \(self.sanitizeError(errorText))" + } + if let messageText { + return "Error: \(self.sanitizeError(messageText))" + } + return "Error" + } + + if toolName == "nodes", let summary = self.renderNodesSummary(dict) { + return summary + } + + if let message = messageText { + return message + } + + if let status, !status.isEmpty { + return "Status: \(status)" + } + + return "" + } + + private static func renderNodesSummary(_ dict: [String: Any]) -> String? { + if let nodes = dict["nodes"] as? [[String: Any]] { + if nodes.isEmpty { return "No nodes found." } + var lines: [String] = [] + lines.append("\(nodes.count) node\(nodes.count == 1 ? "" : "s") found.") + + for node in nodes.prefix(3) { + let label = self.firstString(in: node, keys: ["displayName", "name", "nodeId"]) ?? "Node" + var details: [String] = [] + + if let connected = node["connected"] as? Bool { + details.append(connected ? "connected" : "offline") + } + if let platform = self.firstString(in: node, keys: ["platform"]) { + details.append(platform) + } + if let version = self.firstString(in: node, keys: ["osVersion", "appVersion", "version"]) { + details.append(version) + } + if let pairing = self.pairingDetail(node) { + details.append(pairing) + } + + if details.isEmpty { + lines.append("• \(label)") + } else { + lines.append("• \(label) - \(details.joined(separator: ", "))") + } + } + + let extra = nodes.count - 3 + if extra > 0 { + lines.append("... +\(extra) more") + } + return lines.joined(separator: "\n") + } + + if let pending = dict["pending"] as? [Any], let paired = dict["paired"] as? [Any] { + return "Pairing requests: \(pending.count) pending, \(paired.count) paired." + } + + if let pending = dict["pending"] as? [Any] { + if pending.isEmpty { return "No pending pairing requests." } + return "\(pending.count) pending pairing request\(pending.count == 1 ? "" : "s")." + } + + return nil + } + + private static func pairingDetail(_ node: [String: Any]) -> String? { + if let paired = node["paired"] as? Bool, !paired { + return "pairing required" + } + + for key in ["status", "state", "deviceStatus"] { + if let raw = node[key] as? String, raw.lowercased().contains("pairing required") { + return "pairing required" + } + } + return nil + } + + private static func firstString(in dict: [String: Any], keys: [String]) -> String? { + for key in keys { + if let value = dict[key] as? String { + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) + if !trimmed.isEmpty { + return trimmed + } + } + } + return nil + } + + private static func sanitizeError(_ raw: String) -> String { + var cleaned = raw.trimmingCharacters(in: .whitespacesAndNewlines) + if cleaned.contains("agent="), + cleaned.contains("action="), + let marker = cleaned.range(of: ": ") + { + cleaned = String(cleaned[marker.upperBound...]).trimmingCharacters(in: .whitespacesAndNewlines) + } + + if let firstLine = cleaned.split(separator: "\n").first { + cleaned = String(firstLine).trimmingCharacters(in: .whitespacesAndNewlines) + } + + if cleaned.count > 220 { + cleaned = String(cleaned.prefix(217)) + "..." + } + return cleaned + } +} diff --git a/apps/shared/OpenClawKit/Sources/OpenClawKit/Capabilities.swift b/apps/shared/OpenClawKit/Sources/OpenClawKit/Capabilities.swift index d5c5e3c439c..49f9efe996b 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawKit/Capabilities.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawKit/Capabilities.swift @@ -7,6 +7,7 @@ public enum OpenClawCapability: String, Codable, Sendable { case voiceWake case location case device + case watch case photos case contacts case calendar diff --git a/apps/shared/OpenClawKit/Sources/OpenClawKit/DeepLinks.swift b/apps/shared/OpenClawKit/Sources/OpenClawKit/DeepLinks.swift index 10dd7ea0536..50714884619 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawKit/DeepLinks.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawKit/DeepLinks.swift @@ -1,7 +1,96 @@ import Foundation +import Network public enum DeepLinkRoute: Sendable, Equatable { case agent(AgentDeepLink) + case gateway(GatewayConnectDeepLink) +} + +public struct GatewayConnectDeepLink: Codable, Sendable, Equatable { + public let host: String + public let port: Int + public let tls: Bool + public let token: String? + public let password: String? + + public init(host: String, port: Int, tls: Bool, token: String?, password: String?) { + self.host = host + self.port = port + self.tls = tls + self.token = token + self.password = password + } + + fileprivate static func isLoopbackHost(_ raw: String) -> Bool { + var host = raw + .trimmingCharacters(in: .whitespacesAndNewlines) + .lowercased() + .trimmingCharacters(in: CharacterSet(charactersIn: "[]")) + if host.hasSuffix(".") { + host.removeLast() + } + if let zoneIndex = host.firstIndex(of: "%") { + host = String(host[.. GatewayConnectDeepLink? { + guard let data = Self.decodeBase64Url(code) else { return nil } + guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { return nil } + guard let urlString = json["url"] as? String, + let parsed = URLComponents(string: urlString), + let hostname = parsed.host, !hostname.isEmpty + else { return nil } + + let scheme = (parsed.scheme ?? "ws").lowercased() + guard scheme == "ws" || scheme == "wss" else { return nil } + let tls = scheme == "wss" + if !tls, !Self.isLoopbackHost(hostname) { + return nil + } + let port = parsed.port ?? (tls ? 443 : 18789) + let token = json["token"] as? String + let password = json["password"] as? String + return GatewayConnectDeepLink(host: hostname, port: port, tls: tls, token: token, password: password) + } + + private static func decodeBase64Url(_ input: String) -> Data? { + var base64 = input + .replacingOccurrences(of: "-", with: "+") + .replacingOccurrences(of: "_", with: "/") + let remainder = base64.count % 4 + if remainder > 0 { + base64.append(contentsOf: String(repeating: "=", count: 4 - remainder)) + } + return Data(base64Encoded: base64) + } } public struct AgentDeepLink: Codable, Sendable, Equatable { @@ -69,6 +158,26 @@ public enum DeepLinkParser { channel: query["channel"], timeoutSeconds: timeoutSeconds, key: query["key"])) + + case "gateway": + guard let hostParam = query["host"], + !hostParam.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + else { + return nil + } + let port = query["port"].flatMap { Int($0) } ?? 18789 + let tls = (query["tls"] as NSString?)?.boolValue ?? false + if !tls, !GatewayConnectDeepLink.isLoopbackHost(hostParam) { + return nil + } + return .gateway( + .init( + host: hostParam, + port: port, + tls: tls, + token: query["token"], + password: query["password"])) + default: return nil } diff --git a/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayChannel.swift b/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayChannel.swift index a255fc7a81d..30935df79d4 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayChannel.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayChannel.swift @@ -7,6 +7,7 @@ public protocol WebSocketTasking: AnyObject { func resume() func cancel(with closeCode: URLSessionWebSocketTask.CloseCode, reason: Data?) func send(_ message: URLSessionWebSocketTask.Message) async throws + func sendPing(pongReceiveHandler: @escaping @Sendable (Error?) -> Void) func receive() async throws -> URLSessionWebSocketTask.Message func receive(completionHandler: @escaping @Sendable (Result) -> Void) } @@ -40,6 +41,18 @@ public struct WebSocketTaskBox: @unchecked Sendable { { self.task.receive(completionHandler: completionHandler) } + + public func sendPing() async throws { + try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in + self.task.sendPing { error in + if let error { + continuation.resume(throwing: error) + } else { + continuation.resume(returning: ()) + } + } + } + } } public protocol WebSocketSessioning: AnyObject { @@ -72,9 +85,9 @@ public struct GatewayConnectOptions: Sendable { public var clientId: String public var clientMode: String public var clientDisplayName: String? - // When false, the connection omits the signed device identity payload. - // This is useful for secondary "operator" connections where the shared gateway token - // should authorize without triggering device pairing flows. + // 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. public var includeDeviceIdentity: Bool public init( @@ -114,6 +127,14 @@ private enum ConnectChallengeError: Error { case timeout } +private let defaultOperatorConnectScopes: [String] = [ + "operator.admin", + "operator.read", + "operator.write", + "operator.approvals", + "operator.pairing", +] + public actor GatewayChannelActor { private let logger = Logger(subsystem: "ai.openclaw", category: "gateway") private var task: WebSocketTaskBox? @@ -133,10 +154,16 @@ public actor GatewayChannelActor { private var lastAuthSource: GatewayAuthSource = .none private let decoder = JSONDecoder() private let encoder = JSONEncoder() - private let connectTimeoutSeconds: Double = 6 - private let connectChallengeTimeoutSeconds: Double = 3.0 + // Remote gateways (tailscale/wan) can take longer to deliver connect.challenge. + // Connect now requires this nonce before we send device-auth. + private let connectTimeoutSeconds: Double = 12 + private let connectChallengeTimeoutSeconds: Double = 6.0 + // Some networks will silently drop idle TCP/TLS flows around ~30s. The gateway tick is server->client, + // but NATs/proxies often require outbound traffic to keep the connection alive. + private let keepaliveIntervalSeconds: Double = 15.0 private var watchdogTask: Task? private var tickTask: Task? + private var keepaliveTask: Task? private let defaultRequestTimeoutMs: Double = 15000 private let pushHandler: (@Sendable (GatewayPush) async -> Void)? private let connectOptions: GatewayConnectOptions? @@ -175,6 +202,9 @@ public actor GatewayChannelActor { self.tickTask?.cancel() self.tickTask = nil + self.keepaliveTask?.cancel() + self.keepaliveTask = nil + self.task?.cancel(with: .goingAway, reason: nil) self.task = nil @@ -204,7 +234,7 @@ public actor GatewayChannelActor { private func watchdogLoop() async { // Keep nudging reconnect in case exponential backoff stalls. while self.shouldReconnect { - try? await Task.sleep(nanoseconds: 30 * 1_000_000_000) // 30s cadence + guard await self.sleepUnlessCancelled(nanoseconds: 30 * 1_000_000_000) else { return } // 30s cadence guard self.shouldReconnect else { return } if self.connected { continue } do { @@ -257,6 +287,7 @@ public actor GatewayChannelActor { self.connected = true self.backoffMs = 500 self.lastSeq = nil + self.startKeepalive() let waiters = self.connectWaiters self.connectWaiters.removeAll() @@ -265,12 +296,37 @@ public actor GatewayChannelActor { } } + private func startKeepalive() { + self.keepaliveTask?.cancel() + self.keepaliveTask = Task { [weak self] in + guard let self else { return } + await self.keepaliveLoop() + } + } + + private func keepaliveLoop() async { + while self.shouldReconnect { + guard await self.sleepUnlessCancelled( + nanoseconds: UInt64(self.keepaliveIntervalSeconds * 1_000_000_000)) + else { return } + guard self.shouldReconnect else { return } + guard self.connected else { continue } + guard let task = self.task else { continue } + // Best-effort ping keeps NAT/proxy state alive without generating RPC load. + do { + try await task.sendPing() + } catch { + // Avoid spamming logs; the reconnect paths will surface meaningful errors. + } + } + } + private func sendConnect() async throws { let platform = InstanceIdentity.platformString let primaryLocale = Locale.preferredLanguages.first ?? Locale.current.identifier let options = self.connectOptions ?? GatewayConnectOptions( role: "operator", - scopes: ["operator.admin", "operator.approvals", "operator.pairing"], + scopes: defaultOperatorConnectScopes, caps: [], commands: [], permissions: [:], @@ -343,8 +399,8 @@ public actor GatewayChannelActor { let signedAtMs = Int(Date().timeIntervalSince1970 * 1000) let connectNonce = try await self.waitForConnectChallenge() let scopesValue = scopes.joined(separator: ",") - var payloadParts = [ - connectNonce == nil ? "v1" : "v2", + let payloadParts = [ + "v2", identity?.deviceId ?? "", clientId, clientMode, @@ -352,23 +408,19 @@ public actor GatewayChannelActor { scopesValue, String(signedAtMs), authToken ?? "", + connectNonce, ] - if let connectNonce { - payloadParts.append(connectNonce) - } let payload = payloadParts.joined(separator: "|") if includeDeviceIdentity, let identity { if let signature = DeviceIdentityStore.signPayload(payload, identity: identity), let publicKey = DeviceIdentityStore.publicKeyBase64Url(identity) { - var device: [String: ProtoAnyCodable] = [ + let device: [String: ProtoAnyCodable] = [ "id": ProtoAnyCodable(identity.deviceId), "publicKey": ProtoAnyCodable(publicKey), "signature": ProtoAnyCodable(signature), "signedAt": ProtoAnyCodable(signedAtMs), + "nonce": ProtoAnyCodable(connectNonce), ] - if let connectNonce { - device["nonce"] = ProtoAnyCodable(connectNonce) - } params["device"] = ProtoAnyCodable(device) } } @@ -458,6 +510,8 @@ public actor GatewayChannelActor { let wrapped = self.wrap(err, context: "gateway receive") self.logger.error("gateway ws receive failed \(wrapped.localizedDescription, privacy: .public)") self.connected = false + self.keepaliveTask?.cancel() + self.keepaliveTask = nil await self.disconnectHandler?("receive failed: \(wrapped.localizedDescription)") await self.failPending(wrapped) await self.scheduleReconnect() @@ -495,33 +549,26 @@ public actor GatewayChannelActor { } } - private func waitForConnectChallenge() async throws -> String? { - guard let task = self.task else { return nil } - do { - return try await AsyncTimeout.withTimeout( - seconds: self.connectChallengeTimeoutSeconds, - onTimeout: { ConnectChallengeError.timeout }, - operation: { [weak self] in - guard let self else { return nil } - while true { - let msg = try await task.receive() - guard let data = self.decodeMessageData(msg) else { continue } - guard let frame = try? self.decoder.decode(GatewayFrame.self, from: data) else { continue } - if case let .event(evt) = frame, evt.event == "connect.challenge" { - if let payload = evt.payload?.value as? [String: ProtoAnyCodable], - let nonce = payload["nonce"]?.value as? String { - return nonce - } - } + private func waitForConnectChallenge() async throws -> String { + guard let task = self.task else { throw ConnectChallengeError.timeout } + return try await AsyncTimeout.withTimeout( + seconds: self.connectChallengeTimeoutSeconds, + onTimeout: { ConnectChallengeError.timeout }, + operation: { [weak self] in + guard let self else { throw ConnectChallengeError.timeout } + while true { + let msg = try await task.receive() + guard let data = self.decodeMessageData(msg) else { continue } + guard let frame = try? self.decoder.decode(GatewayFrame.self, from: data) else { continue } + if case let .event(evt) = frame, evt.event == "connect.challenge", + let payload = evt.payload?.value as? [String: ProtoAnyCodable], + let nonce = payload["nonce"]?.value as? String, + nonce.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false + { + return nonce } - }) - } catch { - if error is ConnectChallengeError { - self.logger.warning("gateway connect challenge timed out") - return nil - } - throw error - } + } + }) } private func waitForConnectResponse(reqId: String) async throws -> ResponseFrame { @@ -558,7 +605,7 @@ public actor GatewayChannelActor { private func watchTicks() async { let tolerance = self.tickIntervalMs * 2 while self.connected { - try? await Task.sleep(nanoseconds: UInt64(tolerance * 1_000_000)) + guard await self.sleepUnlessCancelled(nanoseconds: UInt64(tolerance * 1_000_000)) else { return } guard self.connected else { return } if let last = self.lastTick { let delta = Date().timeIntervalSince(last) * 1000 @@ -581,7 +628,7 @@ public actor GatewayChannelActor { guard self.shouldReconnect else { return } let delay = self.backoffMs / 1000 self.backoffMs = min(self.backoffMs * 2, 30000) - try? await Task.sleep(nanoseconds: UInt64(delay * 1_000_000_000)) + guard await self.sleepUnlessCancelled(nanoseconds: UInt64(delay * 1_000_000_000)) else { return } guard self.shouldReconnect else { return } do { try await self.connect() @@ -592,6 +639,15 @@ public actor GatewayChannelActor { } } + private nonisolated func sleepUnlessCancelled(nanoseconds: UInt64) async -> Bool { + do { + try await Task.sleep(nanoseconds: nanoseconds) + } catch { + return false + } + return !Task.isCancelled + } + public func request( method: String, params: [String: AnyCodable]?, diff --git a/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayNodeSession.swift b/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayNodeSession.swift index 6311b4632cb..7dd2fe1eee1 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayNodeSession.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayNodeSession.swift @@ -26,6 +26,7 @@ public actor GatewayNodeSession { private var onConnected: (@Sendable () async -> Void)? private var onDisconnected: (@Sendable (String) async -> Void)? private var onInvoke: (@Sendable (BridgeInvokeRequest) async -> BridgeInvokeResponse)? + private var hasEverConnected = false private var hasNotifiedConnected = false private var snapshotReceived = false private var snapshotWaiters: [CheckedContinuation] = [] @@ -85,7 +86,13 @@ public actor GatewayNodeSession { latch.resume(result) } timeoutTask = Task.detached { - try? await Task.sleep(nanoseconds: UInt64(timeout) * 1_000_000) + do { + try await Task.sleep(nanoseconds: UInt64(timeout) * 1_000_000) + } catch { + // Expected when invoke finishes first and cancels the timeout task. + return + } + guard !Task.isCancelled else { return } timeoutLogger.info("node invoke timeout fired id=\(request.id, privacy: .public)") latch.resume(BridgeInvokeResponse( id: request.id, @@ -208,6 +215,7 @@ public actor GatewayNodeSession { self.activeToken = nil self.activePassword = nil self.activeConnectOptionsKey = nil + self.hasEverConnected = false self.resetConnectionState() } @@ -268,6 +276,11 @@ public actor GatewayNodeSession { case let .snapshot(ok): let raw = ok.canvashosturl?.trimmingCharacters(in: .whitespacesAndNewlines) self.canvasHostUrl = (raw?.isEmpty == false) ? raw : nil + if self.hasEverConnected { + self.broadcastServerEvent( + EventFrame(type: "event", event: "seqGap", payload: nil, seq: nil, stateversion: nil)) + } + self.hasEverConnected = true self.markSnapshotReceived() await self.notifyConnectedIfNeeded() case let .event(evt): diff --git a/apps/shared/OpenClawKit/Sources/OpenClawKit/ShareGatewayRelaySettings.swift b/apps/shared/OpenClawKit/Sources/OpenClawKit/ShareGatewayRelaySettings.swift new file mode 100644 index 00000000000..7b4c3864b37 --- /dev/null +++ b/apps/shared/OpenClawKit/Sources/OpenClawKit/ShareGatewayRelaySettings.swift @@ -0,0 +1,62 @@ +import Foundation + +public struct ShareGatewayRelayConfig: Codable, Sendable, Equatable { + public let gatewayURLString: String + public let token: String? + public let password: String? + public let sessionKey: String + public let deliveryChannel: String? + public let deliveryTo: String? + + public init( + gatewayURLString: String, + token: String?, + password: String?, + sessionKey: String, + deliveryChannel: String? = nil, + deliveryTo: String? = nil) + { + self.gatewayURLString = gatewayURLString + self.token = token + self.password = password + self.sessionKey = sessionKey + self.deliveryChannel = deliveryChannel + self.deliveryTo = deliveryTo + } +} + +public enum ShareGatewayRelaySettings { + private static let suiteName = "group.ai.openclaw.shared" + private static let relayConfigKey = "share.gatewayRelay.config.v1" + private static let lastEventKey = "share.gatewayRelay.event.v1" + + private static var defaults: UserDefaults { + UserDefaults(suiteName: self.suiteName) ?? .standard + } + + public static func loadConfig() -> ShareGatewayRelayConfig? { + guard let data = self.defaults.data(forKey: self.relayConfigKey) else { return nil } + return try? JSONDecoder().decode(ShareGatewayRelayConfig.self, from: data) + } + + public static func saveConfig(_ config: ShareGatewayRelayConfig) { + guard let data = try? JSONEncoder().encode(config) else { return } + self.defaults.set(data, forKey: self.relayConfigKey) + } + + public static func clearConfig() { + self.defaults.removeObject(forKey: self.relayConfigKey) + } + + public static func saveLastEvent(_ message: String) { + let timestamp = ISO8601DateFormatter().string(from: Date()) + let payload = "[\(timestamp)] \(message)" + self.defaults.set(payload, forKey: self.lastEventKey) + } + + public static func loadLastEvent() -> String? { + let value = self.defaults.string(forKey: self.lastEventKey)? + .trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + return value.isEmpty ? nil : value + } +} diff --git a/apps/shared/OpenClawKit/Sources/OpenClawKit/ShareToAgentDeepLink.swift b/apps/shared/OpenClawKit/Sources/OpenClawKit/ShareToAgentDeepLink.swift new file mode 100644 index 00000000000..08f06234334 --- /dev/null +++ b/apps/shared/OpenClawKit/Sources/OpenClawKit/ShareToAgentDeepLink.swift @@ -0,0 +1,62 @@ +import Foundation + +public struct SharedContentPayload: Sendable, Equatable { + public let title: String? + public let url: URL? + public let text: String? + + public init(title: String?, url: URL?, text: String?) { + self.title = title + self.url = url + self.text = text + } +} + +public enum ShareToAgentDeepLink { + public static func buildURL(from payload: SharedContentPayload, instruction: String? = nil) -> URL? { + let message = self.buildMessage(from: payload, instruction: instruction) + guard !message.isEmpty else { return nil } + + var components = URLComponents() + components.scheme = "openclaw" + components.host = "agent" + components.queryItems = [ + URLQueryItem(name: "message", value: message), + URLQueryItem(name: "thinking", value: "low"), + ] + return components.url + } + + public static func buildMessage(from payload: SharedContentPayload, instruction: String? = nil) -> String { + let title = self.clean(payload.title) + let text = self.clean(payload.text) + let urlText = payload.url?.absoluteString.trimmingCharacters(in: .whitespacesAndNewlines) + let resolvedInstruction = self.clean(instruction) ?? ShareToAgentSettings.loadDefaultInstruction() + + var lines: [String] = ["Shared from iOS."] + if let title, !title.isEmpty { + lines.append("Title: \(title)") + } + if let urlText, !urlText.isEmpty { + lines.append("URL: \(urlText)") + } + if let text, !text.isEmpty { + lines.append("Text:\n\(text)") + } + lines.append(resolvedInstruction) + + let message = lines.joined(separator: "\n\n") + return self.limit(message, maxCharacters: 2400) + } + + private static func clean(_ value: String?) -> String? { + guard let value else { return nil } + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? nil : trimmed + } + + private static func limit(_ value: String, maxCharacters: Int) -> String { + guard value.count > maxCharacters else { return value } + return String(value.prefix(maxCharacters)) + } +} diff --git a/apps/shared/OpenClawKit/Sources/OpenClawKit/ShareToAgentSettings.swift b/apps/shared/OpenClawKit/Sources/OpenClawKit/ShareToAgentSettings.swift new file mode 100644 index 00000000000..9034dcfe1b6 --- /dev/null +++ b/apps/shared/OpenClawKit/Sources/OpenClawKit/ShareToAgentSettings.swift @@ -0,0 +1,29 @@ +import Foundation + +public enum ShareToAgentSettings { + private static let suiteName = "group.ai.openclaw.shared" + private static let defaultInstructionKey = "share.defaultInstruction" + private static let fallbackInstruction = "Please help me with this." + + private static var defaults: UserDefaults { + UserDefaults(suiteName: suiteName) ?? .standard + } + + public static func loadDefaultInstruction() -> String { + let raw = self.defaults.string(forKey: self.defaultInstructionKey)? + .trimmingCharacters(in: .whitespacesAndNewlines) + if let raw, !raw.isEmpty { + return raw + } + return self.fallbackInstruction + } + + public static func saveDefaultInstruction(_ value: String?) { + let trimmed = value?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + if trimmed.isEmpty { + self.defaults.removeObject(forKey: self.defaultInstructionKey) + return + } + self.defaults.set(trimmed, forKey: self.defaultInstructionKey) + } +} diff --git a/apps/shared/OpenClawKit/Sources/OpenClawKit/TalkPromptBuilder.swift b/apps/shared/OpenClawKit/Sources/OpenClawKit/TalkPromptBuilder.swift index c63f40e9d3a..2a2e39d68cf 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawKit/TalkPromptBuilder.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawKit/TalkPromptBuilder.swift @@ -1,10 +1,19 @@ public enum TalkPromptBuilder: Sendable { - public static func build(transcript: String, interruptedAtSeconds: Double?) -> String { + public static func build( + transcript: String, + interruptedAtSeconds: Double?, + includeVoiceDirectiveHint: Bool = true + ) -> String { var lines: [String] = [ "Talk Mode active. Reply in a concise, spoken tone.", - "You may optionally prefix the response with JSON (first line) to set ElevenLabs voice (id or alias), e.g. {\"voice\":\"\",\"once\":true}.", ] + if includeVoiceDirectiveHint { + lines.append( + "You may optionally prefix the response with JSON (first line) to set ElevenLabs voice (id or alias), e.g. {\"voice\":\"\",\"once\":true}." + ) + } + if let interruptedAtSeconds { let formatted = String(format: "%.1f", interruptedAtSeconds) lines.append("Assistant speech interrupted at \(formatted)s.") diff --git a/apps/shared/OpenClawKit/Sources/OpenClawKit/WatchCommands.swift b/apps/shared/OpenClawKit/Sources/OpenClawKit/WatchCommands.swift new file mode 100644 index 00000000000..0bd6990710c --- /dev/null +++ b/apps/shared/OpenClawKit/Sources/OpenClawKit/WatchCommands.swift @@ -0,0 +1,95 @@ +import Foundation + +public enum OpenClawWatchCommand: String, Codable, Sendable { + case status = "watch.status" + case notify = "watch.notify" +} + +public enum OpenClawWatchRisk: String, Codable, Sendable, Equatable { + case low + case medium + case high +} + +public struct OpenClawWatchAction: Codable, Sendable, Equatable { + public var id: String + public var label: String + public var style: String? + + public init(id: String, label: String, style: String? = nil) { + self.id = id + self.label = label + self.style = style + } +} + +public struct OpenClawWatchStatusPayload: Codable, Sendable, Equatable { + public var supported: Bool + public var paired: Bool + public var appInstalled: Bool + public var reachable: Bool + public var activationState: String + + public init( + supported: Bool, + paired: Bool, + appInstalled: Bool, + reachable: Bool, + activationState: String) + { + self.supported = supported + self.paired = paired + self.appInstalled = appInstalled + self.reachable = reachable + self.activationState = activationState + } +} + +public struct OpenClawWatchNotifyParams: Codable, Sendable, Equatable { + public var title: String + public var body: String + public var priority: OpenClawNotificationPriority? + public var promptId: String? + public var sessionKey: String? + public var kind: String? + public var details: String? + public var expiresAtMs: Int? + public var risk: OpenClawWatchRisk? + public var actions: [OpenClawWatchAction]? + + public init( + title: String, + body: String, + priority: OpenClawNotificationPriority? = nil, + promptId: String? = nil, + sessionKey: String? = nil, + kind: String? = nil, + details: String? = nil, + expiresAtMs: Int? = nil, + risk: OpenClawWatchRisk? = nil, + actions: [OpenClawWatchAction]? = nil) + { + self.title = title + self.body = body + self.priority = priority + self.promptId = promptId + self.sessionKey = sessionKey + self.kind = kind + self.details = details + self.expiresAtMs = expiresAtMs + self.risk = risk + self.actions = actions + } +} + +public struct OpenClawWatchNotifyPayload: Codable, Sendable, Equatable { + public var deliveredImmediately: Bool + public var queuedForDelivery: Bool + public var transport: String + + public init(deliveredImmediately: Bool, queuedForDelivery: Bool, transport: String) { + self.deliveredImmediately = deliveredImmediately + self.queuedForDelivery = queuedForDelivery + self.transport = transport + } +} diff --git a/apps/shared/OpenClawKit/Sources/OpenClawProtocol/AnyCodable.swift b/apps/shared/OpenClawKit/Sources/OpenClawProtocol/AnyCodable.swift index 252e6131e4c..4315bb073ef 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawProtocol/AnyCodable.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawProtocol/AnyCodable.swift @@ -6,13 +6,13 @@ import Foundation public struct AnyCodable: Codable, @unchecked Sendable, Hashable { public let value: Any - public init(_ value: Any) { self.value = value } + public init(_ value: Any) { self.value = Self.normalize(value) } public init(from decoder: Decoder) throws { let container = try decoder.singleValueContainer() + if let boolVal = try? container.decode(Bool.self) { self.value = boolVal; return } if let intVal = try? container.decode(Int.self) { self.value = intVal; return } if let doubleVal = try? container.decode(Double.self) { self.value = doubleVal; return } - if let boolVal = try? container.decode(Bool.self) { self.value = boolVal; return } if let stringVal = try? container.decode(String.self) { self.value = stringVal; return } if container.decodeNil() { self.value = NSNull(); return } if let dict = try? container.decode([String: AnyCodable].self) { self.value = dict; return } @@ -23,10 +23,12 @@ public struct AnyCodable: Codable, @unchecked Sendable, Hashable { public func encode(to encoder: Encoder) throws { var container = encoder.singleValueContainer() switch self.value { + case let boolVal as Bool: try container.encode(boolVal) case let intVal as Int: try container.encode(intVal) case let doubleVal as Double: try container.encode(doubleVal) - case let boolVal as Bool: try container.encode(boolVal) case let stringVal as String: try container.encode(stringVal) + case let number as NSNumber where CFGetTypeID(number) == CFBooleanGetTypeID(): + try container.encode(number.boolValue) case is NSNull: try container.encodeNil() case let dict as [String: AnyCodable]: try container.encode(dict) case let array as [AnyCodable]: try container.encode(array) @@ -51,11 +53,18 @@ public struct AnyCodable: Codable, @unchecked Sendable, Hashable { } } + private static func normalize(_ value: Any) -> Any { + if let number = value as? NSNumber, CFGetTypeID(number) == CFBooleanGetTypeID() { + return number.boolValue + } + return value + } + public static func == (lhs: AnyCodable, rhs: AnyCodable) -> Bool { switch (lhs.value, rhs.value) { + case let (l as Bool, r as Bool): l == r case let (l as Int, r as Int): l == r case let (l as Double, r as Double): l == r - case let (l as Bool, r as Bool): l == r case let (l as String, r as String): l == r case (_ as NSNull, _ as NSNull): true case let (l as [String: AnyCodable], r as [String: AnyCodable]): l == r @@ -67,12 +76,12 @@ public struct AnyCodable: Codable, @unchecked Sendable, Hashable { public func hash(into hasher: inout Hasher) { switch self.value { + case let v as Bool: + hasher.combine(2); hasher.combine(v) case let v as Int: hasher.combine(0); hasher.combine(v) case let v as Double: hasher.combine(1); hasher.combine(v) - case let v as Bool: - hasher.combine(2); hasher.combine(v) case let v as String: hasher.combine(3); hasher.combine(v) case _ as NSNull: diff --git a/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift b/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift index 8486e4c4551..4e766514def 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift @@ -40,8 +40,8 @@ public struct ConnectParams: Codable, Sendable { device: [String: AnyCodable]?, auth: [String: AnyCodable]?, locale: String?, - useragent: String? - ) { + useragent: String?) + { self.minprotocol = minprotocol self.maxprotocol = maxprotocol self.client = client @@ -56,6 +56,7 @@ public struct ConnectParams: Codable, Sendable { self.locale = locale self.useragent = useragent } + private enum CodingKeys: String, CodingKey { case minprotocol = "minProtocol" case maxprotocol = "maxProtocol" @@ -91,8 +92,8 @@ public struct HelloOk: Codable, Sendable { snapshot: Snapshot, canvashosturl: String?, auth: [String: AnyCodable]?, - policy: [String: AnyCodable] - ) { + policy: [String: AnyCodable]) + { self.type = type self._protocol = _protocol self.server = server @@ -102,6 +103,7 @@ public struct HelloOk: Codable, Sendable { self.auth = auth self.policy = policy } + private enum CodingKeys: String, CodingKey { case type case _protocol = "protocol" @@ -124,13 +126,14 @@ public struct RequestFrame: Codable, Sendable { type: String, id: String, method: String, - params: AnyCodable? - ) { + params: AnyCodable?) + { self.type = type self.id = id self.method = method self.params = params } + private enum CodingKeys: String, CodingKey { case type case id @@ -151,14 +154,15 @@ public struct ResponseFrame: Codable, Sendable { id: String, ok: Bool, payload: AnyCodable?, - error: [String: AnyCodable]? - ) { + error: [String: AnyCodable]?) + { self.type = type self.id = id self.ok = ok self.payload = payload self.error = error } + private enum CodingKeys: String, CodingKey { case type case id @@ -180,14 +184,15 @@ public struct EventFrame: Codable, Sendable { event: String, payload: AnyCodable?, seq: Int?, - stateversion: [String: AnyCodable]? - ) { + stateversion: [String: AnyCodable]?) + { self.type = type self.event = event self.payload = payload self.seq = seq self.stateversion = stateversion } + private enum CodingKeys: String, CodingKey { case type case event @@ -231,8 +236,8 @@ public struct PresenceEntry: Codable, Sendable { deviceid: String?, roles: [String]?, scopes: [String]?, - instanceid: String? - ) { + instanceid: String?) + { self.host = host self.ip = ip self.version = version @@ -250,6 +255,7 @@ public struct PresenceEntry: Codable, Sendable { self.scopes = scopes self.instanceid = instanceid } + private enum CodingKeys: String, CodingKey { case host case ip @@ -276,11 +282,12 @@ public struct StateVersion: Codable, Sendable { public init( presence: Int, - health: Int - ) { + health: Int) + { self.presence = presence self.health = health } + private enum CodingKeys: String, CodingKey { case presence case health @@ -296,6 +303,7 @@ public struct Snapshot: Codable, Sendable { public let statedir: String? public let sessiondefaults: [String: AnyCodable]? public let authmode: AnyCodable? + public let updateavailable: [String: AnyCodable]? public init( presence: [PresenceEntry], @@ -305,8 +313,9 @@ public struct Snapshot: Codable, Sendable { configpath: String?, statedir: String?, sessiondefaults: [String: AnyCodable]?, - authmode: AnyCodable? - ) { + authmode: AnyCodable?, + updateavailable: [String: AnyCodable]?) + { self.presence = presence self.health = health self.stateversion = stateversion @@ -315,7 +324,9 @@ public struct Snapshot: Codable, Sendable { self.statedir = statedir self.sessiondefaults = sessiondefaults self.authmode = authmode + self.updateavailable = updateavailable } + private enum CodingKeys: String, CodingKey { case presence case health @@ -325,6 +336,7 @@ public struct Snapshot: Codable, Sendable { case statedir = "stateDir" case sessiondefaults = "sessionDefaults" case authmode = "authMode" + case updateavailable = "updateAvailable" } } @@ -340,14 +352,15 @@ public struct ErrorShape: Codable, Sendable { message: String, details: AnyCodable?, retryable: Bool?, - retryafterms: Int? - ) { + retryafterms: Int?) + { self.code = code self.message = message self.details = details self.retryable = retryable self.retryafterms = retryafterms } + private enum CodingKeys: String, CodingKey { case code case message @@ -369,14 +382,15 @@ public struct AgentEvent: Codable, Sendable { seq: Int, stream: String, ts: Int, - data: [String: AnyCodable] - ) { + data: [String: AnyCodable]) + { self.runid = runid self.seq = seq self.stream = stream self.ts = ts self.data = data } + private enum CodingKeys: String, CodingKey { case runid = "runId" case seq @@ -394,6 +408,7 @@ public struct SendParams: Codable, Sendable { public let gifplayback: Bool? public let channel: String? public let accountid: String? + public let threadid: String? public let sessionkey: String? public let idempotencykey: String @@ -405,9 +420,10 @@ public struct SendParams: Codable, Sendable { gifplayback: Bool?, channel: String?, accountid: String?, + threadid: String?, sessionkey: String?, - idempotencykey: String - ) { + idempotencykey: String) + { self.to = to self.message = message self.mediaurl = mediaurl @@ -415,9 +431,11 @@ public struct SendParams: Codable, Sendable { self.gifplayback = gifplayback self.channel = channel self.accountid = accountid + self.threadid = threadid self.sessionkey = sessionkey self.idempotencykey = idempotencykey } + private enum CodingKeys: String, CodingKey { case to case message @@ -426,6 +444,7 @@ public struct SendParams: Codable, Sendable { case gifplayback = "gifPlayback" case channel case accountid = "accountId" + case threadid = "threadId" case sessionkey = "sessionKey" case idempotencykey = "idempotencyKey" } @@ -457,8 +476,8 @@ public struct PollParams: Codable, Sendable { threadid: String?, channel: String?, accountid: String?, - idempotencykey: String - ) { + idempotencykey: String) + { self.to = to self.question = question self.options = options @@ -472,6 +491,7 @@ public struct PollParams: Codable, Sendable { self.accountid = accountid self.idempotencykey = idempotencykey } + private enum CodingKeys: String, CodingKey { case to case question @@ -507,6 +527,7 @@ public struct AgentParams: Codable, Sendable { public let groupchannel: String? public let groupspace: String? public let timeout: Int? + public let besteffortdeliver: Bool? public let lane: String? public let extrasystemprompt: String? public let inputprovenance: [String: AnyCodable]? @@ -533,13 +554,14 @@ public struct AgentParams: Codable, Sendable { groupchannel: String?, groupspace: String?, timeout: Int?, + besteffortdeliver: Bool?, lane: String?, extrasystemprompt: String?, inputprovenance: [String: AnyCodable]?, idempotencykey: String, label: String?, - spawnedby: String? - ) { + spawnedby: String?) + { self.message = message self.agentid = agentid self.to = to @@ -558,6 +580,7 @@ public struct AgentParams: Codable, Sendable { self.groupchannel = groupchannel self.groupspace = groupspace self.timeout = timeout + self.besteffortdeliver = besteffortdeliver self.lane = lane self.extrasystemprompt = extrasystemprompt self.inputprovenance = inputprovenance @@ -565,6 +588,7 @@ public struct AgentParams: Codable, Sendable { self.label = label self.spawnedby = spawnedby } + private enum CodingKeys: String, CodingKey { case message case agentid = "agentId" @@ -584,6 +608,7 @@ public struct AgentParams: Codable, Sendable { case groupchannel = "groupChannel" case groupspace = "groupSpace" case timeout + case besteffortdeliver = "bestEffortDeliver" case lane case extrasystemprompt = "extraSystemPrompt" case inputprovenance = "inputProvenance" @@ -599,11 +624,12 @@ public struct AgentIdentityParams: Codable, Sendable { public init( agentid: String?, - sessionkey: String? - ) { + sessionkey: String?) + { self.agentid = agentid self.sessionkey = sessionkey } + private enum CodingKeys: String, CodingKey { case agentid = "agentId" case sessionkey = "sessionKey" @@ -620,13 +646,14 @@ public struct AgentIdentityResult: Codable, Sendable { agentid: String, name: String?, avatar: String?, - emoji: String? - ) { + emoji: String?) + { self.agentid = agentid self.name = name self.avatar = avatar self.emoji = emoji } + private enum CodingKeys: String, CodingKey { case agentid = "agentId" case name @@ -641,11 +668,12 @@ public struct AgentWaitParams: Codable, Sendable { public init( runid: String, - timeoutms: Int? - ) { + timeoutms: Int?) + { self.runid = runid self.timeoutms = timeoutms } + private enum CodingKeys: String, CodingKey { case runid = "runId" case timeoutms = "timeoutMs" @@ -658,11 +686,12 @@ public struct WakeParams: Codable, Sendable { public init( mode: AnyCodable, - text: String - ) { + text: String) + { self.mode = mode self.text = text } + private enum CodingKeys: String, CodingKey { case mode case text @@ -695,8 +724,8 @@ public struct NodePairRequestParams: Codable, Sendable { caps: [String]?, commands: [String]?, remoteip: String?, - silent: Bool? - ) { + silent: Bool?) + { self.nodeid = nodeid self.displayname = displayname self.platform = platform @@ -710,6 +739,7 @@ public struct NodePairRequestParams: Codable, Sendable { self.remoteip = remoteip self.silent = silent } + private enum CodingKeys: String, CodingKey { case nodeid = "nodeId" case displayname = "displayName" @@ -726,17 +756,17 @@ public struct NodePairRequestParams: Codable, Sendable { } } -public struct NodePairListParams: Codable, Sendable { -} +public struct NodePairListParams: Codable, Sendable {} public struct NodePairApproveParams: Codable, Sendable { public let requestid: String public init( - requestid: String - ) { + requestid: String) + { self.requestid = requestid } + private enum CodingKeys: String, CodingKey { case requestid = "requestId" } @@ -746,10 +776,11 @@ public struct NodePairRejectParams: Codable, Sendable { public let requestid: String public init( - requestid: String - ) { + requestid: String) + { self.requestid = requestid } + private enum CodingKeys: String, CodingKey { case requestid = "requestId" } @@ -761,11 +792,12 @@ public struct NodePairVerifyParams: Codable, Sendable { public init( nodeid: String, - token: String - ) { + token: String) + { self.nodeid = nodeid self.token = token } + private enum CodingKeys: String, CodingKey { case nodeid = "nodeId" case token @@ -778,28 +810,29 @@ public struct NodeRenameParams: Codable, Sendable { public init( nodeid: String, - displayname: String - ) { + displayname: String) + { self.nodeid = nodeid self.displayname = displayname } + private enum CodingKeys: String, CodingKey { case nodeid = "nodeId" case displayname = "displayName" } } -public struct NodeListParams: Codable, Sendable { -} +public struct NodeListParams: Codable, Sendable {} public struct NodeDescribeParams: Codable, Sendable { public let nodeid: String public init( - nodeid: String - ) { + nodeid: String) + { self.nodeid = nodeid } + private enum CodingKeys: String, CodingKey { case nodeid = "nodeId" } @@ -817,14 +850,15 @@ public struct NodeInvokeParams: Codable, Sendable { command: String, params: AnyCodable?, timeoutms: Int?, - idempotencykey: String - ) { + idempotencykey: String) + { self.nodeid = nodeid self.command = command self.params = params self.timeoutms = timeoutms self.idempotencykey = idempotencykey } + private enum CodingKeys: String, CodingKey { case nodeid = "nodeId" case command @@ -848,8 +882,8 @@ public struct NodeInvokeResultParams: Codable, Sendable { ok: Bool, payload: AnyCodable?, payloadjson: String?, - error: [String: AnyCodable]? - ) { + error: [String: AnyCodable]?) + { self.id = id self.nodeid = nodeid self.ok = ok @@ -857,6 +891,7 @@ public struct NodeInvokeResultParams: Codable, Sendable { self.payloadjson = payloadjson self.error = error } + private enum CodingKeys: String, CodingKey { case id case nodeid = "nodeId" @@ -875,12 +910,13 @@ public struct NodeEventParams: Codable, Sendable { public init( event: String, payload: AnyCodable?, - payloadjson: String? - ) { + payloadjson: String?) + { self.event = event self.payload = payload self.payloadjson = payloadjson } + private enum CodingKeys: String, CodingKey { case event case payload @@ -902,8 +938,8 @@ public struct NodeInvokeRequestEvent: Codable, Sendable { command: String, paramsjson: String?, timeoutms: Int?, - idempotencykey: String? - ) { + idempotencykey: String?) + { self.id = id self.nodeid = nodeid self.command = command @@ -911,6 +947,7 @@ public struct NodeInvokeRequestEvent: Codable, Sendable { self.timeoutms = timeoutms self.idempotencykey = idempotencykey } + private enum CodingKeys: String, CodingKey { case id case nodeid = "nodeId" @@ -921,6 +958,70 @@ public struct NodeInvokeRequestEvent: Codable, Sendable { } } +public struct PushTestParams: Codable, Sendable { + public let nodeid: String + public let title: String? + public let body: String? + public let environment: String? + + public init( + nodeid: String, + title: String?, + body: String?, + environment: String?) + { + self.nodeid = nodeid + self.title = title + self.body = body + self.environment = environment + } + + private enum CodingKeys: String, CodingKey { + case nodeid = "nodeId" + case title + case body + case environment + } +} + +public struct PushTestResult: Codable, Sendable { + public let ok: Bool + public let status: Int + public let apnsid: String? + public let reason: String? + public let tokensuffix: String + public let topic: String + public let environment: String + + public init( + ok: Bool, + status: Int, + apnsid: String?, + reason: String?, + tokensuffix: String, + topic: String, + environment: String) + { + self.ok = ok + self.status = status + self.apnsid = apnsid + self.reason = reason + self.tokensuffix = tokensuffix + self.topic = topic + self.environment = environment + } + + private enum CodingKeys: String, CodingKey { + case ok + case status + case apnsid = "apnsId" + case reason + case tokensuffix = "tokenSuffix" + case topic + case environment + } +} + public struct SessionsListParams: Codable, Sendable { public let limit: Int? public let activeminutes: Int? @@ -943,8 +1044,8 @@ public struct SessionsListParams: Codable, Sendable { label: String?, spawnedby: String?, agentid: String?, - search: String? - ) { + search: String?) + { self.limit = limit self.activeminutes = activeminutes self.includeglobal = includeglobal @@ -956,6 +1057,7 @@ public struct SessionsListParams: Codable, Sendable { self.agentid = agentid self.search = search } + private enum CodingKeys: String, CodingKey { case limit case activeminutes = "activeMinutes" @@ -978,12 +1080,13 @@ public struct SessionsPreviewParams: Codable, Sendable { public init( keys: [String], limit: Int?, - maxchars: Int? - ) { + maxchars: Int?) + { self.keys = keys self.limit = limit self.maxchars = maxchars } + private enum CodingKeys: String, CodingKey { case keys case limit @@ -1007,8 +1110,8 @@ public struct SessionsResolveParams: Codable, Sendable { agentid: String?, spawnedby: String?, includeglobal: Bool?, - includeunknown: Bool? - ) { + includeunknown: Bool?) + { self.key = key self.sessionid = sessionid self.label = label @@ -1017,6 +1120,7 @@ public struct SessionsResolveParams: Codable, Sendable { self.includeglobal = includeglobal self.includeunknown = includeunknown } + private enum CodingKeys: String, CodingKey { case key case sessionid = "sessionId" @@ -1062,8 +1166,8 @@ public struct SessionsPatchParams: Codable, Sendable { spawnedby: AnyCodable?, spawndepth: AnyCodable?, sendpolicy: AnyCodable?, - groupactivation: AnyCodable? - ) { + groupactivation: AnyCodable?) + { self.key = key self.label = label self.thinkinglevel = thinkinglevel @@ -1081,6 +1185,7 @@ public struct SessionsPatchParams: Codable, Sendable { self.sendpolicy = sendpolicy self.groupactivation = groupactivation } + private enum CodingKeys: String, CodingKey { case key case label @@ -1107,11 +1212,12 @@ public struct SessionsResetParams: Codable, Sendable { public init( key: String, - reason: AnyCodable? - ) { + reason: AnyCodable?) + { self.key = key self.reason = reason } + private enum CodingKeys: String, CodingKey { case key case reason @@ -1121,17 +1227,22 @@ public struct SessionsResetParams: Codable, Sendable { public struct SessionsDeleteParams: Codable, Sendable { public let key: String public let deletetranscript: Bool? + public let emitlifecyclehooks: Bool? public init( key: String, - deletetranscript: Bool? - ) { + deletetranscript: Bool?, + emitlifecyclehooks: Bool?) + { self.key = key self.deletetranscript = deletetranscript + self.emitlifecyclehooks = emitlifecyclehooks } + private enum CodingKeys: String, CodingKey { case key case deletetranscript = "deleteTranscript" + case emitlifecyclehooks = "emitLifecycleHooks" } } @@ -1141,11 +1252,12 @@ public struct SessionsCompactParams: Codable, Sendable { public init( key: String, - maxlines: Int? - ) { + maxlines: Int?) + { self.key = key self.maxlines = maxlines } + private enum CodingKeys: String, CodingKey { case key case maxlines = "maxLines" @@ -1156,6 +1268,8 @@ public struct SessionsUsageParams: Codable, Sendable { public let key: String? public let startdate: String? public let enddate: String? + public let mode: AnyCodable? + public let utcoffset: String? public let limit: Int? public let includecontextweight: Bool? @@ -1163,26 +1277,32 @@ public struct SessionsUsageParams: Codable, Sendable { key: String?, startdate: String?, enddate: String?, + mode: AnyCodable?, + utcoffset: String?, limit: Int?, - includecontextweight: Bool? - ) { + includecontextweight: Bool?) + { self.key = key self.startdate = startdate self.enddate = enddate + self.mode = mode + self.utcoffset = utcoffset self.limit = limit self.includecontextweight = includecontextweight } + private enum CodingKeys: String, CodingKey { case key case startdate = "startDate" case enddate = "endDate" + case mode + case utcoffset = "utcOffset" case limit case includecontextweight = "includeContextWeight" } } -public struct ConfigGetParams: Codable, Sendable { -} +public struct ConfigGetParams: Codable, Sendable {} public struct ConfigSetParams: Codable, Sendable { public let raw: String @@ -1190,11 +1310,12 @@ public struct ConfigSetParams: Codable, Sendable { public init( raw: String, - basehash: String? - ) { + basehash: String?) + { self.raw = raw self.basehash = basehash } + private enum CodingKeys: String, CodingKey { case raw case basehash = "baseHash" @@ -1213,14 +1334,15 @@ public struct ConfigApplyParams: Codable, Sendable { basehash: String?, sessionkey: String?, note: String?, - restartdelayms: Int? - ) { + restartdelayms: Int?) + { self.raw = raw self.basehash = basehash self.sessionkey = sessionkey self.note = note self.restartdelayms = restartdelayms } + private enum CodingKeys: String, CodingKey { case raw case basehash = "baseHash" @@ -1242,14 +1364,15 @@ public struct ConfigPatchParams: Codable, Sendable { basehash: String?, sessionkey: String?, note: String?, - restartdelayms: Int? - ) { + restartdelayms: Int?) + { self.raw = raw self.basehash = basehash self.sessionkey = sessionkey self.note = note self.restartdelayms = restartdelayms } + private enum CodingKeys: String, CodingKey { case raw case basehash = "baseHash" @@ -1259,8 +1382,7 @@ public struct ConfigPatchParams: Codable, Sendable { } } -public struct ConfigSchemaParams: Codable, Sendable { -} +public struct ConfigSchemaParams: Codable, Sendable {} public struct ConfigSchemaResponse: Codable, Sendable { public let schema: AnyCodable @@ -1272,13 +1394,14 @@ public struct ConfigSchemaResponse: Codable, Sendable { schema: AnyCodable, uihints: [String: AnyCodable], version: String, - generatedat: String - ) { + generatedat: String) + { self.schema = schema self.uihints = uihints self.version = version self.generatedat = generatedat } + private enum CodingKeys: String, CodingKey { case schema case uihints = "uiHints" @@ -1293,11 +1416,12 @@ public struct WizardStartParams: Codable, Sendable { public init( mode: AnyCodable?, - workspace: String? - ) { + workspace: String?) + { self.mode = mode self.workspace = workspace } + private enum CodingKeys: String, CodingKey { case mode case workspace @@ -1310,11 +1434,12 @@ public struct WizardNextParams: Codable, Sendable { public init( sessionid: String, - answer: [String: AnyCodable]? - ) { + answer: [String: AnyCodable]?) + { self.sessionid = sessionid self.answer = answer } + private enum CodingKeys: String, CodingKey { case sessionid = "sessionId" case answer @@ -1325,10 +1450,11 @@ public struct WizardCancelParams: Codable, Sendable { public let sessionid: String public init( - sessionid: String - ) { + sessionid: String) + { self.sessionid = sessionid } + private enum CodingKeys: String, CodingKey { case sessionid = "sessionId" } @@ -1338,10 +1464,11 @@ public struct WizardStatusParams: Codable, Sendable { public let sessionid: String public init( - sessionid: String - ) { + sessionid: String) + { self.sessionid = sessionid } + private enum CodingKeys: String, CodingKey { case sessionid = "sessionId" } @@ -1367,8 +1494,8 @@ public struct WizardStep: Codable, Sendable { initialvalue: AnyCodable?, placeholder: String?, sensitive: Bool?, - executor: AnyCodable? - ) { + executor: AnyCodable?) + { self.id = id self.type = type self.title = title @@ -1379,6 +1506,7 @@ public struct WizardStep: Codable, Sendable { self.sensitive = sensitive self.executor = executor } + private enum CodingKeys: String, CodingKey { case id case type @@ -1402,13 +1530,14 @@ public struct WizardNextResult: Codable, Sendable { done: Bool, step: [String: AnyCodable]?, status: AnyCodable?, - error: String? - ) { + error: String?) + { self.done = done self.step = step self.status = status self.error = error } + private enum CodingKeys: String, CodingKey { case done case step @@ -1429,14 +1558,15 @@ public struct WizardStartResult: Codable, Sendable { done: Bool, step: [String: AnyCodable]?, status: AnyCodable?, - error: String? - ) { + error: String?) + { self.sessionid = sessionid self.done = done self.step = step self.status = status self.error = error } + private enum CodingKeys: String, CodingKey { case sessionid = "sessionId" case done @@ -1452,11 +1582,12 @@ public struct WizardStatusResult: Codable, Sendable { public init( status: AnyCodable, - error: String? - ) { + error: String?) + { self.status = status self.error = error } + private enum CodingKeys: String, CodingKey { case status case error @@ -1469,11 +1600,12 @@ public struct TalkModeParams: Codable, Sendable { public init( enabled: Bool, - phase: String? - ) { + phase: String?) + { self.enabled = enabled self.phase = phase } + private enum CodingKeys: String, CodingKey { case enabled case phase @@ -1484,10 +1616,11 @@ public struct TalkConfigParams: Codable, Sendable { public let includesecrets: Bool? public init( - includesecrets: Bool? - ) { + includesecrets: Bool?) + { self.includesecrets = includesecrets } + private enum CodingKeys: String, CodingKey { case includesecrets = "includeSecrets" } @@ -1497,10 +1630,11 @@ public struct TalkConfigResult: Codable, Sendable { public let config: [String: AnyCodable] public init( - config: [String: AnyCodable] - ) { + config: [String: AnyCodable]) + { self.config = config } + private enum CodingKeys: String, CodingKey { case config } @@ -1512,11 +1646,12 @@ public struct ChannelsStatusParams: Codable, Sendable { public init( probe: Bool?, - timeoutms: Int? - ) { + timeoutms: Int?) + { self.probe = probe self.timeoutms = timeoutms } + private enum CodingKeys: String, CodingKey { case probe case timeoutms = "timeoutMs" @@ -1543,8 +1678,8 @@ public struct ChannelsStatusResult: Codable, Sendable { channelmeta: [[String: AnyCodable]]?, channels: [String: AnyCodable], channelaccounts: [String: AnyCodable], - channeldefaultaccountid: [String: AnyCodable] - ) { + channeldefaultaccountid: [String: AnyCodable]) + { self.ts = ts self.channelorder = channelorder self.channellabels = channellabels @@ -1555,6 +1690,7 @@ public struct ChannelsStatusResult: Codable, Sendable { self.channelaccounts = channelaccounts self.channeldefaultaccountid = channeldefaultaccountid } + private enum CodingKeys: String, CodingKey { case ts case channelorder = "channelOrder" @@ -1574,11 +1710,12 @@ public struct ChannelsLogoutParams: Codable, Sendable { public init( channel: String, - accountid: String? - ) { + accountid: String?) + { self.channel = channel self.accountid = accountid } + private enum CodingKeys: String, CodingKey { case channel case accountid = "accountId" @@ -1595,13 +1732,14 @@ public struct WebLoginStartParams: Codable, Sendable { force: Bool?, timeoutms: Int?, verbose: Bool?, - accountid: String? - ) { + accountid: String?) + { self.force = force self.timeoutms = timeoutms self.verbose = verbose self.accountid = accountid } + private enum CodingKeys: String, CodingKey { case force case timeoutms = "timeoutMs" @@ -1616,11 +1754,12 @@ public struct WebLoginWaitParams: Codable, Sendable { public init( timeoutms: Int?, - accountid: String? - ) { + accountid: String?) + { self.timeoutms = timeoutms self.accountid = accountid } + private enum CodingKeys: String, CodingKey { case timeoutms = "timeoutMs" case accountid = "accountId" @@ -1635,12 +1774,13 @@ public struct AgentSummary: Codable, Sendable { public init( id: String, name: String?, - identity: [String: AnyCodable]? - ) { + identity: [String: AnyCodable]?) + { self.id = id self.name = name self.identity = identity } + private enum CodingKeys: String, CodingKey { case id case name @@ -1658,13 +1798,14 @@ public struct AgentsCreateParams: Codable, Sendable { name: String, workspace: String, emoji: String?, - avatar: String? - ) { + avatar: String?) + { self.name = name self.workspace = workspace self.emoji = emoji self.avatar = avatar } + private enum CodingKeys: String, CodingKey { case name case workspace @@ -1683,13 +1824,14 @@ public struct AgentsCreateResult: Codable, Sendable { ok: Bool, agentid: String, name: String, - workspace: String - ) { + workspace: String) + { self.ok = ok self.agentid = agentid self.name = name self.workspace = workspace } + private enum CodingKeys: String, CodingKey { case ok case agentid = "agentId" @@ -1710,14 +1852,15 @@ public struct AgentsUpdateParams: Codable, Sendable { name: String?, workspace: String?, model: String?, - avatar: String? - ) { + avatar: String?) + { self.agentid = agentid self.name = name self.workspace = workspace self.model = model self.avatar = avatar } + private enum CodingKeys: String, CodingKey { case agentid = "agentId" case name @@ -1733,11 +1876,12 @@ public struct AgentsUpdateResult: Codable, Sendable { public init( ok: Bool, - agentid: String - ) { + agentid: String) + { self.ok = ok self.agentid = agentid } + private enum CodingKeys: String, CodingKey { case ok case agentid = "agentId" @@ -1750,11 +1894,12 @@ public struct AgentsDeleteParams: Codable, Sendable { public init( agentid: String, - deletefiles: Bool? - ) { + deletefiles: Bool?) + { self.agentid = agentid self.deletefiles = deletefiles } + private enum CodingKeys: String, CodingKey { case agentid = "agentId" case deletefiles = "deleteFiles" @@ -1769,12 +1914,13 @@ public struct AgentsDeleteResult: Codable, Sendable { public init( ok: Bool, agentid: String, - removedbindings: Int - ) { + removedbindings: Int) + { self.ok = ok self.agentid = agentid self.removedbindings = removedbindings } + private enum CodingKeys: String, CodingKey { case ok case agentid = "agentId" @@ -1796,8 +1942,8 @@ public struct AgentsFileEntry: Codable, Sendable { missing: Bool, size: Int?, updatedatms: Int?, - content: String? - ) { + content: String?) + { self.name = name self.path = path self.missing = missing @@ -1805,6 +1951,7 @@ public struct AgentsFileEntry: Codable, Sendable { self.updatedatms = updatedatms self.content = content } + private enum CodingKeys: String, CodingKey { case name case path @@ -1819,10 +1966,11 @@ public struct AgentsFilesListParams: Codable, Sendable { public let agentid: String public init( - agentid: String - ) { + agentid: String) + { self.agentid = agentid } + private enum CodingKeys: String, CodingKey { case agentid = "agentId" } @@ -1836,12 +1984,13 @@ public struct AgentsFilesListResult: Codable, Sendable { public init( agentid: String, workspace: String, - files: [AgentsFileEntry] - ) { + files: [AgentsFileEntry]) + { self.agentid = agentid self.workspace = workspace self.files = files } + private enum CodingKeys: String, CodingKey { case agentid = "agentId" case workspace @@ -1855,11 +2004,12 @@ public struct AgentsFilesGetParams: Codable, Sendable { public init( agentid: String, - name: String - ) { + name: String) + { self.agentid = agentid self.name = name } + private enum CodingKeys: String, CodingKey { case agentid = "agentId" case name @@ -1874,12 +2024,13 @@ public struct AgentsFilesGetResult: Codable, Sendable { public init( agentid: String, workspace: String, - file: AgentsFileEntry - ) { + file: AgentsFileEntry) + { self.agentid = agentid self.workspace = workspace self.file = file } + private enum CodingKeys: String, CodingKey { case agentid = "agentId" case workspace @@ -1895,12 +2046,13 @@ public struct AgentsFilesSetParams: Codable, Sendable { public init( agentid: String, name: String, - content: String - ) { + content: String) + { self.agentid = agentid self.name = name self.content = content } + private enum CodingKeys: String, CodingKey { case agentid = "agentId" case name @@ -1918,13 +2070,14 @@ public struct AgentsFilesSetResult: Codable, Sendable { ok: Bool, agentid: String, workspace: String, - file: AgentsFileEntry - ) { + file: AgentsFileEntry) + { self.ok = ok self.agentid = agentid self.workspace = workspace self.file = file } + private enum CodingKeys: String, CodingKey { case ok case agentid = "agentId" @@ -1933,8 +2086,7 @@ public struct AgentsFilesSetResult: Codable, Sendable { } } -public struct AgentsListParams: Codable, Sendable { -} +public struct AgentsListParams: Codable, Sendable {} public struct AgentsListResult: Codable, Sendable { public let defaultid: String @@ -1946,13 +2098,14 @@ public struct AgentsListResult: Codable, Sendable { defaultid: String, mainkey: String, scope: AnyCodable, - agents: [AgentSummary] - ) { + agents: [AgentSummary]) + { self.defaultid = defaultid self.mainkey = mainkey self.scope = scope self.agents = agents } + private enum CodingKeys: String, CodingKey { case defaultid = "defaultId" case mainkey = "mainKey" @@ -1973,14 +2126,15 @@ public struct ModelChoice: Codable, Sendable { name: String, provider: String, contextwindow: Int?, - reasoning: Bool? - ) { + reasoning: Bool?) + { self.id = id self.name = name self.provider = provider self.contextwindow = contextwindow self.reasoning = reasoning } + private enum CodingKeys: String, CodingKey { case id case name @@ -1990,17 +2144,17 @@ public struct ModelChoice: Codable, Sendable { } } -public struct ModelsListParams: Codable, Sendable { -} +public struct ModelsListParams: Codable, Sendable {} public struct ModelsListResult: Codable, Sendable { public let models: [ModelChoice] public init( - models: [ModelChoice] - ) { + models: [ModelChoice]) + { self.models = models } + private enum CodingKeys: String, CodingKey { case models } @@ -2010,26 +2164,153 @@ public struct SkillsStatusParams: Codable, Sendable { public let agentid: String? public init( - agentid: String? - ) { + agentid: String?) + { self.agentid = agentid } + private enum CodingKeys: String, CodingKey { case agentid = "agentId" } } -public struct SkillsBinsParams: Codable, Sendable { +public struct ToolsCatalogParams: Codable, Sendable { + public let agentid: String? + public let includeplugins: Bool? + + public init( + agentid: String?, + includeplugins: Bool?) + { + self.agentid = agentid + self.includeplugins = includeplugins + } + + private enum CodingKeys: String, CodingKey { + case agentid = "agentId" + case includeplugins = "includePlugins" + } } +public struct ToolCatalogProfile: Codable, Sendable { + public let id: AnyCodable + public let label: String + + public init( + id: AnyCodable, + label: String) + { + self.id = id + self.label = label + } + + private enum CodingKeys: String, CodingKey { + case id + case label + } +} + +public struct ToolCatalogEntry: Codable, Sendable { + public let id: String + public let label: String + public let description: String + public let source: AnyCodable + public let pluginid: String? + public let optional: Bool? + public let defaultprofiles: [AnyCodable] + + public init( + id: String, + label: String, + description: String, + source: AnyCodable, + pluginid: String?, + optional: Bool?, + defaultprofiles: [AnyCodable]) + { + self.id = id + self.label = label + self.description = description + self.source = source + self.pluginid = pluginid + self.optional = optional + self.defaultprofiles = defaultprofiles + } + + private enum CodingKeys: String, CodingKey { + case id + case label + case description + case source + case pluginid = "pluginId" + case optional + case defaultprofiles = "defaultProfiles" + } +} + +public struct ToolCatalogGroup: Codable, Sendable { + public let id: String + public let label: String + public let source: AnyCodable + public let pluginid: String? + public let tools: [ToolCatalogEntry] + + public init( + id: String, + label: String, + source: AnyCodable, + pluginid: String?, + tools: [ToolCatalogEntry]) + { + self.id = id + self.label = label + self.source = source + self.pluginid = pluginid + self.tools = tools + } + + private enum CodingKeys: String, CodingKey { + case id + case label + case source + case pluginid = "pluginId" + case tools + } +} + +public struct ToolsCatalogResult: Codable, Sendable { + public let agentid: String + public let profiles: [ToolCatalogProfile] + public let groups: [ToolCatalogGroup] + + public init( + agentid: String, + profiles: [ToolCatalogProfile], + groups: [ToolCatalogGroup]) + { + self.agentid = agentid + self.profiles = profiles + self.groups = groups + } + + private enum CodingKeys: String, CodingKey { + case agentid = "agentId" + case profiles + case groups + } +} + +public struct SkillsBinsParams: Codable, Sendable {} + public struct SkillsBinsResult: Codable, Sendable { public let bins: [String] public init( - bins: [String] - ) { + bins: [String]) + { self.bins = bins } + private enum CodingKeys: String, CodingKey { case bins } @@ -2043,12 +2324,13 @@ public struct SkillsInstallParams: Codable, Sendable { public init( name: String, installid: String, - timeoutms: Int? - ) { + timeoutms: Int?) + { self.name = name self.installid = installid self.timeoutms = timeoutms } + private enum CodingKeys: String, CodingKey { case name case installid = "installId" @@ -2066,13 +2348,14 @@ public struct SkillsUpdateParams: Codable, Sendable { skillkey: String, enabled: Bool?, apikey: String?, - env: [String: AnyCodable]? - ) { + env: [String: AnyCodable]?) + { self.skillkey = skillkey self.enabled = enabled self.apikey = apikey self.env = env } + private enum CodingKeys: String, CodingKey { case skillkey = "skillKey" case enabled @@ -2084,6 +2367,7 @@ public struct SkillsUpdateParams: Codable, Sendable { public struct CronJob: Codable, Sendable { public let id: String public let agentid: String? + public let sessionkey: String? public let name: String public let description: String? public let enabled: Bool @@ -2100,6 +2384,7 @@ public struct CronJob: Codable, Sendable { public init( id: String, agentid: String?, + sessionkey: String?, name: String, description: String?, enabled: Bool, @@ -2111,10 +2396,11 @@ public struct CronJob: Codable, Sendable { wakemode: AnyCodable, payload: AnyCodable, delivery: AnyCodable?, - state: [String: AnyCodable] - ) { + state: [String: AnyCodable]) + { self.id = id self.agentid = agentid + self.sessionkey = sessionkey self.name = name self.description = description self.enabled = enabled @@ -2128,9 +2414,11 @@ public struct CronJob: Codable, Sendable { self.delivery = delivery self.state = state } + private enum CodingKeys: String, CodingKey { case id case agentid = "agentId" + case sessionkey = "sessionKey" case name case description case enabled @@ -2148,23 +2436,48 @@ public struct CronJob: Codable, Sendable { public struct CronListParams: Codable, Sendable { public let includedisabled: Bool? + public let limit: Int? + public let offset: Int? + public let query: String? + public let enabled: AnyCodable? + public let sortby: AnyCodable? + public let sortdir: AnyCodable? public init( - includedisabled: Bool? - ) { + includedisabled: Bool?, + limit: Int?, + offset: Int?, + query: String?, + enabled: AnyCodable?, + sortby: AnyCodable?, + sortdir: AnyCodable?) + { self.includedisabled = includedisabled + self.limit = limit + self.offset = offset + self.query = query + self.enabled = enabled + self.sortby = sortby + self.sortdir = sortdir } + private enum CodingKeys: String, CodingKey { case includedisabled = "includeDisabled" + case limit + case offset + case query + case enabled + case sortby = "sortBy" + case sortdir = "sortDir" } } -public struct CronStatusParams: Codable, Sendable { -} +public struct CronStatusParams: Codable, Sendable {} public struct CronAddParams: Codable, Sendable { public let name: String public let agentid: AnyCodable? + public let sessionkey: AnyCodable? public let description: String? public let enabled: Bool? public let deleteafterrun: Bool? @@ -2177,6 +2490,7 @@ public struct CronAddParams: Codable, Sendable { public init( name: String, agentid: AnyCodable?, + sessionkey: AnyCodable?, description: String?, enabled: Bool?, deleteafterrun: Bool?, @@ -2184,10 +2498,11 @@ public struct CronAddParams: Codable, Sendable { sessiontarget: AnyCodable, wakemode: AnyCodable, payload: AnyCodable, - delivery: AnyCodable? - ) { + delivery: AnyCodable?) + { self.name = name self.agentid = agentid + self.sessionkey = sessionkey self.description = description self.enabled = enabled self.deleteafterrun = deleteafterrun @@ -2197,9 +2512,11 @@ public struct CronAddParams: Codable, Sendable { self.payload = payload self.delivery = delivery } + private enum CodingKeys: String, CodingKey { case name case agentid = "agentId" + case sessionkey = "sessionKey" case description case enabled case deleteafterrun = "deleteAfterRun" @@ -2211,6 +2528,60 @@ public struct CronAddParams: Codable, Sendable { } } +public struct CronRunsParams: Codable, Sendable { + public let scope: AnyCodable? + public let id: String? + public let jobid: String? + public let limit: Int? + public let offset: Int? + public let statuses: [AnyCodable]? + public let status: AnyCodable? + public let deliverystatuses: [AnyCodable]? + public let deliverystatus: AnyCodable? + public let query: String? + public let sortdir: AnyCodable? + + public init( + scope: AnyCodable?, + id: String?, + jobid: String?, + limit: Int?, + offset: Int?, + statuses: [AnyCodable]?, + status: AnyCodable?, + deliverystatuses: [AnyCodable]?, + deliverystatus: AnyCodable?, + query: String?, + sortdir: AnyCodable?) + { + self.scope = scope + self.id = id + self.jobid = jobid + self.limit = limit + self.offset = offset + self.statuses = statuses + self.status = status + self.deliverystatuses = deliverystatuses + self.deliverystatus = deliverystatus + self.query = query + self.sortdir = sortdir + } + + private enum CodingKeys: String, CodingKey { + case scope + case id + case jobid = "jobId" + case limit + case offset + case statuses + case status + case deliverystatuses = "deliveryStatuses" + case deliverystatus = "deliveryStatus" + case query + case sortdir = "sortDir" + } +} + public struct CronRunLogEntry: Codable, Sendable { public let ts: Int public let jobid: String @@ -2218,11 +2589,18 @@ public struct CronRunLogEntry: Codable, Sendable { public let status: AnyCodable? public let error: String? public let summary: String? + public let delivered: Bool? + public let deliverystatus: AnyCodable? + public let deliveryerror: String? public let sessionid: String? public let sessionkey: String? public let runatms: Int? public let durationms: Int? public let nextrunatms: Int? + public let model: String? + public let provider: String? + public let usage: [String: AnyCodable]? + public let jobname: String? public init( ts: Int, @@ -2231,24 +2609,39 @@ public struct CronRunLogEntry: Codable, Sendable { status: AnyCodable?, error: String?, summary: String?, + delivered: Bool?, + deliverystatus: AnyCodable?, + deliveryerror: String?, sessionid: String?, sessionkey: String?, runatms: Int?, durationms: Int?, - nextrunatms: Int? - ) { + nextrunatms: Int?, + model: String?, + provider: String?, + usage: [String: AnyCodable]?, + jobname: String?) + { self.ts = ts self.jobid = jobid self.action = action self.status = status self.error = error self.summary = summary + self.delivered = delivered + self.deliverystatus = deliverystatus + self.deliveryerror = deliveryerror self.sessionid = sessionid self.sessionkey = sessionkey self.runatms = runatms self.durationms = durationms self.nextrunatms = nextrunatms + self.model = model + self.provider = provider + self.usage = usage + self.jobname = jobname } + private enum CodingKeys: String, CodingKey { case ts case jobid = "jobId" @@ -2256,11 +2649,18 @@ public struct CronRunLogEntry: Codable, Sendable { case status case error case summary + case delivered + case deliverystatus = "deliveryStatus" + case deliveryerror = "deliveryError" case sessionid = "sessionId" case sessionkey = "sessionKey" case runatms = "runAtMs" case durationms = "durationMs" case nextrunatms = "nextRunAtMs" + case model + case provider + case usage + case jobname = "jobName" } } @@ -2272,12 +2672,13 @@ public struct LogsTailParams: Codable, Sendable { public init( cursor: Int?, limit: Int?, - maxbytes: Int? - ) { + maxbytes: Int?) + { self.cursor = cursor self.limit = limit self.maxbytes = maxbytes } + private enum CodingKeys: String, CodingKey { case cursor case limit @@ -2299,8 +2700,8 @@ public struct LogsTailResult: Codable, Sendable { size: Int, lines: [String], truncated: Bool?, - reset: Bool? - ) { + reset: Bool?) + { self.file = file self.cursor = cursor self.size = size @@ -2308,6 +2709,7 @@ public struct LogsTailResult: Codable, Sendable { self.truncated = truncated self.reset = reset } + private enum CodingKeys: String, CodingKey { case file case cursor @@ -2318,8 +2720,7 @@ public struct LogsTailResult: Codable, Sendable { } } -public struct ExecApprovalsGetParams: Codable, Sendable { -} +public struct ExecApprovalsGetParams: Codable, Sendable {} public struct ExecApprovalsSetParams: Codable, Sendable { public let file: [String: AnyCodable] @@ -2327,11 +2728,12 @@ public struct ExecApprovalsSetParams: Codable, Sendable { public init( file: [String: AnyCodable], - basehash: String? - ) { + basehash: String?) + { self.file = file self.basehash = basehash } + private enum CodingKeys: String, CodingKey { case file case basehash = "baseHash" @@ -2342,10 +2744,11 @@ public struct ExecApprovalsNodeGetParams: Codable, Sendable { public let nodeid: String public init( - nodeid: String - ) { + nodeid: String) + { self.nodeid = nodeid } + private enum CodingKeys: String, CodingKey { case nodeid = "nodeId" } @@ -2359,12 +2762,13 @@ public struct ExecApprovalsNodeSetParams: Codable, Sendable { public init( nodeid: String, file: [String: AnyCodable], - basehash: String? - ) { + basehash: String?) + { self.nodeid = nodeid self.file = file self.basehash = basehash } + private enum CodingKeys: String, CodingKey { case nodeid = "nodeId" case file @@ -2382,13 +2786,14 @@ public struct ExecApprovalsSnapshot: Codable, Sendable { path: String, exists: Bool, hash: String, - file: [String: AnyCodable] - ) { + file: [String: AnyCodable]) + { self.path = path self.exists = exists self.hash = hash self.file = file } + private enum CodingKeys: String, CodingKey { case path case exists @@ -2401,6 +2806,7 @@ public struct ExecApprovalRequestParams: Codable, Sendable { public let id: String? public let command: String public let cwd: AnyCodable? + public let nodeid: AnyCodable? public let host: AnyCodable? public let security: AnyCodable? public let ask: AnyCodable? @@ -2414,6 +2820,7 @@ public struct ExecApprovalRequestParams: Codable, Sendable { id: String?, command: String, cwd: AnyCodable?, + nodeid: AnyCodable?, host: AnyCodable?, security: AnyCodable?, ask: AnyCodable?, @@ -2421,11 +2828,12 @@ public struct ExecApprovalRequestParams: Codable, Sendable { resolvedpath: AnyCodable?, sessionkey: AnyCodable?, timeoutms: Int?, - twophase: Bool? - ) { + twophase: Bool?) + { self.id = id self.command = command self.cwd = cwd + self.nodeid = nodeid self.host = host self.security = security self.ask = ask @@ -2435,10 +2843,12 @@ public struct ExecApprovalRequestParams: Codable, Sendable { self.timeoutms = timeoutms self.twophase = twophase } + private enum CodingKeys: String, CodingKey { case id case command case cwd + case nodeid = "nodeId" case host case security case ask @@ -2456,28 +2866,29 @@ public struct ExecApprovalResolveParams: Codable, Sendable { public init( id: String, - decision: String - ) { + decision: String) + { self.id = id self.decision = decision } + private enum CodingKeys: String, CodingKey { case id case decision } } -public struct DevicePairListParams: Codable, Sendable { -} +public struct DevicePairListParams: Codable, Sendable {} public struct DevicePairApproveParams: Codable, Sendable { public let requestid: String public init( - requestid: String - ) { + requestid: String) + { self.requestid = requestid } + private enum CodingKeys: String, CodingKey { case requestid = "requestId" } @@ -2487,15 +2898,30 @@ public struct DevicePairRejectParams: Codable, Sendable { public let requestid: String public init( - requestid: String - ) { + requestid: String) + { self.requestid = requestid } + private enum CodingKeys: String, CodingKey { case requestid = "requestId" } } +public struct DevicePairRemoveParams: Codable, Sendable { + public let deviceid: String + + public init( + deviceid: String) + { + self.deviceid = deviceid + } + + private enum CodingKeys: String, CodingKey { + case deviceid = "deviceId" + } +} + public struct DeviceTokenRotateParams: Codable, Sendable { public let deviceid: String public let role: String @@ -2504,12 +2930,13 @@ public struct DeviceTokenRotateParams: Codable, Sendable { public init( deviceid: String, role: String, - scopes: [String]? - ) { + scopes: [String]?) + { self.deviceid = deviceid self.role = role self.scopes = scopes } + private enum CodingKeys: String, CodingKey { case deviceid = "deviceId" case role @@ -2523,11 +2950,12 @@ public struct DeviceTokenRevokeParams: Codable, Sendable { public init( deviceid: String, - role: String - ) { + role: String) + { self.deviceid = deviceid self.role = role } + private enum CodingKeys: String, CodingKey { case deviceid = "deviceId" case role @@ -2564,8 +2992,8 @@ public struct DevicePairRequestedEvent: Codable, Sendable { remoteip: String?, silent: Bool?, isrepair: Bool?, - ts: Int - ) { + ts: Int) + { self.requestid = requestid self.deviceid = deviceid self.publickey = publickey @@ -2581,6 +3009,7 @@ public struct DevicePairRequestedEvent: Codable, Sendable { self.isrepair = isrepair self.ts = ts } + private enum CodingKeys: String, CodingKey { case requestid = "requestId" case deviceid = "deviceId" @@ -2609,13 +3038,14 @@ public struct DevicePairResolvedEvent: Codable, Sendable { requestid: String, deviceid: String, decision: String, - ts: Int - ) { + ts: Int) + { self.requestid = requestid self.deviceid = deviceid self.decision = decision self.ts = ts } + private enum CodingKeys: String, CodingKey { case requestid = "requestId" case deviceid = "deviceId" @@ -2630,11 +3060,12 @@ public struct ChatHistoryParams: Codable, Sendable { public init( sessionkey: String, - limit: Int? - ) { + limit: Int?) + { self.sessionkey = sessionkey self.limit = limit } + private enum CodingKeys: String, CodingKey { case sessionkey = "sessionKey" case limit @@ -2657,8 +3088,8 @@ public struct ChatSendParams: Codable, Sendable { deliver: Bool?, attachments: [AnyCodable]?, timeoutms: Int?, - idempotencykey: String - ) { + idempotencykey: String) + { self.sessionkey = sessionkey self.message = message self.thinking = thinking @@ -2667,6 +3098,7 @@ public struct ChatSendParams: Codable, Sendable { self.timeoutms = timeoutms self.idempotencykey = idempotencykey } + private enum CodingKeys: String, CodingKey { case sessionkey = "sessionKey" case message @@ -2684,11 +3116,12 @@ public struct ChatAbortParams: Codable, Sendable { public init( sessionkey: String, - runid: String? - ) { + runid: String?) + { self.sessionkey = sessionkey self.runid = runid } + private enum CodingKeys: String, CodingKey { case sessionkey = "sessionKey" case runid = "runId" @@ -2703,12 +3136,13 @@ public struct ChatInjectParams: Codable, Sendable { public init( sessionkey: String, message: String, - label: String? - ) { + label: String?) + { self.sessionkey = sessionkey self.message = message self.label = label } + private enum CodingKeys: String, CodingKey { case sessionkey = "sessionKey" case message @@ -2734,8 +3168,8 @@ public struct ChatEvent: Codable, Sendable { message: AnyCodable?, errormessage: String?, usage: AnyCodable?, - stopreason: String? - ) { + stopreason: String?) + { self.runid = runid self.sessionkey = sessionkey self.seq = seq @@ -2745,6 +3179,7 @@ public struct ChatEvent: Codable, Sendable { self.usage = usage self.stopreason = stopreason } + private enum CodingKeys: String, CodingKey { case runid = "runId" case sessionkey = "sessionKey" @@ -2767,13 +3202,14 @@ public struct UpdateRunParams: Codable, Sendable { sessionkey: String?, note: String?, restartdelayms: Int?, - timeoutms: Int? - ) { + timeoutms: Int?) + { self.sessionkey = sessionkey self.note = note self.restartdelayms = restartdelayms self.timeoutms = timeoutms } + private enum CodingKeys: String, CodingKey { case sessionkey = "sessionKey" case note @@ -2786,10 +3222,11 @@ public struct TickEvent: Codable, Sendable { public let ts: Int public init( - ts: Int - ) { + ts: Int) + { self.ts = ts } + private enum CodingKeys: String, CodingKey { case ts } @@ -2801,11 +3238,12 @@ public struct ShutdownEvent: Codable, Sendable { public init( reason: String, - restartexpectedms: Int? - ) { + restartexpectedms: Int?) + { self.reason = reason self.restartexpectedms = restartexpectedms } + private enum CodingKeys: String, CodingKey { case reason case restartexpectedms = "restartExpectedMs" @@ -2827,11 +3265,11 @@ public enum GatewayFrame: Codable, Sendable { let type = try typeContainer.decode(String.self, forKey: .type) switch type { case "req": - self = .req(try RequestFrame(from: decoder)) + self = try .req(RequestFrame(from: decoder)) case "res": - self = .res(try ResponseFrame(from: decoder)) + self = try .res(ResponseFrame(from: decoder)) case "event": - self = .event(try EventFrame(from: decoder)) + self = try .event(EventFrame(from: decoder)) default: let container = try decoder.singleValueContainer() let raw = try container.decode([String: AnyCodable].self) @@ -2841,13 +3279,15 @@ public enum GatewayFrame: Codable, Sendable { public func encode(to encoder: Encoder) throws { switch self { - case .req(let v): try v.encode(to: encoder) - case .res(let v): try v.encode(to: encoder) - case .event(let v): try v.encode(to: encoder) - case .unknown(_, let raw): + case let .req(v): + try v.encode(to: encoder) + case let .res(v): + try v.encode(to: encoder) + case let .event(v): + try v.encode(to: encoder) + case let .unknown(_, raw): var container = encoder.singleValueContainer() try container.encode(raw) } } - } diff --git a/apps/shared/OpenClawKit/Tests/OpenClawKitTests/AnyCodableTests.swift b/apps/shared/OpenClawKit/Tests/OpenClawKitTests/AnyCodableTests.swift new file mode 100644 index 00000000000..3835f1186c0 --- /dev/null +++ b/apps/shared/OpenClawKit/Tests/OpenClawKitTests/AnyCodableTests.swift @@ -0,0 +1,40 @@ +import Foundation +import Testing +import OpenClawProtocol + +struct AnyCodableTests { + @Test + func encodesNSNumberBooleansAsJSONBooleans() throws { + let trueData = try JSONEncoder().encode(AnyCodable(NSNumber(value: true))) + let falseData = try JSONEncoder().encode(AnyCodable(NSNumber(value: false))) + + #expect(String(data: trueData, encoding: .utf8) == "true") + #expect(String(data: falseData, encoding: .utf8) == "false") + } + + @Test + func preservesBooleanLiteralsFromJSONSerializationBridge() throws { + let raw = try #require( + JSONSerialization.jsonObject(with: Data(#"{"enabled":true,"nested":{"active":false}}"#.utf8)) + as? [String: Any] + ) + let enabled = try #require(raw["enabled"]) + let nested = try #require(raw["nested"]) + + struct RequestEnvelope: Codable { + let params: [String: AnyCodable] + } + + let envelope = RequestEnvelope( + params: [ + "enabled": AnyCodable(enabled), + "nested": AnyCodable(nested), + ] + ) + let data = try JSONEncoder().encode(envelope) + let json = try #require(String(data: data, encoding: .utf8)) + + #expect(json.contains(#""enabled":true"#)) + #expect(json.contains(#""active":false"#)) + } +} diff --git a/apps/shared/OpenClawKit/Tests/OpenClawKitTests/ChatMarkdownPreprocessorTests.swift b/apps/shared/OpenClawKit/Tests/OpenClawKitTests/ChatMarkdownPreprocessorTests.swift index 808f74af64f..781a325f3cf 100644 --- a/apps/shared/OpenClawKit/Tests/OpenClawKitTests/ChatMarkdownPreprocessorTests.swift +++ b/apps/shared/OpenClawKit/Tests/OpenClawKitTests/ChatMarkdownPreprocessorTests.swift @@ -17,4 +17,91 @@ struct ChatMarkdownPreprocessorTests { #expect(result.images.count == 1) #expect(result.images.first?.image != nil) } + + @Test func stripsInboundUntrustedContextBlocks() { + let markdown = """ + Conversation info (untrusted metadata): + ```json + { + "message_id": "123", + "sender": "openclaw-ios" + } + ``` + + Sender (untrusted metadata): + ```json + { + "label": "Razor" + } + ``` + + Razor? + """ + + let result = ChatMarkdownPreprocessor.preprocess(markdown: markdown) + + #expect(result.cleaned == "Razor?") + } + + @Test func stripsSingleConversationInfoBlock() { + let text = """ + Conversation info (untrusted metadata): + ```json + {"x": 1} + ``` + + User message + """ + + let result = ChatMarkdownPreprocessor.preprocess(markdown: text) + + #expect(result.cleaned == "User message") + } + + @Test func stripsAllKnownInboundMetadataSentinels() { + let sentinels = [ + "Conversation info (untrusted metadata):", + "Sender (untrusted metadata):", + "Thread starter (untrusted, for context):", + "Replied message (untrusted, for context):", + "Forwarded message context (untrusted metadata):", + "Chat history since last reply (untrusted, for context):", + ] + + for sentinel in sentinels { + let markdown = """ + \(sentinel) + ```json + {"x": 1} + ``` + + User content + """ + let result = ChatMarkdownPreprocessor.preprocess(markdown: markdown) + #expect(result.cleaned == "User content") + } + } + + @Test func preservesNonMetadataJsonFence() { + let markdown = """ + Here is some json: + ```json + {"x": 1} + ``` + """ + + let result = ChatMarkdownPreprocessor.preprocess(markdown: markdown) + + #expect(result.cleaned == markdown.trimmingCharacters(in: .whitespacesAndNewlines)) + } + + @Test func stripsLeadingTimestampPrefix() { + let markdown = """ + [Fri 2026-02-20 18:45 GMT+1] How's it going? + """ + + let result = ChatMarkdownPreprocessor.preprocess(markdown: markdown) + + #expect(result.cleaned == "How's it going?") + } } diff --git a/apps/shared/OpenClawKit/Tests/OpenClawKitTests/ChatViewModelTests.swift b/apps/shared/OpenClawKit/Tests/OpenClawKitTests/ChatViewModelTests.swift index 3babe8b9a30..147b80e5be1 100644 --- a/apps/shared/OpenClawKit/Tests/OpenClawKitTests/ChatViewModelTests.swift +++ b/apps/shared/OpenClawKit/Tests/OpenClawKitTests/ChatViewModelTests.swift @@ -215,6 +215,153 @@ extension TestChatTransportState { #expect(await MainActor.run { vm.pendingToolCalls.isEmpty }) } + @Test func acceptsCanonicalSessionKeyEventsForOwnPendingRun() async throws { + let history1 = OpenClawChatHistoryPayload( + sessionKey: "main", + sessionId: "sess-main", + messages: [], + thinkingLevel: "off") + let history2 = OpenClawChatHistoryPayload( + sessionKey: "main", + sessionId: "sess-main", + messages: [ + AnyCodable([ + "role": "assistant", + "content": [["type": "text", "text": "from history"]], + "timestamp": Date().timeIntervalSince1970 * 1000, + ]), + ], + thinkingLevel: "off") + + let transport = TestChatTransport(historyResponses: [history1, history2]) + let vm = await MainActor.run { OpenClawChatViewModel(sessionKey: "main", transport: transport) } + + await MainActor.run { vm.load() } + try await waitUntil("bootstrap") { await MainActor.run { vm.healthOK } } + + await MainActor.run { + vm.input = "hi" + vm.send() + } + try await waitUntil("pending run starts") { await MainActor.run { vm.pendingRunCount == 1 } } + + let runId = try #require(await transport.lastSentRunId()) + transport.emit( + .chat( + OpenClawChatEventPayload( + runId: runId, + sessionKey: "agent:main:main", + state: "final", + message: nil, + errorMessage: nil))) + + try await waitUntil("pending run clears") { await MainActor.run { vm.pendingRunCount == 0 } } + try await waitUntil("history refresh") { + await MainActor.run { vm.messages.contains(where: { $0.role == "assistant" }) } + } + } + + @Test func acceptsCanonicalSessionKeyEventsForExternalRuns() async throws { + let now = Date().timeIntervalSince1970 * 1000 + let history1 = OpenClawChatHistoryPayload( + sessionKey: "main", + sessionId: "sess-main", + messages: [ + AnyCodable([ + "role": "user", + "content": [["type": "text", "text": "first"]], + "timestamp": now, + ]), + ], + thinkingLevel: "off") + let history2 = OpenClawChatHistoryPayload( + sessionKey: "main", + sessionId: "sess-main", + messages: [ + AnyCodable([ + "role": "user", + "content": [["type": "text", "text": "first"]], + "timestamp": now, + ]), + AnyCodable([ + "role": "assistant", + "content": [["type": "text", "text": "from external run"]], + "timestamp": now + 1, + ]), + ], + thinkingLevel: "off") + + let transport = TestChatTransport(historyResponses: [history1, history2]) + let vm = await MainActor.run { OpenClawChatViewModel(sessionKey: "main", transport: transport) } + + await MainActor.run { vm.load() } + try await waitUntil("bootstrap") { await MainActor.run { vm.messages.count == 1 } } + + transport.emit( + .chat( + OpenClawChatEventPayload( + runId: "external-run", + sessionKey: "agent:main:main", + state: "final", + message: nil, + errorMessage: nil))) + + try await waitUntil("history refresh after canonical external event") { + await MainActor.run { vm.messages.count == 2 } + } + } + + @Test func preservesMessageIDsAcrossHistoryRefreshes() async throws { + let now = Date().timeIntervalSince1970 * 1000 + let history1 = OpenClawChatHistoryPayload( + sessionKey: "main", + sessionId: "sess-main", + messages: [ + AnyCodable([ + "role": "user", + "content": [["type": "text", "text": "hello"]], + "timestamp": now, + ]), + ], + thinkingLevel: "off") + let history2 = OpenClawChatHistoryPayload( + sessionKey: "main", + sessionId: "sess-main", + messages: [ + AnyCodable([ + "role": "user", + "content": [["type": "text", "text": "hello"]], + "timestamp": now, + ]), + AnyCodable([ + "role": "assistant", + "content": [["type": "text", "text": "world"]], + "timestamp": now + 1, + ]), + ], + thinkingLevel: "off") + + let transport = TestChatTransport(historyResponses: [history1, history2]) + let vm = await MainActor.run { OpenClawChatViewModel(sessionKey: "main", transport: transport) } + + await MainActor.run { vm.load() } + try await waitUntil("bootstrap") { await MainActor.run { vm.messages.count == 1 } } + let firstIdBefore = try #require(await MainActor.run { vm.messages.first?.id }) + + transport.emit( + .chat( + OpenClawChatEventPayload( + runId: "other-run", + sessionKey: "main", + state: "final", + message: nil, + errorMessage: nil))) + + try await waitUntil("history refresh") { await MainActor.run { vm.messages.count == 2 } } + let firstIdAfter = try #require(await MainActor.run { vm.messages.first?.id }) + #expect(firstIdAfter == firstIdBefore) + } + @Test func clearsStreamingOnExternalFinalEvent() async throws { let sessionId = "sess-main" let history = OpenClawChatHistoryPayload( @@ -269,6 +416,48 @@ extension TestChatTransportState { #expect(await MainActor.run { vm.pendingToolCalls.isEmpty }) } + @Test func seqGapClearsPendingRunsAndAutoRefreshesHistory() async throws { + let now = Date().timeIntervalSince1970 * 1000 + let history1 = OpenClawChatHistoryPayload( + sessionKey: "main", + sessionId: "sess-main", + messages: [], + thinkingLevel: "off") + let history2 = OpenClawChatHistoryPayload( + sessionKey: "main", + sessionId: "sess-main", + messages: [ + AnyCodable([ + "role": "assistant", + "content": [["type": "text", "text": "resynced after gap"]], + "timestamp": now, + ]), + ], + thinkingLevel: "off") + + let transport = TestChatTransport(historyResponses: [history1, history2]) + let vm = await MainActor.run { OpenClawChatViewModel(sessionKey: "main", transport: transport) } + + await MainActor.run { vm.load() } + try await waitUntil("bootstrap") { await MainActor.run { vm.healthOK } } + + await MainActor.run { + vm.input = "hello" + vm.send() + } + try await waitUntil("pending run starts") { await MainActor.run { vm.pendingRunCount == 1 } } + + transport.emit(.seqGap) + + try await waitUntil("pending run clears on seqGap") { + await MainActor.run { vm.pendingRunCount == 0 } + } + try await waitUntil("history refreshes on seqGap") { + await MainActor.run { vm.messages.contains(where: { $0.role == "assistant" }) } + } + #expect(await MainActor.run { vm.errorText == nil }) + } + @Test func sessionChoicesPreferMainAndRecent() async throws { let now = Date().timeIntervalSince1970 * 1000 let recent = now - (2 * 60 * 60 * 1000) @@ -458,6 +647,35 @@ extension TestChatTransportState { try await waitUntil("streaming cleared") { await MainActor.run { vm.streamingAssistantText == nil } } } + @Test func stripsInboundMetadataFromHistoryMessages() async throws { + let history = OpenClawChatHistoryPayload( + sessionKey: "main", + sessionId: "sess-main", + messages: [ + AnyCodable([ + "role": "user", + "content": [["type": "text", "text": """ +Conversation info (untrusted metadata): +```json +{ \"sender\": \"openclaw-ios\" } +``` + +Hello? +"""]], + "timestamp": Date().timeIntervalSince1970 * 1000, + ]), + ], + thinkingLevel: "off") + let transport = TestChatTransport(historyResponses: [history]) + let vm = await MainActor.run { OpenClawChatViewModel(sessionKey: "main", transport: transport) } + + await MainActor.run { vm.load() } + try await waitUntil("history loaded") { await MainActor.run { !vm.messages.isEmpty } } + + let sanitized = await MainActor.run { vm.messages.first?.content.first?.text } + #expect(sanitized == "Hello?") + } + @Test func abortRequestsDoNotClearPendingUntilAbortedEvent() async throws { let sessionId = "sess-main" let history = OpenClawChatHistoryPayload( diff --git a/apps/shared/OpenClawKit/Tests/OpenClawKitTests/DeepLinksSecurityTests.swift b/apps/shared/OpenClawKit/Tests/OpenClawKitTests/DeepLinksSecurityTests.swift new file mode 100644 index 00000000000..8bbf4f8a650 --- /dev/null +++ b/apps/shared/OpenClawKit/Tests/OpenClawKitTests/DeepLinksSecurityTests.swift @@ -0,0 +1,61 @@ +import Foundation +import OpenClawKit +import Testing + +@Suite struct DeepLinksSecurityTests { + @Test func gatewayDeepLinkRejectsInsecureNonLoopbackWs() { + let url = URL( + string: "openclaw://gateway?host=attacker.example&port=18789&tls=0&token=abc")! + #expect(DeepLinkParser.parse(url) == nil) + } + + @Test func gatewayDeepLinkRejectsInsecurePrefixBypassHost() { + let url = URL( + string: "openclaw://gateway?host=127.attacker.example&port=18789&tls=0&token=abc")! + #expect(DeepLinkParser.parse(url) == nil) + } + + @Test func gatewayDeepLinkAllowsLoopbackWs() { + let url = URL( + string: "openclaw://gateway?host=127.0.0.1&port=18789&tls=0&token=abc")! + #expect( + DeepLinkParser.parse(url) == .gateway( + .init(host: "127.0.0.1", port: 18789, tls: false, token: "abc", password: nil))) + } + + @Test func setupCodeRejectsInsecureNonLoopbackWs() { + let payload = #"{"url":"ws://attacker.example:18789","token":"tok"}"# + let encoded = Data(payload.utf8) + .base64EncodedString() + .replacingOccurrences(of: "+", with: "-") + .replacingOccurrences(of: "/", with: "_") + .replacingOccurrences(of: "=", with: "") + #expect(GatewayConnectDeepLink.fromSetupCode(encoded) == nil) + } + + @Test func setupCodeRejectsInsecurePrefixBypassHost() { + let payload = #"{"url":"ws://127.attacker.example:18789","token":"tok"}"# + let encoded = Data(payload.utf8) + .base64EncodedString() + .replacingOccurrences(of: "+", with: "-") + .replacingOccurrences(of: "/", with: "_") + .replacingOccurrences(of: "=", with: "") + #expect(GatewayConnectDeepLink.fromSetupCode(encoded) == nil) + } + + @Test func setupCodeAllowsLoopbackWs() { + let payload = #"{"url":"ws://127.0.0.1:18789","token":"tok"}"# + let encoded = Data(payload.utf8) + .base64EncodedString() + .replacingOccurrences(of: "+", with: "-") + .replacingOccurrences(of: "/", with: "_") + .replacingOccurrences(of: "=", with: "") + #expect( + GatewayConnectDeepLink.fromSetupCode(encoded) == .init( + host: "127.0.0.1", + port: 18789, + tls: false, + token: "tok", + password: nil)) + } +} diff --git a/apps/shared/OpenClawKit/Tests/OpenClawKitTests/GatewayNodeSessionTests.swift b/apps/shared/OpenClawKit/Tests/OpenClawKitTests/GatewayNodeSessionTests.swift index 91e30961591..08a6ea2162a 100644 --- a/apps/shared/OpenClawKit/Tests/OpenClawKitTests/GatewayNodeSessionTests.swift +++ b/apps/shared/OpenClawKit/Tests/OpenClawKitTests/GatewayNodeSessionTests.swift @@ -3,6 +3,182 @@ import Testing @testable import OpenClawKit import OpenClawProtocol +private struct TimeoutError: Error, CustomStringConvertible { + let label: String + var description: String { "Timeout waiting for: \(self.label)" } +} + +private func waitUntil( + _ label: String, + timeoutSeconds: Double = 3.0, + pollMs: UInt64 = 10, + _ condition: @escaping @Sendable () async -> Bool) async throws +{ + let deadline = Date().addingTimeInterval(timeoutSeconds) + while Date() < deadline { + if await condition() { + return + } + try await Task.sleep(nanoseconds: pollMs * 1_000_000) + } + throw TimeoutError(label: label) +} + +private extension NSLock { + func withLock(_ body: () -> T) -> T { + self.lock() + defer { self.unlock() } + return body() + } +} + +private final class FakeGatewayWebSocketTask: WebSocketTasking, @unchecked Sendable { + private let lock = NSLock() + private var _state: URLSessionTask.State = .suspended + private var connectRequestId: String? + private var receivePhase = 0 + private var pendingReceiveHandler: + (@Sendable (Result) -> Void)? + + var state: URLSessionTask.State { + get { self.lock.withLock { self._state } } + set { self.lock.withLock { self._state = newValue } } + } + + func resume() { + self.state = .running + } + + func cancel(with closeCode: URLSessionWebSocketTask.CloseCode, reason: Data?) { + _ = (closeCode, reason) + self.state = .canceling + let handler = self.lock.withLock { () -> (@Sendable (Result) -> Void)? in + defer { self.pendingReceiveHandler = nil } + return self.pendingReceiveHandler + } + handler?(Result.failure(URLError(.cancelled))) + } + + func send(_ message: URLSessionWebSocketTask.Message) async throws { + let data: Data? = switch message { + case let .data(d): d + case let .string(s): s.data(using: .utf8) + @unknown default: nil + } + guard let data else { return } + if let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + obj["type"] as? String == "req", + obj["method"] as? String == "connect", + let id = obj["id"] as? String + { + self.lock.withLock { self.connectRequestId = id } + } + } + + func sendPing(pongReceiveHandler: @escaping @Sendable (Error?) -> Void) { + pongReceiveHandler(nil) + } + + func receive() async throws -> URLSessionWebSocketTask.Message { + let phase = self.lock.withLock { () -> Int in + let current = self.receivePhase + self.receivePhase += 1 + return current + } + if phase == 0 { + return .data(Self.connectChallengeData(nonce: "nonce-1")) + } + for _ in 0..<50 { + let id = self.lock.withLock { self.connectRequestId } + if let id { + return .data(Self.connectOkData(id: id)) + } + try await Task.sleep(nanoseconds: 1_000_000) + } + return .data(Self.connectOkData(id: "connect")) + } + + func receive( + completionHandler: @escaping @Sendable (Result) -> Void) + { + self.lock.withLock { self.pendingReceiveHandler = completionHandler } + } + + func emitReceiveFailure() { + let handler = self.lock.withLock { () -> (@Sendable (Result) -> Void)? in + self._state = .canceling + defer { self.pendingReceiveHandler = nil } + return self.pendingReceiveHandler + } + handler?(Result.failure(URLError(.networkConnectionLost))) + } + + private static func connectChallengeData(nonce: String) -> Data { + let json = """ + { + "type": "event", + "event": "connect.challenge", + "payload": { "nonce": "\(nonce)" } + } + """ + return Data(json.utf8) + } + + private static func connectOkData(id: String) -> Data { + let json = """ + { + "type": "res", + "id": "\(id)", + "ok": true, + "payload": { + "type": "hello-ok", + "protocol": 2, + "server": { "version": "test", "connId": "test" }, + "features": { "methods": [], "events": [] }, + "snapshot": { + "presence": [ { "ts": 1 } ], + "health": {}, + "stateVersion": { "presence": 0, "health": 0 }, + "uptimeMs": 0 + }, + "policy": { "maxPayload": 1, "maxBufferedBytes": 1, "tickIntervalMs": 30000 } + } + } + """ + return Data(json.utf8) + } +} + +private final class FakeGatewayWebSocketSession: WebSocketSessioning, @unchecked Sendable { + private let lock = NSLock() + private var tasks: [FakeGatewayWebSocketTask] = [] + private var makeCount = 0 + + func snapshotMakeCount() -> Int { + self.lock.withLock { self.makeCount } + } + + func latestTask() -> FakeGatewayWebSocketTask? { + self.lock.withLock { self.tasks.last } + } + + func makeWebSocketTask(url: URL) -> WebSocketTaskBox { + _ = url + return self.lock.withLock { + self.makeCount += 1 + let task = FakeGatewayWebSocketTask() + self.tasks.append(task) + return WebSocketTaskBox(task: task) + } + } +} + +private actor SeqGapProbe { + private var saw = false + func mark() { self.saw = true } + func value() -> Bool { self.saw } +} + struct GatewayNodeSessionTests { @Test func invokeWithTimeoutReturnsUnderlyingResponseBeforeTimeout() async { @@ -53,4 +229,56 @@ struct GatewayNodeSessionTests { #expect(response.ok == true) #expect(response.error == nil) } + + @Test + func emitsSyntheticSeqGapAfterReconnectSnapshot() async throws { + let session = FakeGatewayWebSocketSession() + let gateway = GatewayNodeSession() + let options = GatewayConnectOptions( + role: "operator", + scopes: ["operator.read"], + caps: [], + commands: [], + permissions: [:], + clientId: "openclaw-ios-test", + clientMode: "ui", + clientDisplayName: "iOS Test", + includeDeviceIdentity: false) + + let stream = await gateway.subscribeServerEvents(bufferingNewest: 32) + let probe = SeqGapProbe() + let listenTask = Task { + for await evt in stream { + if evt.event == "seqGap" { + await probe.mark() + return + } + } + } + + try await gateway.connect( + url: URL(string: "ws://example.invalid")!, + token: nil, + password: nil, + connectOptions: options, + sessionBox: WebSocketSessionBox(session: session), + onConnected: {}, + onDisconnected: { _ in }, + onInvoke: { req in + BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: nil, error: nil) + }) + + let firstTask = try #require(session.latestTask()) + firstTask.emitReceiveFailure() + + try await waitUntil("reconnect socket created") { + session.snapshotMakeCount() >= 2 + } + try await waitUntil("synthetic seqGap broadcast") { + await probe.value() + } + + listenTask.cancel() + await gateway.disconnect() + } } diff --git a/apps/shared/OpenClawKit/Tests/OpenClawKitTests/TalkPromptBuilderTests.swift b/apps/shared/OpenClawKit/Tests/OpenClawKitTests/TalkPromptBuilderTests.swift index 1ca18fdf32d..513b60d047a 100644 --- a/apps/shared/OpenClawKit/Tests/OpenClawKitTests/TalkPromptBuilderTests.swift +++ b/apps/shared/OpenClawKit/Tests/OpenClawKitTests/TalkPromptBuilderTests.swift @@ -12,4 +12,18 @@ final class TalkPromptBuilderTests: XCTestCase { let prompt = TalkPromptBuilder.build(transcript: "Hi", interruptedAtSeconds: 1.234) XCTAssertTrue(prompt.contains("Assistant speech interrupted at 1.2s.")) } + + func testBuildIncludesVoiceDirectiveHintByDefault() { + let prompt = TalkPromptBuilder.build(transcript: "Hello", interruptedAtSeconds: nil) + XCTAssertTrue(prompt.contains("ElevenLabs voice")) + } + + func testBuildExcludesVoiceDirectiveHintWhenDisabled() { + let prompt = TalkPromptBuilder.build( + transcript: "Hello", + interruptedAtSeconds: nil, + includeVoiceDirectiveHint: false) + XCTAssertFalse(prompt.contains("ElevenLabs voice")) + XCTAssertTrue(prompt.contains("Talk Mode active.")) + } } diff --git a/apps/shared/OpenClawKit/Tests/OpenClawKitTests/ToolResultTextFormatterTests.swift b/apps/shared/OpenClawKit/Tests/OpenClawKitTests/ToolResultTextFormatterTests.swift new file mode 100644 index 00000000000..1688725c850 --- /dev/null +++ b/apps/shared/OpenClawKit/Tests/OpenClawKitTests/ToolResultTextFormatterTests.swift @@ -0,0 +1,54 @@ +import Testing +@testable import OpenClawChatUI + +@Suite("ToolResultTextFormatter") +struct ToolResultTextFormatterTests { + @Test func leavesPlainTextUntouched() { + let result = ToolResultTextFormatter.format(text: "All good", toolName: "nodes") + #expect(result == "All good") + } + + @Test func summarizesNodesListJSON() { + let json = """ + { + "ts": 1771610031380, + "nodes": [ + { + "displayName": "iPhone 16 Pro Max", + "connected": true, + "platform": "ios" + } + ] + } + """ + + let result = ToolResultTextFormatter.format(text: json, toolName: "nodes") + #expect(result.contains("1 node found.")) + #expect(result.contains("iPhone 16 Pro Max")) + #expect(result.contains("connected")) + } + + @Test func summarizesErrorJSONAndDropsAgentPrefix() { + let json = """ + { + "status": "error", + "tool": "nodes", + "error": "agent=main node=iPhone gateway=default action=invoke: pairing required" + } + """ + + let result = ToolResultTextFormatter.format(text: json, toolName: "nodes") + #expect(result == "Error: pairing required") + } + + @Test func suppressesUnknownStructuredPayload() { + let json = """ + { + "foo": "bar" + } + """ + + let result = ToolResultTextFormatter.format(text: json, toolName: "nodes") + #expect(result.isEmpty) + } +} diff --git a/apps/shared/OpenClawKit/Tools/CanvasA2UI/bootstrap.js b/apps/shared/OpenClawKit/Tools/CanvasA2UI/bootstrap.js index 563adcc3b1d..a9cb659876a 100644 --- a/apps/shared/OpenClawKit/Tools/CanvasA2UI/bootstrap.js +++ b/apps/shared/OpenClawKit/Tools/CanvasA2UI/bootstrap.js @@ -451,7 +451,6 @@ class OpenClawA2UIHost extends LitElement { if (this.surfaces.length === 0) { return html`
Canvas (A2UI)
-
Waiting for A2UI messages…
`; } diff --git a/apps/shared/OpenClawKit/Tools/CanvasA2UI/rolldown.config.mjs b/apps/shared/OpenClawKit/Tools/CanvasA2UI/rolldown.config.mjs index dbd4b86fff6..ccf1683d565 100644 --- a/apps/shared/OpenClawKit/Tools/CanvasA2UI/rolldown.config.mjs +++ b/apps/shared/OpenClawKit/Tools/CanvasA2UI/rolldown.config.mjs @@ -1,9 +1,10 @@ import path from "node:path"; +import { existsSync } from "node:fs"; import { fileURLToPath } from "node:url"; -import { defineConfig } from "rolldown"; const here = path.dirname(fileURLToPath(import.meta.url)); const repoRoot = path.resolve(here, "../../../../.."); +const uiRoot = path.resolve(repoRoot, "ui"); const fromHere = (p) => path.resolve(here, p); const outputFile = path.resolve( here, @@ -16,8 +17,28 @@ const outputFile = path.resolve( const a2uiLitDist = path.resolve(repoRoot, "vendor/a2ui/renderers/lit/dist/src"); const a2uiThemeContext = path.resolve(a2uiLitDist, "0.8/ui/context/theme.js"); +const uiNodeModules = path.resolve(uiRoot, "node_modules"); +const repoNodeModules = path.resolve(repoRoot, "node_modules"); -export default defineConfig({ +function resolveUiDependency(moduleId) { + const candidates = [ + path.resolve(uiNodeModules, moduleId), + path.resolve(repoNodeModules, moduleId), + ]; + for (const candidate of candidates) { + if (existsSync(candidate)) { + return candidate; + } + } + + const fallbackCandidates = candidates.join(", "); + throw new Error( + `A2UI bundle config cannot resolve ${moduleId}. Checked: ${fallbackCandidates}. ` + + "Keep dependency installed in ui workspace or repo root before bundling.", + ); +} + +export default { input: fromHere("bootstrap.js"), experimental: { attachDebugInfo: "none", @@ -28,12 +49,13 @@ export default defineConfig({ "@a2ui/lit": path.resolve(a2uiLitDist, "index.js"), "@a2ui/lit/ui": path.resolve(a2uiLitDist, "0.8/ui/ui.js"), "@openclaw/a2ui-theme-context": a2uiThemeContext, - "@lit/context": path.resolve(repoRoot, "node_modules/@lit/context/index.js"), - "@lit/context/": path.resolve(repoRoot, "node_modules/@lit/context/"), - "@lit-labs/signals": path.resolve(repoRoot, "node_modules/@lit-labs/signals/index.js"), - "@lit-labs/signals/": path.resolve(repoRoot, "node_modules/@lit-labs/signals/"), - lit: path.resolve(repoRoot, "node_modules/lit/index.js"), - "lit/": path.resolve(repoRoot, "node_modules/lit/"), + "@lit/context": resolveUiDependency("@lit/context"), + "@lit/context/": resolveUiDependency("@lit/context/"), + "@lit-labs/signals": resolveUiDependency("@lit-labs/signals"), + "@lit-labs/signals/": resolveUiDependency("@lit-labs/signals/"), + lit: resolveUiDependency("lit"), + "lit/": resolveUiDependency("lit/"), + "signal-utils/": resolveUiDependency("signal-utils/"), }, }, output: { @@ -42,4 +64,4 @@ export default defineConfig({ codeSplitting: false, sourcemap: false, }, -}); +}; diff --git a/assets/chrome-extension/README.md b/assets/chrome-extension/README.md index 2a2a11a3be5..4ee072c1f2b 100644 --- a/assets/chrome-extension/README.md +++ b/assets/chrome-extension/README.md @@ -20,3 +20,4 @@ Purpose: attach OpenClaw to an existing Chrome tab so the Gateway can automate i ## Options - `Relay port`: defaults to `18792`. +- `Gateway token`: required. Set this to `gateway.auth.token` (or `OPENCLAW_GATEWAY_TOKEN`). diff --git a/assets/chrome-extension/background-utils.js b/assets/chrome-extension/background-utils.js new file mode 100644 index 00000000000..fe32d2c0616 --- /dev/null +++ b/assets/chrome-extension/background-utils.js @@ -0,0 +1,48 @@ +export function reconnectDelayMs( + attempt, + opts = { baseMs: 1000, maxMs: 30000, jitterMs: 1000, random: Math.random }, +) { + const baseMs = Number.isFinite(opts.baseMs) ? opts.baseMs : 1000; + const maxMs = Number.isFinite(opts.maxMs) ? opts.maxMs : 30000; + const jitterMs = Number.isFinite(opts.jitterMs) ? opts.jitterMs : 1000; + const random = typeof opts.random === "function" ? opts.random : Math.random; + const safeAttempt = Math.max(0, Number.isFinite(attempt) ? attempt : 0); + const backoff = Math.min(baseMs * 2 ** safeAttempt, maxMs); + return backoff + Math.max(0, jitterMs) * random(); +} + +export async function deriveRelayToken(gatewayToken, port) { + const enc = new TextEncoder(); + const key = await crypto.subtle.importKey( + "raw", + enc.encode(gatewayToken), + { name: "HMAC", hash: "SHA-256" }, + false, + ["sign"], + ); + const sig = await crypto.subtle.sign( + "HMAC", + key, + enc.encode(`openclaw-extension-relay-v1:${port}`), + ); + return [...new Uint8Array(sig)].map((b) => b.toString(16).padStart(2, "0")).join(""); +} + +export async function buildRelayWsUrl(port, gatewayToken) { + const token = String(gatewayToken || "").trim(); + if (!token) { + throw new Error( + "Missing gatewayToken in extension settings (chrome.storage.local.gatewayToken)", + ); + } + const relayToken = await deriveRelayToken(token, port); + return `ws://127.0.0.1:${port}/extension?token=${encodeURIComponent(relayToken)}`; +} + +export function isRetryableReconnectError(err) { + const message = err instanceof Error ? err.message : String(err || ""); + if (message.includes("Missing gatewayToken")) { + return false; + } + return true; +} diff --git a/assets/chrome-extension/background.js b/assets/chrome-extension/background.js index 31ba401bddc..60f50d6551e 100644 --- a/assets/chrome-extension/background.js +++ b/assets/chrome-extension/background.js @@ -1,3 +1,5 @@ +import { buildRelayWsUrl, isRetryableReconnectError, reconnectDelayMs } from './background-utils.js' + const DEFAULT_PORT = 18792 const BADGE = { @@ -12,8 +14,6 @@ let relayWs = null /** @type {Promise|null} */ let relayConnectPromise = null -let debuggerListenersInstalled = false - let nextSession = 1 /** @type {Map} */ @@ -26,6 +26,18 @@ const childSessionToTab = new Map() /** @type {Mapvoid, reject:(e:Error)=>void}>} */ const pending = new Map() +// Per-tab operation locks prevent double-attach races. +/** @type {Set} */ +const tabOperationLocks = new Set() + +// Tabs currently in a detach/re-attach cycle after navigation. +/** @type {Set} */ +const reattachPending = new Set() + +// Reconnect state for exponential backoff. +let reconnectAttempt = 0 +let reconnectTimer = null + function nowStack() { try { return new Error().stack || '' @@ -42,6 +54,12 @@ async function getRelayPort() { return n } +async function getGatewayToken() { + const stored = await chrome.storage.local.get(['gatewayToken']) + const token = String(stored.gatewayToken || '').trim() + return token || '' +} + function setBadge(tabId, kind) { const cfg = BADGE[kind] void chrome.action.setBadgeText({ tabId, text: cfg.text }) @@ -49,14 +67,72 @@ function setBadge(tabId, kind) { void chrome.action.setBadgeTextColor({ tabId, color: '#FFFFFF' }).catch(() => {}) } +// Persist attached tab state to survive MV3 service worker restarts. +async function persistState() { + try { + const tabEntries = [] + for (const [tabId, tab] of tabs.entries()) { + if (tab.state === 'connected' && tab.sessionId && tab.targetId) { + tabEntries.push({ tabId, sessionId: tab.sessionId, targetId: tab.targetId, attachOrder: tab.attachOrder }) + } + } + await chrome.storage.session.set({ + persistedTabs: tabEntries, + nextSession, + }) + } catch { + // chrome.storage.session may not be available in all contexts. + } +} + +// Rehydrate tab state on service worker startup. Fast path — just restores +// maps and badges. Relay reconnect happens separately in background. +async function rehydrateState() { + try { + const stored = await chrome.storage.session.get(['persistedTabs', 'nextSession']) + if (stored.nextSession) { + nextSession = Math.max(nextSession, stored.nextSession) + } + const entries = stored.persistedTabs || [] + // Phase 1: optimistically restore state and badges. + for (const entry of entries) { + tabs.set(entry.tabId, { + state: 'connected', + sessionId: entry.sessionId, + targetId: entry.targetId, + attachOrder: entry.attachOrder, + }) + tabBySession.set(entry.sessionId, entry.tabId) + setBadge(entry.tabId, 'on') + } + // Phase 2: validate asynchronously, remove dead tabs. + for (const entry of entries) { + try { + await chrome.tabs.get(entry.tabId) + await chrome.debugger.sendCommand({ tabId: entry.tabId }, 'Runtime.evaluate', { + expression: '1', + returnByValue: true, + }) + } catch { + tabs.delete(entry.tabId) + tabBySession.delete(entry.sessionId) + setBadge(entry.tabId, 'off') + } + } + } catch { + // Ignore rehydration errors. + } +} + async function ensureRelayConnection() { if (relayWs && relayWs.readyState === WebSocket.OPEN) return if (relayConnectPromise) return await relayConnectPromise relayConnectPromise = (async () => { const port = await getRelayPort() + const gatewayToken = await getGatewayToken() const httpBase = `http://127.0.0.1:${port}` - const wsUrl = `ws://127.0.0.1:${port}/extension` + const wsUrl = await buildRelayWsUrl(port, gatewayToken) // Fast preflight: is the relay server up? try { @@ -84,42 +160,144 @@ async function ensureRelayConnection() { } }) - ws.onmessage = (event) => void onRelayMessage(String(event.data || '')) - ws.onclose = () => onRelayClosed('closed') - ws.onerror = () => onRelayClosed('error') - - if (!debuggerListenersInstalled) { - debuggerListenersInstalled = true - chrome.debugger.onEvent.addListener(onDebuggerEvent) - chrome.debugger.onDetach.addListener(onDebuggerDetach) + // Bind permanent handlers. Guard against stale socket: if this WS was + // replaced before its close fires, the handler is a no-op. + ws.onmessage = (event) => { + if (ws !== relayWs) return + void whenReady(() => onRelayMessage(String(event.data || ''))) + } + ws.onclose = () => { + if (ws !== relayWs) return + onRelayClosed('closed') + } + ws.onerror = () => { + if (ws !== relayWs) return + onRelayClosed('error') } })() try { await relayConnectPromise + reconnectAttempt = 0 } finally { relayConnectPromise = null } } +// Relay closed — update badges, reject pending requests, auto-reconnect. +// Debugger sessions are kept alive so they survive transient WS drops. function onRelayClosed(reason) { relayWs = null + for (const [id, p] of pending.entries()) { pending.delete(id) p.reject(new Error(`Relay disconnected (${reason})`)) } - for (const tabId of tabs.keys()) { - void chrome.debugger.detach({ tabId }).catch(() => {}) - setBadge(tabId, 'connecting') - void chrome.action.setTitle({ - tabId, - title: 'OpenClaw Browser Relay: disconnected (click to re-attach)', - }) + reattachPending.clear() + + for (const [tabId, tab] of tabs.entries()) { + if (tab.state === 'connected') { + setBadge(tabId, 'connecting') + void chrome.action.setTitle({ + tabId, + title: 'OpenClaw Browser Relay: relay reconnecting…', + }) + } } - tabs.clear() - tabBySession.clear() - childSessionToTab.clear() + + scheduleReconnect() +} + +function scheduleReconnect() { + if (reconnectTimer) { + clearTimeout(reconnectTimer) + reconnectTimer = null + } + + const delay = reconnectDelayMs(reconnectAttempt) + reconnectAttempt++ + + console.log(`Scheduling reconnect attempt ${reconnectAttempt} in ${Math.round(delay)}ms`) + + reconnectTimer = setTimeout(async () => { + reconnectTimer = null + try { + await ensureRelayConnection() + reconnectAttempt = 0 + console.log('Reconnected successfully') + await reannounceAttachedTabs() + } catch (err) { + const message = err instanceof Error ? err.message : String(err) + console.warn(`Reconnect attempt ${reconnectAttempt} failed: ${message}`) + if (!isRetryableReconnectError(err)) { + return + } + scheduleReconnect() + } + }, delay) +} + +function cancelReconnect() { + if (reconnectTimer) { + clearTimeout(reconnectTimer) + reconnectTimer = null + } + reconnectAttempt = 0 +} + +// Re-announce all attached tabs to the relay after reconnect. +async function reannounceAttachedTabs() { + for (const [tabId, tab] of tabs.entries()) { + if (tab.state !== 'connected' || !tab.sessionId || !tab.targetId) continue + + // Verify debugger is still attached. + try { + await chrome.debugger.sendCommand({ tabId }, 'Runtime.evaluate', { + expression: '1', + returnByValue: true, + }) + } catch { + tabs.delete(tabId) + if (tab.sessionId) tabBySession.delete(tab.sessionId) + setBadge(tabId, 'off') + void chrome.action.setTitle({ + tabId, + title: 'OpenClaw Browser Relay (click to attach/detach)', + }) + continue + } + + // Send fresh attach event to relay. + try { + const info = /** @type {any} */ ( + await chrome.debugger.sendCommand({ tabId }, 'Target.getTargetInfo') + ) + const targetInfo = info?.targetInfo + + sendToRelay({ + method: 'forwardCDPEvent', + params: { + method: 'Target.attachedToTarget', + params: { + sessionId: tab.sessionId, + targetInfo: { ...targetInfo, attached: true }, + waitingForDebugger: false, + }, + }, + }) + + setBadge(tabId, 'on') + void chrome.action.setTitle({ + tabId, + title: 'OpenClaw Browser Relay: attached (click to detach)', + }) + } catch { + setBadge(tabId, 'on') + } + } + + await persistState() } function sendToRelay(payload) { @@ -144,10 +322,18 @@ async function maybeOpenHelpOnce() { function requestFromRelay(command) { const id = command.id return new Promise((resolve, reject) => { - pending.set(id, { resolve, reject }) + const timer = setTimeout(() => { + pending.delete(id) + reject(new Error('Relay request timeout (30s)')) + }, 30000) + pending.set(id, { + resolve: (v) => { clearTimeout(timer); resolve(v) }, + reject: (e) => { clearTimeout(timer); reject(e) }, + }) try { sendToRelay(command) } catch (err) { + clearTimeout(timer) pending.delete(id) reject(err instanceof Error ? err : new Error(String(err))) } @@ -218,8 +404,9 @@ async function attachTab(tabId, opts = {}) { throw new Error('Target.getTargetInfo returned no targetId') } - const sessionId = `cb-tab-${nextSession++}` - const attachOrder = nextSession + const sid = nextSession++ + const sessionId = `cb-tab-${sid}` + const attachOrder = sid tabs.set(tabId, { state: 'connected', sessionId, targetId, attachOrder }) tabBySession.set(sessionId, tabId) @@ -243,11 +430,33 @@ async function attachTab(tabId, opts = {}) { } setBadge(tabId, 'on') + await persistState() + return { sessionId, targetId } } async function detachTab(tabId, reason) { const tab = tabs.get(tabId) + + // Send detach events for child sessions first. + for (const [childSessionId, parentTabId] of childSessionToTab.entries()) { + if (parentTabId === tabId) { + try { + sendToRelay({ + method: 'forwardCDPEvent', + params: { + method: 'Target.detachedFromTarget', + params: { sessionId: childSessionId, reason: 'parent_detached' }, + }, + }) + } catch { + // Relay may be down. + } + childSessionToTab.delete(childSessionId) + } + } + + // Send detach event for main session. if (tab?.sessionId && tab?.targetId) { try { sendToRelay({ @@ -258,21 +467,17 @@ async function detachTab(tabId, reason) { }, }) } catch { - // ignore + // Relay may be down. } } if (tab?.sessionId) tabBySession.delete(tab.sessionId) tabs.delete(tabId) - for (const [childSessionId, parentTabId] of childSessionToTab.entries()) { - if (parentTabId === tabId) childSessionToTab.delete(childSessionId) - } - try { await chrome.debugger.detach({ tabId }) } catch { - // ignore + // May already be detached. } setBadge(tabId, 'off') @@ -280,6 +485,8 @@ async function detachTab(tabId, reason) { tabId, title: 'OpenClaw Browser Relay (click to attach/detach)', }) + + await persistState() } async function connectOrToggleForActiveTab() { @@ -287,33 +494,53 @@ async function connectOrToggleForActiveTab() { const tabId = active?.id if (!tabId) return - const existing = tabs.get(tabId) - if (existing?.state === 'connected') { - await detachTab(tabId, 'toggle') - return - } - - tabs.set(tabId, { state: 'connecting' }) - setBadge(tabId, 'connecting') - void chrome.action.setTitle({ - tabId, - title: 'OpenClaw Browser Relay: connecting to local relay…', - }) + // Prevent concurrent operations on the same tab. + if (tabOperationLocks.has(tabId)) return + tabOperationLocks.add(tabId) try { - await ensureRelayConnection() - await attachTab(tabId) - } catch (err) { - tabs.delete(tabId) - setBadge(tabId, 'error') + if (reattachPending.has(tabId)) { + reattachPending.delete(tabId) + setBadge(tabId, 'off') + void chrome.action.setTitle({ + tabId, + title: 'OpenClaw Browser Relay (click to attach/detach)', + }) + return + } + + const existing = tabs.get(tabId) + if (existing?.state === 'connected') { + await detachTab(tabId, 'toggle') + return + } + + // User is manually connecting — cancel any pending reconnect. + cancelReconnect() + + tabs.set(tabId, { state: 'connecting' }) + setBadge(tabId, 'connecting') void chrome.action.setTitle({ tabId, - title: 'OpenClaw Browser Relay: relay not running (open options for setup)', + title: 'OpenClaw Browser Relay: connecting to local relay…', }) - void maybeOpenHelpOnce() - // Extra breadcrumbs in chrome://extensions service worker logs. - const message = err instanceof Error ? err.message : String(err) - console.warn('attach failed', message, nowStack()) + + try { + await ensureRelayConnection() + await attachTab(tabId) + } catch (err) { + tabs.delete(tabId) + setBadge(tabId, 'error') + void chrome.action.setTitle({ + tabId, + title: 'OpenClaw Browser Relay: relay not running (open options for setup)', + }) + void maybeOpenHelpOnce() + const message = err instanceof Error ? err.message : String(err) + console.warn('attach failed', message, nowStack()) + } + } finally { + tabOperationLocks.delete(tabId) } } @@ -322,14 +549,12 @@ async function handleForwardCdpCommand(msg) { const params = msg?.params?.params || undefined const sessionId = typeof msg?.params?.sessionId === 'string' ? msg.params.sessionId : undefined - // Map command to tab const bySession = sessionId ? getTabBySessionId(sessionId) : null const targetId = typeof params?.targetId === 'string' ? params.targetId : undefined const tabId = bySession?.tabId || (targetId ? getTabByTargetId(targetId) : null) || (() => { - // No sessionId: pick the first connected tab (stable-ish). for (const [id, tab] of tabs.entries()) { if (tab.state === 'connected') return id } @@ -419,20 +644,256 @@ function onDebuggerEvent(source, method, params) { }, }) } catch { - // ignore + // Relay may be down. } } -function onDebuggerDetach(source, reason) { +async function onDebuggerDetach(source, reason) { const tabId = source.tabId if (!tabId) return if (!tabs.has(tabId)) return - void detachTab(tabId, reason) + + // User explicitly cancelled or DevTools replaced the connection — respect their intent + if (reason === 'canceled_by_user' || reason === 'replaced_with_devtools') { + void detachTab(tabId, reason) + return + } + + // Check if tab still exists — distinguishes navigation from tab close + let tabInfo + try { + tabInfo = await chrome.tabs.get(tabId) + } catch { + // Tab is gone (closed) — normal cleanup + void detachTab(tabId, reason) + return + } + + if (tabInfo.url?.startsWith('chrome://') || tabInfo.url?.startsWith('chrome-extension://')) { + void detachTab(tabId, reason) + return + } + + if (reattachPending.has(tabId)) return + + const oldTab = tabs.get(tabId) + const oldSessionId = oldTab?.sessionId + const oldTargetId = oldTab?.targetId + + if (oldSessionId) tabBySession.delete(oldSessionId) + tabs.delete(tabId) + for (const [childSessionId, parentTabId] of childSessionToTab.entries()) { + if (parentTabId === tabId) childSessionToTab.delete(childSessionId) + } + + if (oldSessionId && oldTargetId) { + try { + sendToRelay({ + method: 'forwardCDPEvent', + params: { + method: 'Target.detachedFromTarget', + params: { sessionId: oldSessionId, targetId: oldTargetId, reason: 'navigation-reattach' }, + }, + }) + } catch { + // Relay may be down. + } + } + + reattachPending.add(tabId) + setBadge(tabId, 'connecting') + void chrome.action.setTitle({ + tabId, + title: 'OpenClaw Browser Relay: re-attaching after navigation…', + }) + + const delays = [300, 700, 1500] + for (let attempt = 0; attempt < delays.length; attempt++) { + await new Promise((r) => setTimeout(r, delays[attempt])) + + if (!reattachPending.has(tabId)) return + + try { + await chrome.tabs.get(tabId) + } catch { + reattachPending.delete(tabId) + setBadge(tabId, 'off') + return + } + + if (!relayWs || relayWs.readyState !== WebSocket.OPEN) { + reattachPending.delete(tabId) + setBadge(tabId, 'error') + void chrome.action.setTitle({ + tabId, + title: 'OpenClaw Browser Relay: relay disconnected during re-attach', + }) + return + } + + try { + await attachTab(tabId) + reattachPending.delete(tabId) + return + } catch { + // continue retries + } + } + + reattachPending.delete(tabId) + setBadge(tabId, 'off') + void chrome.action.setTitle({ + tabId, + title: 'OpenClaw Browser Relay: re-attach failed (click to retry)', + }) } -chrome.action.onClicked.addListener(() => void connectOrToggleForActiveTab()) +// Tab lifecycle listeners — clean up stale entries. +chrome.tabs.onRemoved.addListener((tabId) => void whenReady(() => { + reattachPending.delete(tabId) + if (!tabs.has(tabId)) return + const tab = tabs.get(tabId) + if (tab?.sessionId) tabBySession.delete(tab.sessionId) + tabs.delete(tabId) + for (const [childSessionId, parentTabId] of childSessionToTab.entries()) { + if (parentTabId === tabId) childSessionToTab.delete(childSessionId) + } + if (tab?.sessionId && tab?.targetId) { + try { + sendToRelay({ + method: 'forwardCDPEvent', + params: { + method: 'Target.detachedFromTarget', + params: { sessionId: tab.sessionId, targetId: tab.targetId, reason: 'tab_closed' }, + }, + }) + } catch { + // Relay may be down. + } + } + void persistState() +})) + +chrome.tabs.onReplaced.addListener((addedTabId, removedTabId) => void whenReady(() => { + const tab = tabs.get(removedTabId) + if (!tab) return + tabs.delete(removedTabId) + tabs.set(addedTabId, tab) + if (tab.sessionId) { + tabBySession.set(tab.sessionId, addedTabId) + } + for (const [childSessionId, parentTabId] of childSessionToTab.entries()) { + if (parentTabId === removedTabId) { + childSessionToTab.set(childSessionId, addedTabId) + } + } + setBadge(addedTabId, 'on') + void persistState() +})) + +// Register debugger listeners at module scope so detach/event handling works +// even when the relay WebSocket is down. +chrome.debugger.onEvent.addListener((...args) => void whenReady(() => onDebuggerEvent(...args))) +chrome.debugger.onDetach.addListener((...args) => void whenReady(() => onDebuggerDetach(...args))) + +chrome.action.onClicked.addListener(() => void whenReady(() => connectOrToggleForActiveTab())) + +// Refresh badge after navigation completes — service worker may have restarted +// during navigation, losing ephemeral badge state. +chrome.webNavigation.onCompleted.addListener(({ tabId, frameId }) => void whenReady(() => { + if (frameId !== 0) return + const tab = tabs.get(tabId) + if (tab?.state === 'connected') { + setBadge(tabId, relayWs && relayWs.readyState === WebSocket.OPEN ? 'on' : 'connecting') + } +})) + +// Refresh badge when user switches to an attached tab. +chrome.tabs.onActivated.addListener(({ tabId }) => void whenReady(() => { + const tab = tabs.get(tabId) + if (tab?.state === 'connected') { + setBadge(tabId, relayWs && relayWs.readyState === WebSocket.OPEN ? 'on' : 'connecting') + } +})) chrome.runtime.onInstalled.addListener(() => { - // Useful: first-time instructions. void chrome.runtime.openOptionsPage() }) + +// MV3 keepalive via chrome.alarms — more reliable than setInterval across +// service worker restarts. Checks relay health and refreshes badges. +chrome.alarms.create('relay-keepalive', { periodInMinutes: 0.5 }) + +chrome.alarms.onAlarm.addListener(async (alarm) => { + if (alarm.name !== 'relay-keepalive') return + await initPromise + + if (tabs.size === 0) return + + // Refresh badges (ephemeral in MV3). + for (const [tabId, tab] of tabs.entries()) { + if (tab.state === 'connected') { + setBadge(tabId, relayWs && relayWs.readyState === WebSocket.OPEN ? 'on' : 'connecting') + } + } + + // If relay is down and no reconnect is in progress, trigger one. + if (!relayWs || relayWs.readyState !== WebSocket.OPEN) { + if (!relayConnectPromise && !reconnectTimer) { + console.log('Keepalive: WebSocket unhealthy, triggering reconnect') + await ensureRelayConnection().catch(() => { + // ensureRelayConnection may throw without triggering onRelayClosed + // (e.g. preflight fetch fails before WS is created), so ensure + // reconnect is always scheduled on failure. + if (!reconnectTimer) { + scheduleReconnect() + } + }) + } + } +}) + +// Rehydrate state on service worker startup. Split: rehydration is the gate +// (fast), relay reconnect runs in background (slow, non-blocking). +const initPromise = rehydrateState() + +initPromise.then(() => { + if (tabs.size > 0) { + ensureRelayConnection().then(() => { + reconnectAttempt = 0 + return reannounceAttachedTabs() + }).catch(() => { + scheduleReconnect() + }) + } +}) + +// Shared gate: all state-dependent handlers await this before accessing maps. +async function whenReady(fn) { + await initPromise + return fn() +} + +// Relay check handler for the options page. The service worker has +// host_permissions and bypasses CORS preflight, so the options page +// delegates token-validation requests here. +chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => { + if (msg?.type !== 'relayCheck') return false + const { url, token } = msg + const headers = token ? { 'x-openclaw-relay-token': token } : {} + fetch(url, { method: 'GET', headers, signal: AbortSignal.timeout(2000) }) + .then(async (res) => { + const contentType = String(res.headers.get('content-type') || '') + let json = null + if (contentType.includes('application/json')) { + try { + json = await res.json() + } catch { + json = null + } + } + sendResponse({ status: res.status, ok: res.ok, contentType, json }) + }) + .catch((err) => sendResponse({ status: 0, ok: false, error: String(err) })) + return true +}) diff --git a/assets/chrome-extension/manifest.json b/assets/chrome-extension/manifest.json index d6b593990de..62038276cd7 100644 --- a/assets/chrome-extension/manifest.json +++ b/assets/chrome-extension/manifest.json @@ -9,7 +9,7 @@ "48": "icons/icon48.png", "128": "icons/icon128.png" }, - "permissions": ["debugger", "tabs", "activeTab", "storage"], + "permissions": ["debugger", "tabs", "activeTab", "storage", "alarms", "webNavigation"], "host_permissions": ["http://127.0.0.1/*", "http://localhost/*"], "background": { "service_worker": "background.js", "type": "module" }, "action": { diff --git a/assets/chrome-extension/options-validation.js b/assets/chrome-extension/options-validation.js new file mode 100644 index 00000000000..53e2cd55014 --- /dev/null +++ b/assets/chrome-extension/options-validation.js @@ -0,0 +1,57 @@ +const PORT_GUIDANCE = 'Use gateway port + 3 (for gateway 18789, relay is 18792).' + +function hasCdpVersionShape(data) { + return !!data && typeof data === 'object' && 'Browser' in data && 'Protocol-Version' in data +} + +export function classifyRelayCheckResponse(res, port) { + if (!res) { + return { action: 'throw', error: 'No response from service worker' } + } + + if (res.status === 401) { + return { action: 'status', kind: 'error', message: 'Gateway token rejected. Check token and save again.' } + } + + if (res.error) { + return { action: 'throw', error: res.error } + } + + if (!res.ok) { + return { action: 'throw', error: `HTTP ${res.status}` } + } + + const contentType = String(res.contentType || '') + if (!contentType.includes('application/json')) { + return { + action: 'status', + kind: 'error', + message: `Wrong port: this is likely the gateway, not the relay. ${PORT_GUIDANCE}`, + } + } + + if (!hasCdpVersionShape(res.json)) { + return { + action: 'status', + kind: 'error', + message: `Wrong port: expected relay /json/version response. ${PORT_GUIDANCE}`, + } + } + + return { action: 'status', kind: 'ok', message: `Relay reachable and authenticated at http://127.0.0.1:${port}/` } +} + +export function classifyRelayCheckException(err, port) { + const message = String(err || '').toLowerCase() + if (message.includes('json') || message.includes('syntax')) { + return { + kind: 'error', + message: `Wrong port: this is not a relay endpoint. ${PORT_GUIDANCE}`, + } + } + + return { + kind: 'error', + message: `Relay not reachable/authenticated at http://127.0.0.1:${port}/. Start OpenClaw browser relay and verify token.`, + } +} diff --git a/assets/chrome-extension/options.html b/assets/chrome-extension/options.html index 14704d65cf0..17fc6a79eed 100644 --- a/assets/chrome-extension/options.html +++ b/assets/chrome-extension/options.html @@ -176,15 +176,19 @@
-

Relay port

+

Relay connection

+
+ +
+
- Default: 18792. Extension connects to: http://127.0.0.1:<port>/. - Only change this if your OpenClaw profile uses a different cdpUrl port. + Default port: 18792. Extension connects to: http://127.0.0.1:<port>/. + Gateway token must match gateway.auth.token (or OPENCLAW_GATEWAY_TOKEN).
diff --git a/assets/chrome-extension/options.js b/assets/chrome-extension/options.js index 5b558ddccf2..aa6fcc4901f 100644 --- a/assets/chrome-extension/options.js +++ b/assets/chrome-extension/options.js @@ -1,3 +1,6 @@ +import { deriveRelayToken } from './background-utils.js' +import { classifyRelayCheckException, classifyRelayCheckResponse } from './options-validation.js' + const DEFAULT_PORT = 18792 function clampPort(value) { @@ -20,39 +23,51 @@ function setStatus(kind, message) { status.textContent = message || '' } -async function checkRelayReachable(port) { - const url = `http://127.0.0.1:${port}/` - const ctrl = new AbortController() - const t = setTimeout(() => ctrl.abort(), 900) +async function checkRelayReachable(port, token) { + const url = `http://127.0.0.1:${port}/json/version` + const trimmedToken = String(token || '').trim() + if (!trimmedToken) { + setStatus('error', 'Gateway token required. Save your gateway token to connect.') + return + } try { - const res = await fetch(url, { method: 'HEAD', signal: ctrl.signal }) - if (!res.ok) throw new Error(`HTTP ${res.status}`) - setStatus('ok', `Relay reachable at ${url}`) - } catch { - setStatus( - 'error', - `Relay not reachable at ${url}. Start OpenClaw’s browser relay on this machine, then click the toolbar button again.`, - ) - } finally { - clearTimeout(t) + const relayToken = await deriveRelayToken(trimmedToken, port) + // Delegate the fetch to the background service worker to bypass + // CORS preflight on the custom x-openclaw-relay-token header. + const res = await chrome.runtime.sendMessage({ + type: 'relayCheck', + url, + token: relayToken, + }) + const result = classifyRelayCheckResponse(res, port) + if (result.action === 'throw') throw new Error(result.error) + setStatus(result.kind, result.message) + } catch (err) { + const result = classifyRelayCheckException(err, port) + setStatus(result.kind, result.message) } } async function load() { - const stored = await chrome.storage.local.get(['relayPort']) + const stored = await chrome.storage.local.get(['relayPort', 'gatewayToken']) const port = clampPort(stored.relayPort) + const token = String(stored.gatewayToken || '').trim() document.getElementById('port').value = String(port) + document.getElementById('token').value = token updateRelayUrl(port) - await checkRelayReachable(port) + await checkRelayReachable(port, token) } async function save() { - const input = document.getElementById('port') - const port = clampPort(input.value) - await chrome.storage.local.set({ relayPort: port }) - input.value = String(port) + const portInput = document.getElementById('port') + const tokenInput = document.getElementById('token') + const port = clampPort(portInput.value) + const token = String(tokenInput.value || '').trim() + await chrome.storage.local.set({ relayPort: port, gatewayToken: token }) + portInput.value = String(port) + tokenInput.value = token updateRelayUrl(port) - await checkRelayReachable(port) + await checkRelayReachable(port, token) } document.getElementById('save').addEventListener('click', () => void save()) diff --git a/docker-setup.sh b/docker-setup.sh index 1d2f5e53fd1..8c67dc0962d 100755 --- a/docker-setup.sh +++ b/docker-setup.sh @@ -8,6 +8,11 @@ IMAGE_NAME="${OPENCLAW_IMAGE:-openclaw:local}" EXTRA_MOUNTS="${OPENCLAW_EXTRA_MOUNTS:-}" HOME_VOLUME_NAME="${OPENCLAW_HOME_VOLUME:-}" +fail() { + echo "ERROR: $*" >&2 + exit 1 +} + require_cmd() { if ! command -v "$1" >/dev/null 2>&1; then echo "Missing dependency: $1" >&2 @@ -15,6 +20,44 @@ require_cmd() { fi } +contains_disallowed_chars() { + local value="$1" + [[ "$value" == *$'\n'* || "$value" == *$'\r'* || "$value" == *$'\t'* ]] +} + +validate_mount_path_value() { + local label="$1" + local value="$2" + if [[ -z "$value" ]]; then + fail "$label cannot be empty." + fi + if contains_disallowed_chars "$value"; then + fail "$label contains unsupported control characters." + fi + if [[ "$value" =~ [[:space:]] ]]; then + fail "$label cannot contain whitespace." + fi +} + +validate_named_volume() { + local value="$1" + if [[ ! "$value" =~ ^[A-Za-z0-9][A-Za-z0-9_.-]*$ ]]; then + fail "OPENCLAW_HOME_VOLUME must match [A-Za-z0-9][A-Za-z0-9_.-]* when using a named volume." + fi +} + +validate_mount_spec() { + local mount="$1" + if contains_disallowed_chars "$mount"; then + fail "OPENCLAW_EXTRA_MOUNTS entries cannot contain control characters." + fi + # Keep mount specs strict to avoid YAML structure injection. + # Expected format: source:target[:options] + if [[ ! "$mount" =~ ^[^[:space:],:]+:[^[:space:],:]+(:[^[:space:],:]+)?$ ]]; then + fail "Invalid mount format '$mount'. Expected source:target[:options] without spaces." + fi +} + require_cmd docker if ! docker compose version >/dev/null 2>&1; then echo "Docker Compose not available (try: docker compose version)" >&2 @@ -24,8 +67,24 @@ fi OPENCLAW_CONFIG_DIR="${OPENCLAW_CONFIG_DIR:-$HOME/.openclaw}" OPENCLAW_WORKSPACE_DIR="${OPENCLAW_WORKSPACE_DIR:-$HOME/.openclaw/workspace}" +validate_mount_path_value "OPENCLAW_CONFIG_DIR" "$OPENCLAW_CONFIG_DIR" +validate_mount_path_value "OPENCLAW_WORKSPACE_DIR" "$OPENCLAW_WORKSPACE_DIR" +if [[ -n "$HOME_VOLUME_NAME" ]]; then + if [[ "$HOME_VOLUME_NAME" == *"/"* ]]; then + validate_mount_path_value "OPENCLAW_HOME_VOLUME" "$HOME_VOLUME_NAME" + else + validate_named_volume "$HOME_VOLUME_NAME" + fi +fi +if contains_disallowed_chars "$EXTRA_MOUNTS"; then + fail "OPENCLAW_EXTRA_MOUNTS cannot contain control characters." +fi + mkdir -p "$OPENCLAW_CONFIG_DIR" mkdir -p "$OPENCLAW_WORKSPACE_DIR" +# Seed device-identity parent eagerly for Docker Desktop/Windows bind mounts +# that reject creating new subdirectories from inside the container. +mkdir -p "$OPENCLAW_CONFIG_DIR/identity" export OPENCLAW_CONFIG_DIR export OPENCLAW_WORKSPACE_DIR @@ -57,6 +116,9 @@ write_extra_compose() { local home_volume="$1" shift local mount + local gateway_home_mount + local gateway_config_mount + local gateway_workspace_mount cat >"$EXTRA_COMPOSE_FILE" <<'YAML' services: @@ -65,12 +127,19 @@ services: YAML if [[ -n "$home_volume" ]]; then - printf ' - %s:/home/node\n' "$home_volume" >>"$EXTRA_COMPOSE_FILE" - printf ' - %s:/home/node/.openclaw\n' "$OPENCLAW_CONFIG_DIR" >>"$EXTRA_COMPOSE_FILE" - printf ' - %s:/home/node/.openclaw/workspace\n' "$OPENCLAW_WORKSPACE_DIR" >>"$EXTRA_COMPOSE_FILE" + gateway_home_mount="${home_volume}:/home/node" + gateway_config_mount="${OPENCLAW_CONFIG_DIR}:/home/node/.openclaw" + gateway_workspace_mount="${OPENCLAW_WORKSPACE_DIR}:/home/node/.openclaw/workspace" + validate_mount_spec "$gateway_home_mount" + validate_mount_spec "$gateway_config_mount" + validate_mount_spec "$gateway_workspace_mount" + printf ' - %s\n' "$gateway_home_mount" >>"$EXTRA_COMPOSE_FILE" + printf ' - %s\n' "$gateway_config_mount" >>"$EXTRA_COMPOSE_FILE" + printf ' - %s\n' "$gateway_workspace_mount" >>"$EXTRA_COMPOSE_FILE" fi for mount in "$@"; do + validate_mount_spec "$mount" printf ' - %s\n' "$mount" >>"$EXTRA_COMPOSE_FILE" done @@ -80,16 +149,18 @@ YAML YAML if [[ -n "$home_volume" ]]; then - printf ' - %s:/home/node\n' "$home_volume" >>"$EXTRA_COMPOSE_FILE" - printf ' - %s:/home/node/.openclaw\n' "$OPENCLAW_CONFIG_DIR" >>"$EXTRA_COMPOSE_FILE" - printf ' - %s:/home/node/.openclaw/workspace\n' "$OPENCLAW_WORKSPACE_DIR" >>"$EXTRA_COMPOSE_FILE" + printf ' - %s\n' "$gateway_home_mount" >>"$EXTRA_COMPOSE_FILE" + printf ' - %s\n' "$gateway_config_mount" >>"$EXTRA_COMPOSE_FILE" + printf ' - %s\n' "$gateway_workspace_mount" >>"$EXTRA_COMPOSE_FILE" fi for mount in "$@"; do + validate_mount_spec "$mount" printf ' - %s\n' "$mount" >>"$EXTRA_COMPOSE_FILE" done if [[ -n "$home_volume" && "$home_volume" != *"/"* ]]; then + validate_named_volume "$home_volume" cat >>"$EXTRA_COMPOSE_FILE" < + + + + + + + + + + + + + diff --git a/docs/assets/sponsors/openai.svg b/docs/assets/sponsors/openai.svg new file mode 100644 index 00000000000..1c3491b9be9 --- /dev/null +++ b/docs/assets/sponsors/openai.svg @@ -0,0 +1,3 @@ + + + diff --git a/docs/automation/cron-jobs.md b/docs/automation/cron-jobs.md index 4ba650aaf78..8d140192607 100644 --- a/docs/automation/cron-jobs.md +++ b/docs/automation/cron-jobs.md @@ -115,11 +115,22 @@ Cron supports three schedule kinds: - `at`: one-shot timestamp via `schedule.at` (ISO 8601). - `every`: fixed interval (ms). -- `cron`: 5-field cron expression with optional IANA timezone. +- `cron`: 5-field cron expression (or 6-field with seconds) with optional IANA timezone. Cron expressions use `croner`. If a timezone is omitted, the Gateway host’s local timezone is used. +To reduce top-of-hour load spikes across many gateways, OpenClaw applies a +deterministic per-job stagger window of up to 5 minutes for recurring +top-of-hour expressions (for example `0 * * * *`, `0 */2 * * *`). Fixed-hour +expressions such as `0 7 * * *` remain exact. + +For any cron schedule, you can set an explicit stagger window with `schedule.staggerMs` +(`0` keeps exact timing). CLI shortcuts: + +- `--stagger 30s` (or `1m`, `5m`) to set an explicit stagger window. +- `--exact` to force `staggerMs = 0`. + ### Main vs isolated execution #### Main session jobs (system events) @@ -144,7 +155,7 @@ Key behaviors: - Default behavior: if `delivery` is omitted, isolated jobs announce a summary (`delivery.mode = "announce"`). - `delivery.mode` chooses what happens: - `announce`: deliver a summary to the target channel and post a brief summary to the main session. - - `webhook`: POST the finished event payload to `delivery.to`. + - `webhook`: POST the finished event payload to `delivery.to` when the finished event includes a summary. - `none`: internal only (no delivery, no main-session summary). - `wakeMode` controls when the main-session summary posts: - `now`: immediate heartbeat. @@ -197,7 +208,7 @@ Behavior details: #### Webhook delivery flow -When `delivery.mode = "webhook"`, cron posts the finished event payload to `delivery.to`. +When `delivery.mode = "webhook"`, cron posts the finished event payload to `delivery.to` when the finished event includes a summary. Behavior details: @@ -338,7 +349,8 @@ Notes: ## Storage & history - Job store: `~/.openclaw/cron/jobs.json` (Gateway-managed JSON). -- Run history: `~/.openclaw/cron/runs/.jsonl` (JSONL, auto-pruned). +- Run history: `~/.openclaw/cron/runs/.jsonl` (JSONL, auto-pruned by size and line count). +- Isolated cron run sessions in `sessions.json` are pruned by `cron.sessionRetention` (default `24h`; set `false` to disable). - Override store path: `cron.store` in config. ## Configuration @@ -351,15 +363,26 @@ Notes: maxConcurrentRuns: 1, // default 1 webhook: "https://example.invalid/legacy", // deprecated fallback for stored notify:true jobs webhookToken: "replace-with-dedicated-webhook-token", // optional bearer token for webhook mode + sessionRetention: "24h", // duration string or false + runLog: { + maxBytes: "2mb", // default 2_000_000 bytes + keepLines: 2000, // default 2000 + }, }, } ``` +Run-log pruning behavior: + +- `cron.runLog.maxBytes`: max run-log file size before pruning. +- `cron.runLog.keepLines`: when pruning, keep only the newest N lines. +- Both apply to `cron/runs/.jsonl` files. + Webhook behavior: - Preferred: set `delivery.mode: "webhook"` with `delivery.to: "https://..."` per job. - Webhook URLs must be valid `http://` or `https://` URLs. -- Payload is the cron finished event JSON. +- When posted, payload is the cron finished event JSON. - If `cron.webhookToken` is set, auth header is `Authorization: Bearer `. - If `cron.webhookToken` is not set, no `Authorization` header is sent. - Deprecated fallback: stored legacy jobs with `notify: true` still use `cron.webhook` when present. @@ -369,6 +392,85 @@ Disable cron entirely: - `cron.enabled: false` (config) - `OPENCLAW_SKIP_CRON=1` (env) +## Maintenance + +Cron has two built-in maintenance paths: isolated run-session retention and run-log pruning. + +### Defaults + +- `cron.sessionRetention`: `24h` (set `false` to disable run-session pruning) +- `cron.runLog.maxBytes`: `2_000_000` bytes +- `cron.runLog.keepLines`: `2000` + +### How it works + +- Isolated runs create session entries (`...:cron::run:`) and transcript files. +- The reaper removes expired run-session entries older than `cron.sessionRetention`. +- For removed run sessions no longer referenced by the session store, OpenClaw archives transcript files and purges old deleted archives on the same retention window. +- After each run append, `cron/runs/.jsonl` is size-checked: + - if file size exceeds `runLog.maxBytes`, it is trimmed to the newest `runLog.keepLines` lines. + +### Performance caveat for high volume schedulers + +High-frequency cron setups can generate large run-session and run-log footprints. Maintenance is built in, but loose limits can still create avoidable IO and cleanup work. + +What to watch: + +- long `cron.sessionRetention` windows with many isolated runs +- high `cron.runLog.keepLines` combined with large `runLog.maxBytes` +- many noisy recurring jobs writing to the same `cron/runs/.jsonl` + +What to do: + +- keep `cron.sessionRetention` as short as your debugging/audit needs allow +- keep run logs bounded with moderate `runLog.maxBytes` and `runLog.keepLines` +- move noisy background jobs to isolated mode with delivery rules that avoid unnecessary chatter +- review growth periodically with `openclaw cron runs` and adjust retention before logs become large + +### Customize examples + +Keep run sessions for a week and allow bigger run logs: + +```json5 +{ + cron: { + sessionRetention: "7d", + runLog: { + maxBytes: "10mb", + keepLines: 5000, + }, + }, +} +``` + +Disable isolated run-session pruning but keep run-log pruning: + +```json5 +{ + cron: { + sessionRetention: false, + runLog: { + maxBytes: "5mb", + keepLines: 3000, + }, + }, +} +``` + +Tune for high-volume cron usage (example): + +```json5 +{ + cron: { + sessionRetention: "12h", + runLog: { + maxBytes: "3mb", + keepLines: 1500, + }, + }, +} +``` + ## CLI quickstart One-shot reminder (UTC ISO, auto-delete after success): @@ -408,6 +510,19 @@ openclaw cron add \ --to "+15551234567" ``` +Recurring cron job with explicit 30-second stagger: + +```bash +openclaw cron add \ + --name "Minute watcher" \ + --cron "0 * * * * *" \ + --tz "UTC" \ + --stagger 30s \ + --session isolated \ + --message "Run minute watcher checks." \ + --announce +``` + Recurring isolated job (deliver to a Telegram topic): ```bash @@ -465,6 +580,12 @@ openclaw cron edit \ --thinking low ``` +Force an existing cron job to run exactly on schedule (no stagger): + +```bash +openclaw cron edit --exact +``` + Run history: ```bash @@ -503,3 +624,10 @@ openclaw system event --mode now --text "Next heartbeat: check battery." - For forum topics, use `-100…:topic:` so it’s explicit and unambiguous. - If you see `telegram:...` prefixes in logs or stored “last route” targets, that’s normal; cron delivery accepts them and still parses topic IDs correctly. + +### Subagent announce delivery retries + +- When a subagent run completes, the gateway announces the result to the requester session. +- If the announce flow returns `false` (e.g. requester session is busy), the gateway retries up to 3 times with tracking via `announceRetryCount`. +- Announces older than 5 minutes past `endedAt` are force-expired to prevent stale entries from looping indefinitely. +- If you see repeated announce deliveries in logs, check the subagent registry for entries with high `announceRetryCount` values. diff --git a/docs/automation/cron-vs-heartbeat.md b/docs/automation/cron-vs-heartbeat.md index 423565d4f32..c25cbcb80db 100644 --- a/docs/automation/cron-vs-heartbeat.md +++ b/docs/automation/cron-vs-heartbeat.md @@ -74,7 +74,9 @@ See [Heartbeat](/gateway/heartbeat) for full configuration. ## Cron: Precise Scheduling -Cron jobs run at **exact times** and can run in isolated sessions without affecting main context. +Cron jobs run at precise times and can run in isolated sessions without affecting main context. +Recurring top-of-hour schedules are automatically spread by a deterministic +per-job offset in a 0-5 minute window. ### When to use cron @@ -87,7 +89,9 @@ Cron jobs run at **exact times** and can run in isolated sessions without affect ### Cron advantages -- **Exact timing**: 5-field cron expressions with timezone support. +- **Precise timing**: 5-field or 6-field (seconds) cron expressions with timezone support. +- **Built-in load spreading**: recurring top-of-hour schedules are staggered by up to 5 minutes by default. +- **Per-job control**: override stagger with `--stagger ` or force exact timing with `--exact`. - **Session isolation**: Runs in `cron:` without polluting main history. - **Model overrides**: Use a cheaper or more powerful model per job. - **Delivery control**: Isolated jobs default to `announce` (summary); choose `none` as needed. @@ -207,7 +211,7 @@ For ad-hoc workflows, call Lobster directly. - Lobster runs as a **local subprocess** (`lobster` CLI) in tool mode and returns a **JSON envelope**. - If the tool returns `needs_approval`, you resume with a `resumeToken` and `approve` flag. - The tool is an **optional plugin**; enable it additively via `tools.alsoAllow: ["lobster"]` (recommended). -- If you pass `lobsterPath`, it must be an **absolute path**. +- Lobster expects the `lobster` CLI to be available on `PATH`. See [Lobster](/tools/lobster) for full usage and examples. diff --git a/docs/automation/hooks.md b/docs/automation/hooks.md index ffdf32ab79b..0f561741d9a 100644 --- a/docs/automation/hooks.md +++ b/docs/automation/hooks.md @@ -119,6 +119,8 @@ Example `package.json`: Each entry points to a hook directory containing `HOOK.md` and `handler.ts` (or `index.ts`). Hook packs can ship dependencies; they will be installed under `~/.openclaw/hooks/`. +Each `openclaw.hooks` entry must stay inside the package directory after symlink +resolution; entries that escape are rejected. Security note: `openclaw hooks install` installs dependencies with `npm install --ignore-scripts` (no lifecycle scripts). Keep hook pack dependency trees "pure JS/TS" and avoid packages that rely @@ -180,9 +182,7 @@ The `metadata.openclaw` object supports: The `handler.ts` file exports a `HookHandler` function: ```typescript -import type { HookHandler } from "../../src/hooks/hooks.js"; - -const myHandler: HookHandler = async (event) => { +const myHandler = async (event) => { // Only trigger on 'new' command if (event.type !== "command" || event.action !== "new") { return; @@ -207,12 +207,13 @@ Each event includes: ```typescript { - type: 'command' | 'session' | 'agent' | 'gateway', - action: string, // e.g., 'new', 'reset', 'stop' + type: 'command' | 'session' | 'agent' | 'gateway' | 'message', + action: string, // e.g., 'new', 'reset', 'stop', 'received', 'sent' sessionKey: string, // Session identifier timestamp: Date, // When the event occurred messages: string[], // Push messages here to send to user context: { + // Command events: sessionEntry?: SessionEntry, sessionId?: string, sessionFile?: string, @@ -220,7 +221,13 @@ Each event includes: senderId?: string, workspaceDir?: string, bootstrapFiles?: WorkspaceBootstrapFile[], - cfg?: OpenClawConfig + cfg?: OpenClawConfig, + // Message events (see Message Events section for full details): + from?: string, // message:received + to?: string, // message:sent + content?: string, + channelId?: string, + success?: boolean, // message:sent } } ``` @@ -246,6 +253,72 @@ Triggered when the gateway starts: - **`gateway:startup`**: After channels start and hooks are loaded +### Message Events + +Triggered when messages are received or sent: + +- **`message`**: All message events (general listener) +- **`message:received`**: When an inbound message is received from any channel +- **`message:sent`**: When an outbound message is successfully sent + +#### Message Event Context + +Message events include rich context about the message: + +```typescript +// message:received context +{ + from: string, // Sender identifier (phone number, user ID, etc.) + content: string, // Message content + timestamp?: number, // Unix timestamp when received + channelId: string, // Channel (e.g., "whatsapp", "telegram", "discord") + accountId?: string, // Provider account ID for multi-account setups + conversationId?: string, // Chat/conversation ID + messageId?: string, // Message ID from the provider + metadata?: { // Additional provider-specific data + to?: string, + provider?: string, + surface?: string, + threadId?: string, + senderId?: string, + senderName?: string, + senderUsername?: string, + senderE164?: string, + } +} + +// message:sent context +{ + to: string, // Recipient identifier + content: string, // Message content that was sent + success: boolean, // Whether the send succeeded + error?: string, // Error message if sending failed + channelId: string, // Channel (e.g., "whatsapp", "telegram", "discord") + accountId?: string, // Provider account ID + conversationId?: string, // Chat/conversation ID + messageId?: string, // Message ID returned by the provider +} +``` + +#### Example: Message Logger Hook + +```typescript +const isMessageReceivedEvent = (event: { type: string; action: string }) => + event.type === "message" && event.action === "received"; +const isMessageSentEvent = (event: { type: string; action: string }) => + event.type === "message" && event.action === "sent"; + +const handler = async (event) => { + if (isMessageReceivedEvent(event as { type: string; action: string })) { + console.log(`[message-logger] Received from ${event.context.from}: ${event.context.content}`); + } else if (isMessageSentEvent(event as { type: string; action: string })) { + console.log(`[message-logger] Sent to ${event.context.to}: ${event.context.content}`); + } +}; + +export default handler; +``` + ### Tool Result Hooks (Plugin API) These hooks are not event-stream listeners; they let plugins synchronously adjust tool results before OpenClaw persists them. @@ -259,8 +332,6 @@ Planned event types: - **`session:start`**: When a new session begins - **`session:end`**: When a session ends - **`agent:error`**: When an agent encounters an error -- **`message:sent`**: When a message is sent -- **`message:received`**: When a message is received ## Creating Custom Hooks @@ -293,9 +364,7 @@ This hook does something useful when you issue `/new`. ### 4. Create handler.ts ```typescript -import type { HookHandler } from "../../src/hooks/hooks.js"; - -const handler: HookHandler = async (event) => { +const handler = async (event) => { if (event.type !== "command" || event.action !== "new") { return; } @@ -722,13 +791,17 @@ Test your handlers in isolation: ```typescript import { test } from "vitest"; -import { createHookEvent } from "./src/hooks/hooks.js"; import myHandler from "./hooks/my-hook/handler.js"; test("my handler works", async () => { - const event = createHookEvent("command", "new", "test-session", { - foo: "bar", - }); + const event = { + type: "command", + action: "new", + sessionKey: "test-session", + timestamp: new Date(), + messages: [], + context: { foo: "bar" }, + }; await myHandler(event); diff --git a/docs/automation/troubleshooting.md b/docs/automation/troubleshooting.md index 51f2aa209cf..9190855dd59 100644 --- a/docs/automation/troubleshooting.md +++ b/docs/automation/troubleshooting.md @@ -89,7 +89,7 @@ Common signatures: - `heartbeat skipped` with `reason=quiet-hours` → outside `activeHours`. - `requests-in-flight` → main lane busy; heartbeat deferred. -- `empty-heartbeat-file` → `HEARTBEAT.md` exists but has no actionable content. +- `empty-heartbeat-file` → interval heartbeat skipped because `HEARTBEAT.md` has no actionable content and no tagged cron event is queued. - `alerts-disabled` → visibility settings suppress outbound heartbeat messages. ## Timezone and activeHours gotchas diff --git a/docs/channels/bluebubbles.md b/docs/channels/bluebubbles.md index fd677a1d585..8c8267498b7 100644 --- a/docs/channels/bluebubbles.md +++ b/docs/channels/bluebubbles.md @@ -46,7 +46,8 @@ Status: bundled plugin that talks to the BlueBubbles macOS server over HTTP. **R Security note: -- Always set a webhook password. If you expose the gateway through a reverse proxy (Tailscale Serve/Funnel, nginx, Cloudflare Tunnel, ngrok), the proxy may connect to the gateway over loopback. The BlueBubbles webhook handler treats requests with forwarding headers as proxied and will not accept passwordless webhooks. +- Always set a webhook password. +- Webhook authentication is always required. OpenClaw rejects BlueBubbles webhook requests unless they include a password/guid that matches `channels.bluebubbles.password` (for example `?password=` or `x-password`), regardless of loopback/proxy topology. ## Keeping Messages.app alive (VM / headless setups) diff --git a/docs/channels/discord.md b/docs/channels/discord.md index e2328514540..108ef34d4ef 100644 --- a/docs/channels/discord.md +++ b/docs/channels/discord.md @@ -23,16 +23,98 @@ Status: ready for DMs and guild channels via the official Discord gateway. ## Quick setup - - - Create an application in the Discord Developer Portal, add a bot, then enable: +You will need to create a new application with a bot, add the bot to your server, and pair it to OpenClaw. We recommend adding your bot to your own private server. If you don't have one yet, [create one first](https://support.discord.com/hc/en-us/articles/204849977-How-do-I-create-a-server) (choose **Create My Own > For me and my friends**). - - **Message Content Intent** - - **Server Members Intent** (required for role allowlists and role-based routing; recommended for name-to-ID allowlist matching) + + + Go to the [Discord Developer Portal](https://discord.com/developers/applications) and click **New Application**. Name it something like "OpenClaw". + + Click **Bot** on the sidebar. Set the **Username** to whatever you call your OpenClaw agent. - + + Still on the **Bot** page, scroll down to **Privileged Gateway Intents** and enable: + + - **Message Content Intent** (required) + - **Server Members Intent** (recommended; required for role allowlists and name-to-ID matching) + - **Presence Intent** (optional; only needed for presence updates) + + + + + Scroll back up on the **Bot** page and click **Reset Token**. + + + Despite the name, this generates your first token — nothing is being "reset." + + + Copy the token and save it somewhere. This is your **Bot Token** and you will need it shortly. + + + + + Click **OAuth2** on the sidebar. You'll generate an invite URL with the right permissions to add the bot to your server. + + Scroll down to **OAuth2 URL Generator** and enable: + + - `bot` + - `applications.commands` + + A **Bot Permissions** section will appear below. Enable: + + - View Channels + - Send Messages + - Read Message History + - Embed Links + - Attach Files + - Add Reactions (optional) + + Copy the generated URL at the bottom, paste it into your browser, select your server, and click **Continue** to connect. You should now see your bot in the Discord server. + + + + + Back in the Discord app, you need to enable Developer Mode so you can copy internal IDs. + + 1. Click **User Settings** (gear icon next to your avatar) → **Advanced** → toggle on **Developer Mode** + 2. Right-click your **server icon** in the sidebar → **Copy Server ID** + 3. Right-click your **own avatar** → **Copy User ID** + + Save your **Server ID** and **User ID** alongside your Bot Token — you'll send all three to OpenClaw in the next step. + + + + + For pairing to work, Discord needs to allow your bot to DM you. Right-click your **server icon** → **Privacy Settings** → toggle on **Direct Messages**. + + This lets server members (including bots) send you DMs. Keep this enabled if you want to use Discord DMs with OpenClaw. If you only plan to use guild channels, you can disable DMs after pairing. + + + + + Your Discord bot token is a secret (like a password). Set it on the machine running OpenClaw before messaging your agent. + +```bash +openclaw config set channels.discord.token '"YOUR_BOT_TOKEN"' --json +openclaw config set channels.discord.enabled true --json +openclaw gateway +``` + + If OpenClaw is already running as a background service, use `openclaw gateway restart` instead. + + + + + + + + Chat with your OpenClaw agent on any existing channel (e.g. Telegram) and tell it. If Discord is your first channel, use the CLI / config tab instead. + + > "I already set my Discord bot token in config. Please finish Discord setup with User ID `` and Server ID ``." + + + If you prefer file-based config, set: ```json5 { @@ -45,32 +127,40 @@ Status: ready for DMs and guild channels via the official Discord gateway. } ``` - Env fallback for the default account: + Env fallback for the default account: ```bash DISCORD_BOT_TOKEN=... ``` - - - - Invite the bot to your server with message permissions. - -```bash -openclaw gateway -``` + + + Wait until the gateway is running, then DM your bot in Discord. It will respond with a pairing code. + + + + Send the pairing code to your agent on your existing channel: + + > "Approve this Discord pairing code: ``" + + ```bash openclaw pairing list discord openclaw pairing approve discord ``` + + + Pairing codes expire after 1 hour. + You should now be able to chat with your agent in Discord via DM. + @@ -78,6 +168,87 @@ openclaw pairing approve discord Token resolution is account-aware. Config token values win over env fallback. `DISCORD_BOT_TOKEN` is only used for the default account. +## Recommended: Set up a guild workspace + +Once DMs are working, you can set up your Discord server as a full workspace where each channel gets its own agent session with its own context. This is recommended for private servers where it's just you and your bot. + + + + This enables your agent to respond in any channel on your server, not just DMs. + + + + > "Add my Discord Server ID `` to the guild allowlist" + + + +```json5 +{ + channels: { + discord: { + groupPolicy: "allowlist", + guilds: { + YOUR_SERVER_ID: { + requireMention: true, + users: ["YOUR_USER_ID"], + }, + }, + }, + }, +} +``` + + + + + + + + By default, your agent only responds in guild channels when @mentioned. For a private server, you probably want it to respond to every message. + + + + > "Allow my agent to respond on this server without having to be @mentioned" + + + Set `requireMention: false` in your guild config: + +```json5 +{ + channels: { + discord: { + guilds: { + YOUR_SERVER_ID: { + requireMention: false, + }, + }, + }, + }, +} +``` + + + + + + + + By default, long-term memory (MEMORY.md) only loads in DM sessions. Guild channels do not auto-load MEMORY.md. + + + + > "When I ask questions in Discord channels, use memory_search or memory_get if you need long-term context from MEMORY.md." + + + If you need shared context in every channel, put the stable instructions in `AGENTS.md` or `USER.md` (they are injected for every session). Keep long-term notes in `MEMORY.md` and access them on demand with memory tools. + + + + + + +Now create some channels on your Discord server and start chatting. Your agent can see the channel name, and each channel gets its own isolated session — so you can set up `#coding`, `#home`, `#research`, or whatever fits your workflow. + ## Runtime model - Gateway owns the Discord connection. @@ -87,6 +258,29 @@ Token resolution is account-aware. Config token values win over env fallback. `D - Group DMs are ignored by default (`channels.discord.dm.groupEnabled=false`). - Native slash commands run in isolated command sessions (`agent::discord:slash:`), while still carrying `CommandTargetSessionKey` to the routed conversation session. +## Forum channels + +Discord forum and media channels only accept thread posts. OpenClaw supports two ways to create them: + +- Send a message to the forum parent (`channel:`) to auto-create a thread. The thread title uses the first non-empty line of your message. +- Use `openclaw message thread create` to create a thread directly. Do not pass `--message-id` for forum channels. + +Example: send to forum parent to create a thread + +```bash +openclaw message send --channel discord --target channel: \ + --message "Topic title\nBody of the post" +``` + +Example: create a forum thread explicitly + +```bash +openclaw message thread create --channel discord --target channel: \ + --thread-name "Topic title" --message "Body of the post" +``` + +Forum parents do not accept Discord components. If you need components, send to the thread itself (`channel:`). + ## Interactive components OpenClaw supports Discord components v2 containers for agent messages. Use the message tool with a `components` payload. Interaction results are routed back to the agent as normal inbound messages and follow the existing Discord `replyToMode` settings. @@ -97,6 +291,12 @@ Supported blocks: - Action rows allow up to 5 buttons or a single select menu - Select types: `string`, `user`, `role`, `mentionable`, `channel` +By default, components are single use. Set `components.reusable=true` to allow buttons, selects, and forms to be used multiple times until they expire. + +To restrict who can click a button, set `allowedUsers` on that button (Discord user IDs, tags, or `*`). When configured, unmatched users receive an ephemeral denial. + +The `/model` and `/models` slash commands open an interactive model picker with provider and model dropdowns plus a Submit step. The picker reply is ephemeral and only the invoking user can use it. + File attachments: - `file` blocks must point to an attachment reference (`attachment://`) @@ -118,12 +318,17 @@ Example: to: "channel:123456789012345678", message: "Optional fallback text", components: { + reusable: true, text: "Choose a path", blocks: [ { type: "actions", buttons: [ - { label: "Approve", style: "success" }, + { + label: "Approve", + style: "success", + allowedUsers: ["123456789012345678"], + }, { label: "Decline", style: "danger" }, ], }, @@ -192,7 +397,9 @@ Example: `allowlist` behavior: - guild must match `channels.discord.guilds` (`id` preferred, slug accepted) - - optional sender allowlists: `users` (IDs or names) and `roles` (role IDs only); if either is configured, senders are allowed when they match `users` OR `roles` + - optional sender allowlists: `users` (stable IDs recommended) and `roles` (role IDs only); if either is configured, senders are allowed when they match `users` OR `roles` + - direct name/tag matching is disabled by default; enable `channels.discord.dangerouslyAllowNameMatching: true` only as break-glass compatibility mode + - names/tags are supported for `users`, but IDs are safer; `openclaw security audit` warns when name/tag entries are used - if a guild has `channels` configured, non-listed channels are denied - if a guild has no `channels` block, all channels in that allowlisted guild are allowed @@ -219,7 +426,7 @@ Example: } ``` - If you only set `DISCORD_BOT_TOKEN` and do not create a `channels.discord` block, runtime fallback is `groupPolicy="open"` (with a warning in logs). + If you only set `DISCORD_BOT_TOKEN` and do not create a `channels.discord` block, runtime fallback is `groupPolicy="allowlist"` (with a warning in logs), even if `channels.defaults.groupPolicy` is `open`. @@ -329,6 +536,10 @@ Use `bindings[].match.roles` to route Discord guild members to different agents See [Slash commands](/tools/slash-commands) for command catalog and behavior. +Default slash command settings: + +- `ephemeral: true` + ## Feature details @@ -350,6 +561,51 @@ See [Slash commands](/tools/slash-commands) for command catalog and behavior. + + OpenClaw can stream draft replies by sending a temporary message and editing it as text arrives. + + - `channels.discord.streaming` controls preview streaming (`off` | `partial` | `block` | `progress`, default: `off`). + - `progress` is accepted for cross-channel consistency and maps to `partial` on Discord. + - `channels.discord.streamMode` is a legacy alias and is auto-migrated. + - `partial` edits a single preview message as tokens arrive. + - `block` emits draft-sized chunks (use `draftChunk` to tune size and breakpoints). + + Example: + +```json5 +{ + channels: { + discord: { + streaming: "partial", + }, + }, +} +``` + + `block` mode chunking defaults (clamped to `channels.discord.textChunkLimit`): + +```json5 +{ + channels: { + discord: { + streaming: "block", + draftChunk: { + minChars: 200, + maxChars: 800, + breakPreference: "paragraph", + }, + }, + }, +} +``` + + Preview streaming is text-only; media replies fall back to normal delivery. + + Note: preview streaming is separate from block streaming. When block streaming is explicitly + enabled for Discord, OpenClaw skips the preview stream to avoid double streaming. + + + Guild history context: @@ -372,6 +628,49 @@ See [Slash commands](/tools/slash-commands) for command catalog and behavior. + + Discord can bind a thread to a session target so follow-up messages in that thread keep routing to the same session (including subagent sessions). + + Commands: + + - `/focus ` bind current/new thread to a subagent/session target + - `/unfocus` remove current thread binding + - `/agents` show active runs and binding state + - `/session ttl ` inspect/update auto-unfocus TTL for focused bindings + + Config: + +```json5 +{ + session: { + threadBindings: { + enabled: true, + ttlHours: 24, + }, + }, + channels: { + discord: { + threadBindings: { + enabled: true, + ttlHours: 24, + spawnSubagentSessions: false, // opt-in + }, + }, + }, +} +``` + + Notes: + + - `session.threadBindings.*` sets global defaults. + - `channels.discord.threadBindings.*` overrides Discord behavior. + - `spawnSubagentSessions` must be true to auto-create/bind threads for `sessions_spawn({ thread: true })`. + - If thread bindings are disabled for an account, `/focus` and related thread binding operations are unavailable. + + See [Sub-agents](/tools/subagents) and [Configuration Reference](/gateway/configuration-reference). + + + Per-guild reaction notification mode: @@ -421,7 +720,7 @@ See [Slash commands](/tools/slash-commands) for command catalog and behavior. - Route Discord gateway WebSocket traffic through an HTTP(S) proxy with `channels.discord.proxy`. + Route Discord gateway WebSocket traffic and startup REST lookups (application ID + allowlist resolution) through an HTTP(S) proxy with `channels.discord.proxy`. ```json5 { @@ -470,7 +769,7 @@ See [Slash commands](/tools/slash-commands) for command catalog and behavior. Notes: - allowlists can use `pk:` - - member display names are matched by name/slug + - member display names are matched by name/slug only when `channels.discord.dangerouslyAllowNameMatching: true` - lookups use original message ID and are time-window constrained - if lookup fails, proxied messages are treated as bot messages and dropped unless `allowBots=true` @@ -594,6 +893,47 @@ Example: } ``` +## Voice channels + +OpenClaw can join Discord voice channels for realtime, continuous conversations. This is separate from voice message attachments. + +Requirements: + +- Enable native commands (`commands.native` or `channels.discord.commands.native`). +- Configure `channels.discord.voice`. +- The bot needs Connect + Speak permissions in the target voice channel. + +Use the Discord-only native command `/vc join|leave|status` to control sessions. The command uses the account default agent and follows the same allowlist and group policy rules as other Discord commands. + +Auto-join example: + +```json5 +{ + channels: { + discord: { + voice: { + enabled: true, + autoJoin: [ + { + guildId: "123456789012345678", + channelId: "234567890123456789", + }, + ], + tts: { + provider: "openai", + openai: { voice: "alloy" }, + }, + }, + }, + }, +} +``` + +Notes: + +- `voice.tts` overrides `messages.tts` for voice playback only. +- Voice is enabled by default; set `channels.discord.voice.enabled=false` to disable it. + ## Voice messages Discord voice messages show a waveform preview and require OGG/Opus audio plus metadata. OpenClaw generates the waveform automatically, but it needs `ffmpeg` and `ffprobe` available on the gateway host to inspect and convert audio files. @@ -680,9 +1020,10 @@ High-signal Discord fields: - startup/auth: `enabled`, `token`, `accounts.*`, `allowBots` - policy: `groupPolicy`, `dm.*`, `guilds.*`, `guilds.*.channels.*` -- command: `commands.native`, `commands.useAccessGroups`, `configWrites` +- command: `commands.native`, `commands.useAccessGroups`, `configWrites`, `slashCommand.*` - reply/history: `replyToMode`, `historyLimit`, `dmHistoryLimit`, `dms.*.historyLimit` - delivery: `textChunkLimit`, `chunkMode`, `maxLinesPerMessage` +- streaming: `streaming` (legacy alias: `streamMode`), `draftChunk`, `blockStreaming`, `blockStreamingCoalesce` - media/retry: `mediaMaxMb`, `retry` - actions: `actions.*` - presence: `activity`, `status`, `activityType`, `activityUrl` @@ -699,5 +1040,6 @@ High-signal Discord fields: - [Pairing](/channels/pairing) - [Channel routing](/channels/channel-routing) +- [Multi-agent routing](/concepts/multi-agent) - [Troubleshooting](/channels/troubleshooting) - [Slash commands](/tools/slash-commands) diff --git a/docs/channels/feishu.md b/docs/channels/feishu.md index 461facdbb27..e92f84460d3 100644 --- a/docs/channels/feishu.md +++ b/docs/channels/feishu.md @@ -193,6 +193,8 @@ Edit `~/.openclaw/openclaw.json`: } ``` +If you use `connectionMode: "webhook"`, set `verificationToken`. The Feishu webhook server binds to `127.0.0.1` by default; set `webhookHost` only if you intentionally need a different bind address. + ### Configure via environment variables ```bash @@ -527,23 +529,28 @@ Full configuration: [Gateway configuration](/gateway/configuration) Key options: -| Setting | Description | Default | -| ------------------------------------------------- | ------------------------------- | --------- | -| `channels.feishu.enabled` | Enable/disable channel | `true` | -| `channels.feishu.domain` | API domain (`feishu` or `lark`) | `feishu` | -| `channels.feishu.accounts..appId` | App ID | - | -| `channels.feishu.accounts..appSecret` | App Secret | - | -| `channels.feishu.accounts..domain` | Per-account API domain override | `feishu` | -| `channels.feishu.dmPolicy` | DM policy | `pairing` | -| `channels.feishu.allowFrom` | DM allowlist (open_id list) | - | -| `channels.feishu.groupPolicy` | Group policy | `open` | -| `channels.feishu.groupAllowFrom` | Group allowlist | - | -| `channels.feishu.groups..requireMention` | Require @mention | `true` | -| `channels.feishu.groups..enabled` | Enable group | `true` | -| `channels.feishu.textChunkLimit` | Message chunk size | `2000` | -| `channels.feishu.mediaMaxMb` | Media size limit | `30` | -| `channels.feishu.streaming` | Enable streaming card output | `true` | -| `channels.feishu.blockStreaming` | Enable block streaming | `true` | +| Setting | Description | Default | +| ------------------------------------------------- | ------------------------------- | ---------------- | +| `channels.feishu.enabled` | Enable/disable channel | `true` | +| `channels.feishu.domain` | API domain (`feishu` or `lark`) | `feishu` | +| `channels.feishu.connectionMode` | Event transport mode | `websocket` | +| `channels.feishu.verificationToken` | Required for webhook mode | - | +| `channels.feishu.webhookPath` | Webhook route path | `/feishu/events` | +| `channels.feishu.webhookHost` | Webhook bind host | `127.0.0.1` | +| `channels.feishu.webhookPort` | Webhook bind port | `3000` | +| `channels.feishu.accounts..appId` | App ID | - | +| `channels.feishu.accounts..appSecret` | App Secret | - | +| `channels.feishu.accounts..domain` | Per-account API domain override | `feishu` | +| `channels.feishu.dmPolicy` | DM policy | `pairing` | +| `channels.feishu.allowFrom` | DM allowlist (open_id list) | - | +| `channels.feishu.groupPolicy` | Group policy | `open` | +| `channels.feishu.groupAllowFrom` | Group allowlist | - | +| `channels.feishu.groups..requireMention` | Require @mention | `true` | +| `channels.feishu.groups..enabled` | Enable group | `true` | +| `channels.feishu.textChunkLimit` | Message chunk size | `2000` | +| `channels.feishu.mediaMaxMb` | Media size limit | `30` | +| `channels.feishu.streaming` | Enable streaming card output | `true` | +| `channels.feishu.blockStreaming` | Enable block streaming | `true` | --- diff --git a/docs/channels/googlechat.md b/docs/channels/googlechat.md index 818a8288f5d..13729257fe7 100644 --- a/docs/channels/googlechat.md +++ b/docs/channels/googlechat.md @@ -153,7 +153,8 @@ Configure your tunnel's ingress rules to only route the webhook path: Use these identifiers for delivery and allowlists: -- Direct messages: `users/` (recommended) or raw email `name@example.com` (mutable principal). +- Direct messages: `users/` (recommended). +- Raw email `name@example.com` is mutable and only used for direct allowlist matching when `channels.googlechat.dangerouslyAllowNameMatching: true`. - Deprecated: `users/` is treated as a user id, not an email allowlist. - Spaces: `spaces/`. @@ -171,7 +172,7 @@ Use these identifiers for delivery and allowlists: botUser: "users/1234567890", // optional; helps mention detection dm: { policy: "pairing", - allowFrom: ["users/1234567890", "name@example.com"], + allowFrom: ["users/1234567890"], }, groupPolicy: "allowlist", groups: { @@ -194,6 +195,7 @@ Notes: - Service account credentials can also be passed inline with `serviceAccount` (JSON string). - Default webhook path is `/googlechat` if `webhookPath` isn’t set. +- `dangerouslyAllowNameMatching` re-enables mutable email principal matching for allowlists (break-glass compatibility mode). - Reactions are available via the `reactions` tool and `channels action` when `actions.reactions` is enabled. - `typingIndicator` supports `none`, `message` (default), and `reaction` (reaction requires user OAuth). - Attachments are downloaded through the Chat API and stored in the media pipeline (size capped by `mediaMaxMb`). diff --git a/docs/channels/grammy.md b/docs/channels/grammy.md index ae92c5292b0..25c197116f6 100644 --- a/docs/channels/grammy.md +++ b/docs/channels/grammy.md @@ -21,7 +21,7 @@ title: grammY - **Webhook support:** `webhook-set.ts` wraps `setWebhook/deleteWebhook`; `webhook.ts` hosts the callback with health + graceful shutdown. Gateway enables webhook mode when `channels.telegram.webhookUrl` + `channels.telegram.webhookSecret` are set (otherwise it long-polls). - **Sessions:** direct chats collapse into the agent main session (`agent::`); groups use `agent::telegram:group:`; replies route back to the same channel. - **Config knobs:** `channels.telegram.botToken`, `channels.telegram.dmPolicy`, `channels.telegram.groups` (allowlist + mention defaults), `channels.telegram.allowFrom`, `channels.telegram.groupAllowFrom`, `channels.telegram.groupPolicy`, `channels.telegram.mediaMaxMb`, `channels.telegram.linkPreview`, `channels.telegram.proxy`, `channels.telegram.webhookSecret`, `channels.telegram.webhookUrl`, `channels.telegram.webhookHost`. -- **Live stream preview:** optional `channels.telegram.streamMode` sends a temporary message and updates it with `editMessageText`. This is separate from channel block streaming. +- **Live stream preview:** `channels.telegram.streaming` (`off | partial | block | progress`) sends a temporary message and updates it with `editMessageText`. This is separate from channel block streaming. - **Tests:** grammy mocks cover DM + group mention gating and outbound send; more media/webhook fixtures still welcome. Open questions diff --git a/docs/channels/groups.md b/docs/channels/groups.md index 6bd278846c5..de848243c9c 100644 --- a/docs/channels/groups.md +++ b/docs/channels/groups.md @@ -190,6 +190,7 @@ Notes: - Group DMs are controlled separately (`channels.discord.dm.*`, `channels.slack.dm.*`). - Telegram allowlist can match user IDs (`"123456789"`, `"telegram:123456789"`, `"tg:123456789"`) or usernames (`"@alice"` or `"alice"`); prefixes are case-insensitive. - Default is `groupPolicy: "allowlist"`; if your group allowlist is empty, group messages are blocked. +- Runtime safety: when a provider block is completely missing (`channels.` absent), group policy falls back to a fail-closed mode (typically `allowlist`) instead of inheriting `channels.defaults.groupPolicy`. Quick mental model (evaluation order for group messages): @@ -253,7 +254,10 @@ Notes: Some channel configs support restricting which tools are available **inside a specific group/room/channel**. - `tools`: allow/deny tools for the whole group. -- `toolsBySender`: per-sender overrides within the group (keys are sender IDs/usernames/emails/phone numbers depending on the channel). Use `"*"` as a wildcard. +- `toolsBySender`: per-sender overrides within the group. + Use explicit key prefixes: + `id:`, `e164:`, `username:`, `name:`, and `"*"` wildcard. + Legacy unprefixed keys are still accepted and matched as `id:` only. Resolution order (most specific wins): @@ -273,7 +277,7 @@ Example (Telegram): "-1001234567890": { tools: { deny: ["exec", "read", "write"] }, toolsBySender: { - "123456789": { alsoAllow: ["exec"] }, + "id:123456789": { alsoAllow: ["exec"] }, }, }, }, diff --git a/docs/channels/imessage.md b/docs/channels/imessage.md index 2876be31372..5720da1714a 100644 --- a/docs/channels/imessage.md +++ b/docs/channels/imessage.md @@ -97,12 +97,19 @@ exec ssh -T gateway-host imsg "$@" cliPath: "~/.openclaw/scripts/imsg-ssh", remoteHost: "user@gateway-host", // used for SCP attachment fetches includeAttachments: true, + // Optional: override allowed attachment roots. + // Defaults include /Users/*/Library/Messages/Attachments + attachmentRoots: ["/Users/*/Library/Messages/Attachments"], + remoteAttachmentRoots: ["/Users/*/Library/Messages/Attachments"], }, }, } ``` If `remoteHost` is not set, OpenClaw attempts to auto-detect it by parsing the SSH wrapper script. + `remoteHost` must be `host` or `user@host` (no spaces or SSH options). + OpenClaw uses strict host-key checking for SCP, so the relay host key must already exist in `~/.ssh/known_hosts`. + Attachment paths are validated against allowed roots (`attachmentRoots` / `remoteAttachmentRoots`). @@ -151,6 +158,7 @@ imsg send "test" Group sender allowlist: `channels.imessage.groupAllowFrom`. Runtime fallback: if `groupAllowFrom` is unset, iMessage group sender checks fall back to `allowFrom` when available. + Runtime note: if `channels.imessage` is completely missing, runtime falls back to `groupPolicy="allowlist"` and logs a warning (even if `channels.defaults.groupPolicy` is set). Mention gating for groups: @@ -224,13 +232,14 @@ exec ssh -T bot@mac-mini.tailnet-1234.ts.net imsg "$@" ``` Use SSH keys so both SSH and SCP are non-interactive. + Ensure the host key is trusted first (for example `ssh bot@mac-mini.tailnet-1234.ts.net`) so `known_hosts` is populated. iMessage supports per-account config under `channels.imessage.accounts`. - Each account can override fields such as `cliPath`, `dbPath`, `allowFrom`, `groupPolicy`, `mediaMaxMb`, and history settings. + Each account can override fields such as `cliPath`, `dbPath`, `allowFrom`, `groupPolicy`, `mediaMaxMb`, history settings, and attachment root allowlists. @@ -241,6 +250,11 @@ exec ssh -T bot@mac-mini.tailnet-1234.ts.net imsg "$@" - inbound attachment ingestion is optional: `channels.imessage.includeAttachments` - remote attachment paths can be fetched via SCP when `remoteHost` is set + - attachment paths must match allowed roots: + - `channels.imessage.attachmentRoots` (local) + - `channels.imessage.remoteAttachmentRoots` (remote SCP mode) + - default root pattern: `/Users/*/Library/Messages/Attachments` + - SCP uses strict host-key checking (`StrictHostKeyChecking=yes`) - outbound media size uses `channels.imessage.mediaMaxMb` (default 16 MB) @@ -325,7 +339,9 @@ openclaw channels status --probe Check: - `channels.imessage.remoteHost` + - `channels.imessage.remoteAttachmentRoots` - SSH/SCP key auth from the gateway host + - host key exists in `~/.ssh/known_hosts` on the gateway host - remote path readability on the Mac running Messages diff --git a/docs/channels/index.md b/docs/channels/index.md index 181b8d080aa..f5ae8761852 100644 --- a/docs/channels/index.md +++ b/docs/channels/index.md @@ -25,6 +25,7 @@ Text is supported everywhere; media and reactions vary by channel. - [BlueBubbles](/channels/bluebubbles) — **Recommended for iMessage**; uses the BlueBubbles macOS server REST API with full feature support (edit, unsend, effects, reactions, group management — edit currently broken on macOS 26 Tahoe). - [iMessage (legacy)](/channels/imessage) — Legacy macOS integration via imsg CLI (deprecated, use BlueBubbles for new setups). - [Microsoft Teams](/channels/msteams) — Bot Framework; enterprise support (plugin, installed separately). +- [Synology Chat](/channels/synology-chat) — Synology NAS Chat via outgoing+incoming webhooks (plugin, installed separately). - [LINE](/channels/line) — LINE Messaging API bot (plugin, installed separately). - [Nextcloud Talk](/channels/nextcloud-talk) — Self-hosted chat via Nextcloud Talk (plugin, installed separately). - [Matrix](/channels/matrix) — Matrix protocol (plugin, installed separately). diff --git a/docs/channels/irc.md b/docs/channels/irc.md index 2bf6fb4eb4f..00403b6f92d 100644 --- a/docs/channels/irc.md +++ b/docs/channels/irc.md @@ -1,6 +1,10 @@ --- title: IRC description: Connect OpenClaw to IRC channels and direct messages. +summary: "IRC plugin setup, access controls, and troubleshooting" +read_when: + - You want to connect OpenClaw to IRC channels or DMs + - You are configuring IRC allowlists, group policy, or mention gating --- Use IRC when you want OpenClaw in classic channels (`#room`) and direct messages. @@ -53,7 +57,8 @@ Config keys: - Per-channel controls (channel + sender + mention rules): `channels.irc.groups["#channel"]` - `channels.irc.groupPolicy="open"` allows unconfigured channels (**still mention-gated by default**) -Allowlist entries can use nick or `nick!user@host` forms. +Allowlist entries should use stable sender identities (`nick!user@host`). +Bare nick matching is mutable and only enabled when `channels.irc.dangerouslyAllowNameMatching: true`. ### Common gotcha: `allowFrom` is for DMs, not channels @@ -159,7 +164,7 @@ Use `toolsBySender` to apply a stricter policy to `"*"` and a looser one to your "*": { deny: ["group:runtime", "group:fs", "gateway", "nodes", "cron", "browser"], }, - eigen: { + "id:eigen": { deny: ["gateway", "nodes", "cron"], }, }, @@ -172,7 +177,9 @@ Use `toolsBySender` to apply a stricter policy to `"*"` and a looser one to your Notes: -- `toolsBySender` keys can be a nick (e.g. `"eigen"`) or a full hostmask (`"eigen!~eigen@174.127.248.171"`) for stronger identity matching. +- `toolsBySender` keys should use `id:` for IRC sender identity values: + `id:eigen` or `id:eigen!~eigen@174.127.248.171` for stronger matching. +- Legacy unprefixed keys are still accepted and matched as `id:` only. - The first matching sender policy wins; `"*"` is the wildcard fallback. For more on group access vs mention-gating (and how they interact), see: [/channels/groups](/channels/groups). diff --git a/docs/channels/line.md b/docs/channels/line.md index d32e683fbeb..b87cbd3f5fb 100644 --- a/docs/channels/line.md +++ b/docs/channels/line.md @@ -118,6 +118,7 @@ Allowlists and policies: - `channels.line.groupPolicy`: `allowlist | open | disabled` - `channels.line.groupAllowFrom`: allowlisted LINE user IDs for groups - Per-group overrides: `channels.line.groups..allowFrom` +- Runtime note: if `channels.line` is completely missing, runtime falls back to `groupPolicy="allowlist"` for group checks (even if `channels.defaults.groupPolicy` is set). LINE IDs are case-sensitive. Valid IDs look like: diff --git a/docs/channels/matrix.md b/docs/channels/matrix.md index 04205d94971..9bb56d1ddb7 100644 --- a/docs/channels/matrix.md +++ b/docs/channels/matrix.md @@ -195,6 +195,7 @@ Notes: ## Rooms (groups) - Default: `channels.matrix.groupPolicy = "allowlist"` (mention-gated). Use `channels.defaults.groupPolicy` to override the default when unset. +- Runtime note: if `channels.matrix` is completely missing, runtime falls back to `groupPolicy="allowlist"` for room checks (even if `channels.defaults.groupPolicy` is set). - Allowlist rooms with `channels.matrix.groups` (room IDs or aliases; names are resolved to IDs when directory search finds a single exact match): ```json5 diff --git a/docs/channels/mattermost.md b/docs/channels/mattermost.md index f4353180e2a..702f72cc01f 100644 --- a/docs/channels/mattermost.md +++ b/docs/channels/mattermost.md @@ -101,8 +101,10 @@ Notes: ## Channels (groups) - Default: `channels.mattermost.groupPolicy = "allowlist"` (mention-gated). -- Allowlist senders with `channels.mattermost.groupAllowFrom` (user IDs or `@username`). +- Allowlist senders with `channels.mattermost.groupAllowFrom` (user IDs recommended). +- `@username` matching is mutable and only enabled when `channels.mattermost.dangerouslyAllowNameMatching: true`. - Open channels: `channels.mattermost.groupPolicy="open"` (mention-gated). +- Runtime note: if `channels.mattermost` is completely missing, runtime falls back to `groupPolicy="allowlist"` for group checks (even if `channels.defaults.groupPolicy` is set). ## Targets for outbound delivery @@ -114,6 +116,26 @@ Use these target formats with `openclaw message send` or cron/webhooks: Bare IDs are treated as channels. +## Reactions (message tool) + +- Use `message action=react` with `channel=mattermost`. +- `messageId` is the Mattermost post id. +- `emoji` accepts names like `thumbsup` or `:+1:` (colons are optional). +- Set `remove=true` (boolean) to remove a reaction. +- Reaction add/remove events are forwarded as system events to the routed agent session. + +Examples: + +``` +message action=react channel=mattermost target=channel: messageId= emoji=thumbsup +message action=react channel=mattermost target=channel: messageId= emoji=thumbsup remove=true +``` + +Config: + +- `channels.mattermost.actions.reactions`: enable/disable reaction actions (default true). +- Per-account override: `channels.mattermost.accounts..actions.reactions`. + ## Multi-account Mattermost supports multiple accounts under `channels.mattermost.accounts`: diff --git a/docs/channels/msteams.md b/docs/channels/msteams.md index 2232582610a..9c4a583e1b5 100644 --- a/docs/channels/msteams.md +++ b/docs/channels/msteams.md @@ -87,7 +87,9 @@ Disable with: **DM access** - Default: `channels.msteams.dmPolicy = "pairing"`. Unknown senders are ignored until approved. -- `channels.msteams.allowFrom` accepts AAD object IDs, UPNs, or display names. The wizard resolves names to IDs via Microsoft Graph when credentials allow. +- `channels.msteams.allowFrom` should use stable AAD object IDs. +- UPNs/display names are mutable; direct matching is disabled by default and only enabled with `channels.msteams.dangerouslyAllowNameMatching: true`. +- The wizard can resolve names to IDs via Microsoft Graph when credentials allow. **Group access** @@ -454,7 +456,8 @@ Key settings (see `/gateway/configuration` for shared channel patterns): - `channels.msteams.webhook.port` (default `3978`) - `channels.msteams.webhook.path` (default `/api/messages`) - `channels.msteams.dmPolicy`: `pairing | allowlist | open | disabled` (default: pairing) -- `channels.msteams.allowFrom`: allowlist for DMs (AAD object IDs, UPNs, or display names). The wizard resolves names to IDs during setup when Graph access is available. +- `channels.msteams.allowFrom`: DM allowlist (AAD object IDs recommended). The wizard resolves names to IDs during setup when Graph access is available. +- `channels.msteams.dangerouslyAllowNameMatching`: break-glass toggle to re-enable mutable UPN/display-name matching. - `channels.msteams.textChunkLimit`: outbound text chunk size. - `channels.msteams.chunkMode`: `length` (default) or `newline` to split on blank lines (paragraph boundaries) before length chunking. - `channels.msteams.mediaAllowHosts`: allowlist for inbound attachment hosts (defaults to Microsoft/Teams domains). @@ -469,6 +472,8 @@ Key settings (see `/gateway/configuration` for shared channel patterns): - `channels.msteams.teams..channels..requireMention`: per-channel override. - `channels.msteams.teams..channels..tools`: per-channel tool policy overrides (`allow`/`deny`/`alsoAllow`). - `channels.msteams.teams..channels..toolsBySender`: per-channel per-sender tool policy overrides (`"*"` wildcard supported). +- `toolsBySender` keys should use explicit prefixes: + `id:`, `e164:`, `username:`, `name:` (legacy unprefixed keys still map to `id:` only). - `channels.msteams.sharePointSiteId`: SharePoint site ID for file uploads in group chats/channels (see [Sending files in group chats](#sending-files-in-group-chats)). ## Routing & Sessions diff --git a/docs/channels/signal.md b/docs/channels/signal.md index 60bb5f7ce92..b216af120ce 100644 --- a/docs/channels/signal.md +++ b/docs/channels/signal.md @@ -195,6 +195,7 @@ Groups: - `channels.signal.groupPolicy = open | allowlist | disabled`. - `channels.signal.groupAllowFrom` controls who can trigger in groups when `allowlist` is set. +- Runtime note: if `channels.signal` is completely missing, runtime falls back to `groupPolicy="allowlist"` for group checks (even if `channels.defaults.groupPolicy` is set). ## How it works (behavior) diff --git a/docs/channels/slack.md b/docs/channels/slack.md index c4e95c21cf3..869df30ad99 100644 --- a/docs/channels/slack.md +++ b/docs/channels/slack.md @@ -165,12 +165,13 @@ For actions/directory reads, user token can be preferred when configured. For wr Channel allowlist lives under `channels.slack.channels`. - Runtime note: if `channels.slack` is completely missing (env-only setup) and `channels.defaults.groupPolicy` is unset, runtime falls back to `groupPolicy="open"` and logs a warning. + Runtime note: if `channels.slack` is completely missing (env-only setup), runtime falls back to `groupPolicy="allowlist"` and logs a warning (even if `channels.defaults.groupPolicy` is set). Name/ID resolution: - channel allowlist entries and DM allowlist entries are resolved at startup when token access allows - unresolved entries are kept as configured + - inbound authorization matching is ID-first by default; direct username/slug matching requires `channels.slack.dangerouslyAllowNameMatching: true` @@ -191,6 +192,8 @@ For actions/directory reads, user token can be preferred when configured. For wr - `skills` - `systemPrompt` - `tools`, `toolsBySender` + - `toolsBySender` key format: `id:`, `e164:`, `username:`, `name:`, or `"*"` wildcard + (legacy unprefixed keys still map to `id:` only) @@ -201,6 +204,12 @@ For actions/directory reads, user token can be preferred when configured. For wr - Enable native Slack command handlers with `channels.slack.commands.native: true` (or global `commands.native: true`). - When native commands are enabled, register matching slash commands in Slack (`/` names). - If native commands are not enabled, you can run a single configured slash command via `channels.slack.slashCommand`. +- Native arg menus now adapt their rendering strategy: + - up to 5 options: button blocks + - 6-100 options: static select menu + - more than 100 options: external select with async option filtering when interactivity options handlers are available + - if encoded option values exceed Slack limits, the flow falls back to buttons +- For long option payloads, Slash command argument menus use a confirm dialog before dispatching a selected value. Default slash command settings: @@ -235,7 +244,7 @@ Manual reply tags are supported: - `[[reply_to_current]]` - `[[reply_to:]]` -Note: `replyToMode="off"` disables implicit reply threading. Explicit `[[reply_to_*]]` tags are still honored. +Note: `replyToMode="off"` disables **all** reply threading in Slack, including explicit `[[reply_to_*]]` tags. This differs from Telegram, where explicit tags are still honored in `"off"` mode. The difference reflects the platform threading models: Slack threads hide messages from the channel, while Telegram replies remain visible in the main chat flow. ## Media, chunking, and delivery @@ -284,8 +293,12 @@ Available action groups in current Slack tooling: - Message edits/deletes/thread broadcasts are mapped into system events. - Reaction add/remove events are mapped into system events. - Member join/leave, channel created/renamed, and pin add/remove events are mapped into system events. +- Assistant thread status updates (for "is typing..." indicators in threads) use `assistant.threads.setStatus` and require bot scope `assistant:write`. - `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. +- 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 + - modal `view_submission` and `view_closed` events with routed channel metadata and form inputs ## Ack reactions @@ -342,6 +355,7 @@ Notes: "mpim:history", "users:read", "app_mentions:read", + "assistant:write", "reactions:read", "reactions:write", "pins:read", @@ -450,6 +464,47 @@ openclaw pairing list slack +## Text streaming + +OpenClaw supports Slack native text streaming via the Agents and AI Apps API. + +`channels.slack.streaming` controls live preview behavior: + +- `off`: disable live preview streaming. +- `partial` (default): replace preview text with the latest partial output. +- `block`: append chunked preview updates. +- `progress`: show progress status text while generating, then send final text. + +`channels.slack.nativeStreaming` controls Slack's native streaming API (`chat.startStream` / `chat.appendStream` / `chat.stopStream`) when `streaming` is `partial` (default: `true`). + +Disable native Slack streaming (keep draft preview behavior): + +```yaml +channels: + slack: + streaming: partial + nativeStreaming: false +``` + +Legacy keys: + +- `channels.slack.streamMode` (`replace | status_final | append`) is auto-migrated to `channels.slack.streaming`. +- boolean `channels.slack.streaming` is auto-migrated to `channels.slack.nativeStreaming`. + +### Requirements + +1. Enable **Agents and AI Apps** in your Slack app settings. +2. Ensure the app has the `assistant:write` scope. +3. A reply thread must be available for that message. Thread selection still follows `replyToMode`. + +### Behavior + +- First text chunk starts a stream (`chat.startStream`). +- Later text chunks append to the same stream (`chat.appendStream`). +- End of reply finalizes stream (`chat.stopStream`). +- Media and non-text payloads fall back to normal delivery. +- If streaming fails mid-reply, OpenClaw falls back to normal delivery for remaining payloads. + ## Configuration reference pointers Primary reference: @@ -459,9 +514,10 @@ Primary reference: High-signal Slack fields: - mode/auth: `mode`, `botToken`, `appToken`, `signingSecret`, `webhookPath`, `accounts.*` - DM access: `dm.enabled`, `dmPolicy`, `allowFrom` (legacy: `dm.policy`, `dm.allowFrom`), `dm.groupEnabled`, `dm.groupChannels` + - compatibility toggle: `dangerouslyAllowNameMatching` (break-glass; keep off unless needed) - channel access: `groupPolicy`, `channels.*`, `channels.*.users`, `channels.*.requireMention` - threading/history: `replyToMode`, `replyToModeByChatType`, `thread.*`, `historyLimit`, `dmHistoryLimit`, `dms.*.historyLimit` - - delivery: `textChunkLimit`, `chunkMode`, `mediaMaxMb` + - delivery: `textChunkLimit`, `chunkMode`, `mediaMaxMb`, `streaming`, `nativeStreaming` - ops/features: `configWrites`, `commands.native`, `slashCommand.*`, `actions.*`, `userToken`, `userTokenReadOnly` ## Related diff --git a/docs/channels/synology-chat.md b/docs/channels/synology-chat.md new file mode 100644 index 00000000000..78beff43bc4 --- /dev/null +++ b/docs/channels/synology-chat.md @@ -0,0 +1,127 @@ +--- +summary: "Synology Chat webhook setup and OpenClaw config" +read_when: + - Setting up Synology Chat with OpenClaw + - Debugging Synology Chat webhook routing +title: "Synology Chat" +--- + +# Synology Chat (plugin) + +Status: supported via plugin as a direct-message channel using Synology Chat webhooks. +The plugin accepts inbound messages from Synology Chat outgoing webhooks and sends replies +through a Synology Chat incoming webhook. + +## Plugin required + +Synology Chat is plugin-based and not part of the default core channel install. + +Install from a local checkout: + +```bash +openclaw plugins install ./extensions/synology-chat +``` + +Details: [Plugins](/tools/plugin) + +## Quick setup + +1. Install and enable the Synology Chat plugin. +2. In Synology Chat integrations: + - Create an incoming webhook and copy its URL. + - Create an outgoing webhook with your secret token. +3. Point the outgoing webhook URL to your OpenClaw gateway: + - `https://gateway-host/webhook/synology` by default. + - Or your custom `channels.synology-chat.webhookPath`. +4. Configure `channels.synology-chat` in OpenClaw. +5. Restart gateway and send a DM to the Synology Chat bot. + +Minimal config: + +```json5 +{ + channels: { + "synology-chat": { + enabled: true, + token: "synology-outgoing-token", + incomingUrl: "https://nas.example.com/webapi/entry.cgi?api=SYNO.Chat.External&method=incoming&version=2&token=...", + webhookPath: "/webhook/synology", + dmPolicy: "allowlist", + allowedUserIds: ["123456"], + rateLimitPerMinute: 30, + allowInsecureSsl: false, + }, + }, +} +``` + +## Environment variables + +For the default account, you can use env vars: + +- `SYNOLOGY_CHAT_TOKEN` +- `SYNOLOGY_CHAT_INCOMING_URL` +- `SYNOLOGY_NAS_HOST` +- `SYNOLOGY_ALLOWED_USER_IDS` (comma-separated) +- `SYNOLOGY_RATE_LIMIT` +- `OPENCLAW_BOT_NAME` + +Config values override env vars. + +## DM policy and access control + +- `dmPolicy: "allowlist"` is the recommended default. +- `allowedUserIds` accepts a list (or comma-separated string) of Synology user IDs. +- `dmPolicy: "open"` allows any sender. +- `dmPolicy: "disabled"` blocks DMs. +- Pairing approvals work with: + - `openclaw pairing list synology-chat` + - `openclaw pairing approve synology-chat ` + +## Outbound delivery + +Use numeric Synology Chat user IDs as targets. + +Examples: + +```bash +openclaw message send --channel synology-chat --target 123456 --text "Hello from OpenClaw" +openclaw message send --channel synology-chat --target synology-chat:123456 --text "Hello again" +``` + +Media sends are supported by URL-based file delivery. + +## Multi-account + +Multiple Synology Chat accounts are supported under `channels.synology-chat.accounts`. +Each account can override token, incoming URL, webhook path, DM policy, and limits. + +```json5 +{ + channels: { + "synology-chat": { + enabled: true, + accounts: { + default: { + token: "token-a", + incomingUrl: "https://nas-a.example.com/...token=...", + }, + alerts: { + token: "token-b", + incomingUrl: "https://nas-b.example.com/...token=...", + webhookPath: "/webhook/synology-alerts", + dmPolicy: "allowlist", + allowedUserIds: ["987654"], + }, + }, + }, + }, +} +``` + +## Security notes + +- Keep `token` secret and rotate it if leaked. +- Keep `allowInsecureSsl: false` unless you explicitly trust a self-signed local NAS cert. +- Inbound webhook requests are token-verified and rate-limited per sender. +- Prefer `dmPolicy: "allowlist"` for production. diff --git a/docs/channels/telegram.md b/docs/channels/telegram.md index 28a9c227f9d..6a454bd8dcf 100644 --- a/docs/channels/telegram.md +++ b/docs/channels/telegram.md @@ -47,6 +47,7 @@ Status: production-ready for bot DMs + groups via grammY. Long polling is the de ``` Env fallback: `TELEGRAM_BOT_TOKEN=...` (default account only). + Telegram does **not** use `openclaw channels login telegram`; configure token in config/env, then start gateway. @@ -148,6 +149,7 @@ curl "https://api.telegram.org/bot/getUpdates" `groupAllowFrom` is used for group sender filtering. If not set, Telegram falls back to `allowFrom`. `groupAllowFrom` entries must be numeric Telegram user IDs. + Runtime note: if `channels.telegram` is completely missing, runtime falls back to `groupPolicy="allowlist"` for group policy evaluation (even if `channels.defaults.groupPolicy` is set). Example: allow any member in one specific group: @@ -226,21 +228,9 @@ curl "https://api.telegram.org/bot/getUpdates" Requirement: - - `channels.telegram.streamMode` is not `"off"` (default: `"partial"`) - - Modes: - - - `off`: no live preview - - `partial`: frequent preview updates from partial text - - `block`: chunked preview updates using `channels.telegram.draftChunk` - - `draftChunk` defaults for `streamMode: "block"`: - - - `minChars: 200` - - `maxChars: 800` - - `breakPreference: "paragraph"` - - `maxChars` is clamped by `channels.telegram.textChunkLimit`. + - `channels.telegram.streaming` is `off | partial | block | progress` (default: `off`) + - `progress` maps to `partial` on Telegram (compat with cross-channel naming) + - legacy `channels.telegram.streamMode` and boolean `streaming` values are auto-mapped This works in direct chats and groups/topics. @@ -248,7 +238,7 @@ curl "https://api.telegram.org/bot/getUpdates" For complex replies (for example media payloads), OpenClaw falls back to normal final delivery and then cleans up the preview message. - `streamMode` is separate from block streaming. When block streaming is explicitly enabled for Telegram, OpenClaw skips the preview stream to avoid double-streaming. + Preview streaming is separate from block streaming. When block streaming is explicitly enabled for Telegram, OpenClaw skips the preview stream to avoid double-streaming. Telegram-only reasoning stream: @@ -682,6 +672,29 @@ openclaw message send --channel telegram --target @name --message "hi" - Node 22+ + custom fetch/proxy can trigger immediate abort behavior if AbortSignal types mismatch. - Some hosts resolve `api.telegram.org` to IPv6 first; broken IPv6 egress can cause intermittent Telegram API failures. + - If logs include `TypeError: fetch failed` or `Network request for 'getUpdates' failed!`, OpenClaw now retries these as recoverable network errors. + - On VPS hosts with unstable direct egress/TLS, route Telegram API calls through `channels.telegram.proxy`: + +```yaml +channels: + telegram: + proxy: socks5://user:pass@proxy-host:1080 +``` + + - Node 22+ defaults to `autoSelectFamily=true` (except WSL2) and `dnsResultOrder=ipv4first`. + - If your host is WSL2 or explicitly works better with IPv4-only behavior, force family selection: + +```yaml +channels: + telegram: + network: + autoSelectFamily: false +``` + + - Environment overrides (temporary): + - `OPENCLAW_TELEGRAM_DISABLE_AUTO_SELECT_FAMILY=1` + - `OPENCLAW_TELEGRAM_ENABLE_AUTO_SELECT_FAMILY=1` + - `OPENCLAW_TELEGRAM_DNS_RESULT_ORDER=ipv4first` - Validate DNS answers: ```bash @@ -721,10 +734,11 @@ Primary reference: - `channels.telegram.textChunkLimit`: outbound chunk size (chars). - `channels.telegram.chunkMode`: `length` (default) or `newline` to split on blank lines (paragraph boundaries) before length chunking. - `channels.telegram.linkPreview`: toggle link previews for outbound messages (default: true). -- `channels.telegram.streamMode`: `off | partial | block` (live stream preview). +- `channels.telegram.streaming`: `off | partial | block | progress` (live stream preview; default: `off`; `progress` maps to `partial`). - `channels.telegram.mediaMaxMb`: inbound/outbound media cap (MB). - `channels.telegram.retry`: retry policy for outbound Telegram API calls (attempts, minDelayMs, maxDelayMs, jitter). -- `channels.telegram.network.autoSelectFamily`: override Node autoSelectFamily (true=enable, false=disable). Defaults to disabled on Node 22 to avoid Happy Eyeballs timeouts. +- `channels.telegram.network.autoSelectFamily`: override Node autoSelectFamily (true=enable, false=disable). Defaults to enabled on Node 22+, with WSL2 defaulting to disabled. +- `channels.telegram.network.dnsResultOrder`: override DNS result order (`ipv4first` or `verbatim`). Defaults to `ipv4first` on Node 22+. - `channels.telegram.proxy`: proxy URL for Bot API calls (SOCKS/HTTP). - `channels.telegram.webhookUrl`: enable webhook mode (requires `channels.telegram.webhookSecret`). - `channels.telegram.webhookSecret`: webhook secret (required when webhookUrl is set). @@ -745,7 +759,7 @@ Telegram-specific high-signal fields: - access control: `dmPolicy`, `allowFrom`, `groupPolicy`, `groupAllowFrom`, `groups`, `groups.*.topics.*` - command/menu: `commands.native`, `customCommands` - threading/replies: `replyToMode` -- streaming: `streamMode` (preview), `draftChunk`, `blockStreaming` +- streaming: `streaming` (preview), `blockStreaming` - formatting/delivery: `textChunkLimit`, `chunkMode`, `linkPreview`, `responsePrefix` - media/network: `mediaMaxMb`, `timeoutSeconds`, `retry`, `network.autoSelectFamily`, `proxy` - webhook: `webhookUrl`, `webhookSecret`, `webhookPath`, `webhookHost` @@ -757,4 +771,5 @@ Telegram-specific high-signal fields: - [Pairing](/channels/pairing) - [Channel routing](/channels/channel-routing) +- [Multi-agent routing](/concepts/multi-agent) - [Troubleshooting](/channels/troubleshooting) diff --git a/docs/channels/whatsapp.md b/docs/channels/whatsapp.md index d14e38eb5d9..d92dfda9c75 100644 --- a/docs/channels/whatsapp.md +++ b/docs/channels/whatsapp.md @@ -169,8 +169,9 @@ OpenClaw recommends running WhatsApp on a separate number when possible. (The ch Sender allowlist fallback: - if `groupAllowFrom` is unset, runtime falls back to `allowFrom` when available + - sender allowlists are evaluated before mention/reply activation - Note: if no `channels.whatsapp` block exists at all, runtime group-policy fallback is effectively `open`. + Note: if no `channels.whatsapp` block exists at all, runtime group-policy fallback is `allowlist` (with a warning log), even if `channels.defaults.groupPolicy` is set. @@ -183,6 +184,11 @@ OpenClaw recommends running WhatsApp on a separate number when possible. (The ch - configured mention regex patterns (`agents.list[].groupChat.mentionPatterns`, fallback `messages.groupChat.mentionPatterns`) - implicit reply-to-bot detection (reply sender matches bot identity) + Security note: + + - quote/reply only satisfies mention gating; it does **not** grant sender authorization + - with `groupPolicy: "allowlist"`, non-allowlisted senders are still blocked even if they reply to an allowlisted user's message + Session-level activation command: - `/activation mention` @@ -407,6 +413,7 @@ Behavior notes: - `groupAllowFrom` / `allowFrom` - `groups` allowlist entries - mention gating (`requireMention` + mention patterns) + - duplicate keys in `openclaw.json` (JSON5): later entries override earlier ones, so keep a single `groupPolicy` per scope @@ -433,4 +440,5 @@ High-signal WhatsApp fields: - [Pairing](/channels/pairing) - [Channel routing](/channels/channel-routing) +- [Multi-agent routing](/concepts/multi-agent) - [Troubleshooting](/channels/troubleshooting) diff --git a/docs/channels/zalo.md b/docs/channels/zalo.md index c595c5e6dde..cda126f5649 100644 --- a/docs/channels/zalo.md +++ b/docs/channels/zalo.md @@ -115,6 +115,9 @@ Multi-account support: use `channels.zalo.accounts` with per-account tokens and - Webhook URL must use HTTPS. - Zalo sends events with `X-Bot-Api-Secret-Token` header for verification. - Gateway HTTP handles webhook requests at `channels.zalo.webhookPath` (defaults to the webhook URL path). + - Requests must use `Content-Type: application/json` (or `+json` media types). + - Duplicate events (`event_name + message_id`) are ignored for a short replay window. + - Burst traffic is rate-limited per path/source and may return HTTP 429. **Note:** getUpdates (polling) and webhook are mutually exclusive per Zalo API docs. diff --git a/docs/ci.md b/docs/ci.md index cdf5b126a28..51643c87001 100644 --- a/docs/ci.md +++ b/docs/ci.md @@ -1,6 +1,10 @@ --- title: CI Pipeline description: How the OpenClaw CI pipeline works +summary: "CI job graph, scope gates, and local command equivalents" +read_when: + - You need to understand why a CI job did or did not run + - You are debugging failing GitHub Actions checks --- # CI Pipeline @@ -34,12 +38,11 @@ Jobs are ordered so cheap checks fail before expensive ones run: ## Runners -| Runner | Jobs | -| ------------------------------- | ----------------------------- | -| `blacksmith-4vcpu-ubuntu-2404` | Most Linux jobs | -| `blacksmith-4vcpu-windows-2025` | `checks-windows` | -| `macos-latest` | `macos`, `ios` | -| `ubuntu-latest` | Scope detection (lightweight) | +| Runner | Jobs | +| -------------------------------- | ------------------------------------------ | +| `blacksmith-16vcpu-ubuntu-2404` | Most Linux jobs, including scope detection | +| `blacksmith-16vcpu-windows-2025` | `checks-windows` | +| `macos-latest` | `macos`, `ios` | ## Local Equivalents diff --git a/docs/cli/acp.md b/docs/cli/acp.md index 46b78cce6f5..1b1981395e4 100644 --- a/docs/cli/acp.md +++ b/docs/cli/acp.md @@ -21,6 +21,9 @@ openclaw acp # Remote Gateway openclaw acp --url wss://gateway-host:18789 --token +# Remote Gateway (token from file) +openclaw acp --url wss://gateway-host:18789 --token-file ~/.openclaw/gateway.token + # Attach to an existing session key openclaw acp --session agent:main:main @@ -40,12 +43,19 @@ It spawns the ACP bridge and lets you type prompts interactively. openclaw acp client # Point the spawned bridge at a remote Gateway -openclaw acp client --server-args --url wss://gateway-host:18789 --token +openclaw acp client --server-args --url wss://gateway-host:18789 --token-file ~/.openclaw/gateway.token # Override the server command (default: openclaw) openclaw acp client --server "node" --server-args openclaw.mjs acp --url ws://127.0.0.1:19001 ``` +Permission model (client debug mode): + +- Auto-approval is allowlist-based and only applies to trusted core tool IDs. +- `read` auto-approval is scoped to the current working directory (`--cwd` when set). +- Unknown/non-core tool names, out-of-scope reads, and dangerous tools always require explicit prompt approval. +- Server-provided `toolCall.kind` is treated as untrusted metadata (not an authorization source). + ## How to use this Use ACP when an IDE (or other client) speaks Agent Client Protocol and you want @@ -66,6 +76,8 @@ Example direct run (no config write): ```bash openclaw acp --url wss://gateway-host:18789 --token +# preferred for local process safety +openclaw acp --url wss://gateway-host:18789 --token-file ~/.openclaw/gateway.token ``` ## Selecting agents @@ -153,7 +165,9 @@ Learn more about session keys at [/concepts/session](/concepts/session). - `--url `: Gateway WebSocket URL (defaults to gateway.remote.url when configured). - `--token `: Gateway auth token. +- `--token-file `: read Gateway auth token from file. - `--password `: Gateway auth password. +- `--password-file `: read Gateway auth password from file. - `--session `: default session key. - `--session-label