Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3ad11051c3 | ||
|
|
3b6b8e7696 | ||
|
|
f1b023dc6c |
No files matched your search
@@ -1,26 +0,0 @@
|
|||||||
# detect-secrets exclusion patterns (regex)
|
|
||||||
#
|
|
||||||
# Note: detect-secrets does not read this file by default. If you want these
|
|
||||||
# applied, wire them into your scan command (e.g. translate to --exclude-files
|
|
||||||
# / --exclude-lines) or into a baseline's filters_used.
|
|
||||||
|
|
||||||
[exclude-files]
|
|
||||||
# pnpm lockfiles contain lots of high-entropy package integrity blobs.
|
|
||||||
pattern = (^|/)pnpm-lock\.yaml$
|
|
||||||
|
|
||||||
[exclude-lines]
|
|
||||||
# Fastlane checks for private key marker; not a real key.
|
|
||||||
pattern = key_content\.include\?\("BEGIN PRIVATE KEY"\)
|
|
||||||
# UI label string for Anthropic auth mode.
|
|
||||||
pattern = case \.apiKeyEnv: "API key \(env var\)"
|
|
||||||
# CodingKeys mapping uses apiKey literal.
|
|
||||||
pattern = case apikey = "apiKey"
|
|
||||||
# Schema labels referencing password fields (not actual secrets).
|
|
||||||
pattern = "gateway\.remote\.password"
|
|
||||||
pattern = "gateway\.auth\.password"
|
|
||||||
# Schema label for talk API key (label text only).
|
|
||||||
pattern = "talk\.apiKey"
|
|
||||||
# checking for typeof is not something we care about.
|
|
||||||
pattern = === "string"
|
|
||||||
# specific optional-chaining password check that didn't match the line above.
|
|
||||||
pattern = typeof remote\?\.password === "string"
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
* text=auto eol=lf
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
---
|
|
||||||
name: Bug report
|
|
||||||
about: Report a problem or unexpected behavior in Clawdbot.
|
|
||||||
title: "[Bug]: "
|
|
||||||
labels: bug
|
|
||||||
---
|
|
||||||
|
|
||||||
## Summary
|
|
||||||
What went wrong?
|
|
||||||
|
|
||||||
## Steps to reproduce
|
|
||||||
1.
|
|
||||||
2.
|
|
||||||
3.
|
|
||||||
|
|
||||||
## Expected behavior
|
|
||||||
What did you expect to happen?
|
|
||||||
|
|
||||||
## Actual behavior
|
|
||||||
What actually happened?
|
|
||||||
|
|
||||||
## Environment
|
|
||||||
- Clawdbot version:
|
|
||||||
- OS:
|
|
||||||
- Install method (pnpm/npx/docker/etc):
|
|
||||||
|
|
||||||
## Logs or screenshots
|
|
||||||
Paste relevant logs or add screenshots (redact secrets).
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
blank_issues_enabled: true
|
|
||||||
contact_links:
|
|
||||||
- name: Onboarding
|
|
||||||
url: https://discord.gg/clawd
|
|
||||||
about: New to Clawdbot? Join Discord for setup guidance from Krill in #help.
|
|
||||||
- name: Support
|
|
||||||
url: https://discord.gg/clawd
|
|
||||||
about: Get help from Krill and the community on Discord in #help.
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
---
|
|
||||||
name: Feature request
|
|
||||||
about: Suggest an idea or improvement for Clawdbot.
|
|
||||||
title: "[Feature]: "
|
|
||||||
labels: enhancement
|
|
||||||
---
|
|
||||||
|
|
||||||
## Summary
|
|
||||||
Describe the problem you are trying to solve or the opportunity you see.
|
|
||||||
|
|
||||||
## Proposed solution
|
|
||||||
What would you like Clawdbot to do?
|
|
||||||
|
|
||||||
## Alternatives considered
|
|
||||||
Any other approaches you have considered?
|
|
||||||
|
|
||||||
## Additional context
|
|
||||||
Links, screenshots, or related issues.
|
|
||||||
@@ -5,59 +5,8 @@ on:
|
|||||||
pull_request:
|
pull_request:
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
install-check:
|
|
||||||
runs-on: blacksmith-4vcpu-ubuntu-2404
|
|
||||||
steps:
|
|
||||||
- name: Checkout
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
with:
|
|
||||||
submodules: false
|
|
||||||
|
|
||||||
- name: Checkout submodules (retry)
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
git submodule sync --recursive
|
|
||||||
for attempt in 1 2 3 4 5; do
|
|
||||||
if git -c protocol.version=2 submodule update --init --force --depth=1 --recursive; then
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
echo "Submodule update failed (attempt $attempt/5). Retrying…"
|
|
||||||
sleep $((attempt * 10))
|
|
||||||
done
|
|
||||||
exit 1
|
|
||||||
|
|
||||||
- name: Setup Node.js
|
|
||||||
uses: actions/setup-node@v4
|
|
||||||
with:
|
|
||||||
node-version: 22.x
|
|
||||||
check-latest: true
|
|
||||||
|
|
||||||
- name: Runtime versions
|
|
||||||
run: |
|
|
||||||
node -v
|
|
||||||
npm -v
|
|
||||||
|
|
||||||
- name: Capture node path
|
|
||||||
run: echo "NODE_BIN=$(dirname \"$(node -p \"process.execPath\")\")" >> "$GITHUB_ENV"
|
|
||||||
|
|
||||||
- name: Enable corepack and pin pnpm
|
|
||||||
run: |
|
|
||||||
corepack enable
|
|
||||||
corepack prepare pnpm@10.23.0 --activate
|
|
||||||
pnpm -v
|
|
||||||
|
|
||||||
- name: Install dependencies (frozen)
|
|
||||||
env:
|
|
||||||
CI: true
|
|
||||||
run: |
|
|
||||||
export PATH="$NODE_BIN:$PATH"
|
|
||||||
which node
|
|
||||||
node -v
|
|
||||||
pnpm -v
|
|
||||||
pnpm install --frozen-lockfile --ignore-scripts=false --config.engine-strict=false --config.enable-pre-post-scripts=true
|
|
||||||
|
|
||||||
checks:
|
checks:
|
||||||
runs-on: blacksmith-4vcpu-ubuntu-2404
|
runs-on: ubuntu-latest
|
||||||
strategy:
|
strategy:
|
||||||
fail-fast: false
|
fail-fast: false
|
||||||
matrix:
|
matrix:
|
||||||
@@ -74,9 +23,9 @@ jobs:
|
|||||||
- runtime: node
|
- runtime: node
|
||||||
task: protocol
|
task: protocol
|
||||||
command: pnpm protocol:check
|
command: pnpm protocol:check
|
||||||
- runtime: node
|
- runtime: bun
|
||||||
task: format
|
task: lint
|
||||||
command: pnpm format
|
command: bunx biome check src
|
||||||
- runtime: bun
|
- runtime: bun
|
||||||
task: test
|
task: test
|
||||||
command: bunx vitest run
|
command: bunx vitest run
|
||||||
@@ -103,186 +52,32 @@ jobs:
|
|||||||
exit 1
|
exit 1
|
||||||
|
|
||||||
- name: Setup Node.js
|
- name: Setup Node.js
|
||||||
|
if: matrix.runtime == 'node'
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: 22.x
|
node-version: 24
|
||||||
check-latest: true
|
check-latest: true
|
||||||
|
|
||||||
- name: Setup Bun
|
- name: Setup Bun
|
||||||
|
if: matrix.runtime == 'bun'
|
||||||
uses: oven-sh/setup-bun@v2
|
uses: oven-sh/setup-bun@v2
|
||||||
with:
|
with:
|
||||||
bun-version: latest
|
# bun.sh downloads currently fail with:
|
||||||
|
# "Failed to list releases from GitHub: 401" -> "Unexpected HTTP response: 400"
|
||||||
|
bun-download-url: "https://github.com/oven-sh/bun/releases/latest/download/bun-linux-x64.zip"
|
||||||
|
|
||||||
- name: Runtime versions
|
- name: Setup Node.js (tooling for bun)
|
||||||
run: |
|
if: matrix.runtime == 'bun'
|
||||||
node -v
|
|
||||||
npm -v
|
|
||||||
bun -v
|
|
||||||
|
|
||||||
- name: Capture node path
|
|
||||||
run: echo "NODE_BIN=$(dirname \"$(node -p \"process.execPath\")\")" >> "$GITHUB_ENV"
|
|
||||||
|
|
||||||
- name: Enable corepack and pin pnpm
|
|
||||||
run: |
|
|
||||||
corepack enable
|
|
||||||
corepack prepare pnpm@10.23.0 --activate
|
|
||||||
pnpm -v
|
|
||||||
|
|
||||||
- name: Install dependencies
|
|
||||||
env:
|
|
||||||
CI: true
|
|
||||||
run: |
|
|
||||||
export PATH="$NODE_BIN:$PATH"
|
|
||||||
which node
|
|
||||||
node -v
|
|
||||||
pnpm -v
|
|
||||||
pnpm install --frozen-lockfile --ignore-scripts=false --config.engine-strict=false --config.enable-pre-post-scripts=true || pnpm install --frozen-lockfile --ignore-scripts=false --config.engine-strict=false --config.enable-pre-post-scripts=true
|
|
||||||
|
|
||||||
- name: Run ${{ matrix.task }} (${{ matrix.runtime }})
|
|
||||||
run: ${{ matrix.command }}
|
|
||||||
|
|
||||||
secrets:
|
|
||||||
runs-on: blacksmith-4vcpu-ubuntu-2404
|
|
||||||
steps:
|
|
||||||
- name: Checkout
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
with:
|
|
||||||
submodules: false
|
|
||||||
|
|
||||||
- name: Setup Python
|
|
||||||
uses: actions/setup-python@v5
|
|
||||||
with:
|
|
||||||
python-version: "3.12"
|
|
||||||
|
|
||||||
- name: Install detect-secrets
|
|
||||||
run: |
|
|
||||||
python -m pip install --upgrade pip
|
|
||||||
python -m pip install detect-secrets==1.5.0
|
|
||||||
|
|
||||||
- name: Detect secrets
|
|
||||||
run: |
|
|
||||||
if ! detect-secrets scan --baseline .secrets.baseline; then
|
|
||||||
echo "::error::Secret scanning failed. See docs/gateway/security.md#secret-scanning-detect-secrets"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
checks-windows:
|
|
||||||
runs-on: blacksmith-4vcpu-windows-2025
|
|
||||||
defaults:
|
|
||||||
run:
|
|
||||||
shell: bash
|
|
||||||
strategy:
|
|
||||||
fail-fast: false
|
|
||||||
matrix:
|
|
||||||
include:
|
|
||||||
- runtime: node
|
|
||||||
task: lint
|
|
||||||
command: pnpm lint
|
|
||||||
- runtime: node
|
|
||||||
task: test
|
|
||||||
command: pnpm test
|
|
||||||
- runtime: node
|
|
||||||
task: build
|
|
||||||
command: pnpm build
|
|
||||||
- runtime: node
|
|
||||||
task: protocol
|
|
||||||
command: pnpm protocol:check
|
|
||||||
steps:
|
|
||||||
- name: Checkout
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
with:
|
|
||||||
submodules: false
|
|
||||||
|
|
||||||
- name: Checkout submodules (retry)
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
git submodule sync --recursive
|
|
||||||
for attempt in 1 2 3 4 5; do
|
|
||||||
if git -c protocol.version=2 submodule update --init --force --depth=1 --recursive; then
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
echo "Submodule update failed (attempt $attempt/5). Retrying…"
|
|
||||||
sleep $((attempt * 10))
|
|
||||||
done
|
|
||||||
exit 1
|
|
||||||
|
|
||||||
- name: Setup Node.js
|
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: 22.x
|
node-version: 24
|
||||||
check-latest: true
|
|
||||||
|
|
||||||
- name: Setup Bun
|
|
||||||
uses: oven-sh/setup-bun@v2
|
|
||||||
with:
|
|
||||||
bun-version: latest
|
|
||||||
|
|
||||||
- name: Runtime versions
|
|
||||||
run: |
|
|
||||||
node -v
|
|
||||||
npm -v
|
|
||||||
bun -v
|
|
||||||
|
|
||||||
- name: Capture node path
|
|
||||||
run: echo "NODE_BIN=$(dirname \"$(node -p \"process.execPath\")\")" >> "$GITHUB_ENV"
|
|
||||||
|
|
||||||
- name: Enable corepack and pin pnpm
|
|
||||||
run: |
|
|
||||||
corepack enable
|
|
||||||
corepack prepare pnpm@10.23.0 --activate
|
|
||||||
pnpm -v
|
|
||||||
|
|
||||||
- name: Install dependencies
|
|
||||||
env:
|
|
||||||
CI: true
|
|
||||||
run: |
|
|
||||||
export PATH="$NODE_BIN:$PATH"
|
|
||||||
which node
|
|
||||||
node -v
|
|
||||||
pnpm -v
|
|
||||||
pnpm install --frozen-lockfile --ignore-scripts=false --config.engine-strict=false --config.enable-pre-post-scripts=true || pnpm install --frozen-lockfile --ignore-scripts=false --config.engine-strict=false --config.enable-pre-post-scripts=true
|
|
||||||
|
|
||||||
- name: Run ${{ matrix.task }} (${{ matrix.runtime }})
|
|
||||||
run: ${{ matrix.command }}
|
|
||||||
|
|
||||||
checks-macos:
|
|
||||||
if: github.event_name == 'pull_request'
|
|
||||||
runs-on: macos-latest
|
|
||||||
strategy:
|
|
||||||
fail-fast: false
|
|
||||||
matrix:
|
|
||||||
include:
|
|
||||||
- task: test
|
|
||||||
command: pnpm test
|
|
||||||
steps:
|
|
||||||
- name: Checkout
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
with:
|
|
||||||
submodules: false
|
|
||||||
|
|
||||||
- name: Checkout submodules (retry)
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
git submodule sync --recursive
|
|
||||||
for attempt in 1 2 3 4 5; do
|
|
||||||
if git -c protocol.version=2 submodule update --init --force --depth=1 --recursive; then
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
echo "Submodule update failed (attempt $attempt/5). Retrying…"
|
|
||||||
sleep $((attempt * 10))
|
|
||||||
done
|
|
||||||
exit 1
|
|
||||||
|
|
||||||
- name: Setup Node.js
|
|
||||||
uses: actions/setup-node@v4
|
|
||||||
with:
|
|
||||||
node-version: 22.x
|
|
||||||
check-latest: true
|
check-latest: true
|
||||||
|
|
||||||
- name: Runtime versions
|
- name: Runtime versions
|
||||||
run: |
|
run: |
|
||||||
node -v
|
node -v
|
||||||
npm -v
|
npm -v
|
||||||
|
if [ "${{ matrix.runtime }}" = "bun" ]; then bun -v; fi
|
||||||
|
|
||||||
- name: Capture node path
|
- name: Capture node path
|
||||||
run: echo "NODE_BIN=$(dirname \"$(node -p \"process.execPath\")\")" >> "$GITHUB_ENV"
|
run: echo "NODE_BIN=$(dirname \"$(node -p \"process.execPath\")\")" >> "$GITHUB_ENV"
|
||||||
@@ -301,9 +96,9 @@ jobs:
|
|||||||
which node
|
which node
|
||||||
node -v
|
node -v
|
||||||
pnpm -v
|
pnpm -v
|
||||||
pnpm install --frozen-lockfile --ignore-scripts=false --config.engine-strict=false --config.enable-pre-post-scripts=true || pnpm install --frozen-lockfile --ignore-scripts=false --config.engine-strict=false --config.enable-pre-post-scripts=true
|
pnpm install --ignore-scripts=false --config.engine-strict=false --config.enable-pre-post-scripts=true || pnpm install --ignore-scripts=false --config.engine-strict=false --config.enable-pre-post-scripts=true
|
||||||
|
|
||||||
- name: Run ${{ matrix.task }}
|
- name: Run ${{ matrix.task }} (${{ matrix.runtime }})
|
||||||
run: ${{ matrix.command }}
|
run: ${{ matrix.command }}
|
||||||
|
|
||||||
macos-app:
|
macos-app:
|
||||||
@@ -549,7 +344,7 @@ jobs:
|
|||||||
PY
|
PY
|
||||||
|
|
||||||
android:
|
android:
|
||||||
runs-on: blacksmith-4vcpu-ubuntu-2404
|
runs-on: ubuntu-latest
|
||||||
strategy:
|
strategy:
|
||||||
fail-fast: false
|
fail-fast: false
|
||||||
matrix:
|
matrix:
|
||||||
@@ -585,8 +380,6 @@ jobs:
|
|||||||
|
|
||||||
- name: Setup Android SDK
|
- name: Setup Android SDK
|
||||||
uses: android-actions/setup-android@v3
|
uses: android-actions/setup-android@v3
|
||||||
with:
|
|
||||||
accept-android-sdk-licenses: false
|
|
||||||
|
|
||||||
- name: Setup Gradle
|
- name: Setup Gradle
|
||||||
uses: gradle/actions/setup-gradle@v4
|
uses: gradle/actions/setup-gradle@v4
|
||||||
|
|||||||
@@ -1,33 +0,0 @@
|
|||||||
name: Install Smoke
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches: [main]
|
|
||||||
pull_request:
|
|
||||||
workflow_dispatch:
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
install-smoke:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- name: Checkout CLI
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Setup pnpm
|
|
||||||
uses: pnpm/action-setup@v3
|
|
||||||
with:
|
|
||||||
version: 10
|
|
||||||
- name: Enable Corepack
|
|
||||||
run: corepack enable
|
|
||||||
|
|
||||||
- name: Install pnpm deps (minimal)
|
|
||||||
run: pnpm install --ignore-scripts --frozen-lockfile
|
|
||||||
|
|
||||||
- name: Run installer docker tests
|
|
||||||
env:
|
|
||||||
CLAWDBOT_INSTALL_URL: https://clawd.bot/install.sh
|
|
||||||
CLAWDBOT_INSTALL_CLI_URL: https://clawd.bot/install-cli.sh
|
|
||||||
CLAWDBOT_NO_ONBOARD: "1"
|
|
||||||
CLAWDBOT_INSTALL_SMOKE_SKIP_CLI: "1"
|
|
||||||
CLAWDBOT_INSTALL_SMOKE_PREVIOUS: "2026.1.11-4"
|
|
||||||
run: pnpm test:install:smoke
|
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
name: Workflow Sanity
|
|
||||||
|
|
||||||
on:
|
|
||||||
pull_request:
|
|
||||||
push:
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
no-tabs:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- name: Checkout
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Fail on tabs in workflow files
|
|
||||||
run: |
|
|
||||||
python - <<'PY'
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import pathlib
|
|
||||||
import sys
|
|
||||||
|
|
||||||
root = pathlib.Path(".github/workflows")
|
|
||||||
bad: list[str] = []
|
|
||||||
for path in sorted(root.rglob("*.yml")):
|
|
||||||
if b"\t" in path.read_bytes():
|
|
||||||
bad.append(str(path))
|
|
||||||
|
|
||||||
for path in sorted(root.rglob("*.yaml")):
|
|
||||||
if b"\t" in path.read_bytes():
|
|
||||||
bad.append(str(path))
|
|
||||||
|
|
||||||
if bad:
|
|
||||||
print("Tabs found in workflow file(s):")
|
|
||||||
for path in bad:
|
|
||||||
print(f"- {path}")
|
|
||||||
sys.exit(1)
|
|
||||||
PY
|
|
||||||
@@ -1,12 +1,8 @@
|
|||||||
node_modules
|
node_modules
|
||||||
**/node_modules/
|
|
||||||
.env
|
.env
|
||||||
docker-compose.extra.yml
|
|
||||||
dist
|
dist
|
||||||
*.bun-build
|
*.bun-build
|
||||||
pnpm-lock.yaml
|
pnpm-lock.yaml
|
||||||
bun.lock
|
|
||||||
bun.lockb
|
|
||||||
coverage
|
coverage
|
||||||
.pnpm-store
|
.pnpm-store
|
||||||
.worktrees/
|
.worktrees/
|
||||||
@@ -52,10 +48,3 @@ apps/ios/*.dSYM.zip
|
|||||||
# provisioning profiles (local)
|
# provisioning profiles (local)
|
||||||
apps/ios/*.mobileprovision
|
apps/ios/*.mobileprovision
|
||||||
.env
|
.env
|
||||||
|
|
||||||
# Local untracked files
|
|
||||||
.local/
|
|
||||||
.vscode/
|
|
||||||
IDENTITY.md
|
|
||||||
USER.md
|
|
||||||
.tgz
|
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
[submodule "Peekaboo"]
|
||||||
|
path = Peekaboo
|
||||||
|
url = https://github.com/steipete/Peekaboo.git
|
||||||
|
branch = main
|
||||||
@@ -1 +1 @@
|
|||||||
allow-build-scripts=@whiskeysockets/baileys,sharp,esbuild,protobufjs,fs-ext,node-pty,@lydell/node-pty
|
allow-build-scripts=@whiskeysockets/baileys,sharp,esbuild,protobufjs,fs-ext,node-pty
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
{
|
|
||||||
"$schema": "./node_modules/oxfmt/configuration_schema.json",
|
|
||||||
"indentWidth": 2,
|
|
||||||
"printWidth": 100
|
|
||||||
}
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
{
|
|
||||||
"$schema": "./node_modules/oxlint/configuration_schema.json",
|
|
||||||
"plugins": [
|
|
||||||
"unicorn",
|
|
||||||
"typescript",
|
|
||||||
"oxc"
|
|
||||||
],
|
|
||||||
"categories": {
|
|
||||||
"correctness": "error"
|
|
||||||
},
|
|
||||||
"ignorePatterns": ["src/canvas-host/a2ui/a2ui.bundle.js"]
|
|
||||||
}
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
src/canvas-host/a2ui/a2ui.bundle.js
|
|
||||||
@@ -1,518 +0,0 @@
|
|||||||
{
|
|
||||||
"version": "1.5.0",
|
|
||||||
"plugins_used": [
|
|
||||||
{
|
|
||||||
"name": "ArtifactoryDetector"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "AWSKeyDetector"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "AzureStorageKeyDetector"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "Base64HighEntropyString",
|
|
||||||
"limit": 4.5
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "BasicAuthDetector"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "CloudantDetector"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "DiscordBotTokenDetector"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "GitHubTokenDetector"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "GitLabTokenDetector"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "HexHighEntropyString",
|
|
||||||
"limit": 3.0
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "IbmCloudIamDetector"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "IbmCosHmacDetector"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "IPPublicDetector"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "JwtTokenDetector"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "KeywordDetector",
|
|
||||||
"keyword_exclude": ""
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "MailchimpDetector"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "NpmDetector"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "OpenAIDetector"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "PrivateKeyDetector"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "PypiTokenDetector"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "SendGridDetector"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "SlackDetector"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "SoftlayerDetector"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "SquareOAuthDetector"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "StripeDetector"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "TelegramBotTokenDetector"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "TwilioKeyDetector"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"filters_used": [
|
|
||||||
{
|
|
||||||
"path": "detect_secrets.filters.allowlist.is_line_allowlisted"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"path": "detect_secrets.filters.common.is_baseline_file",
|
|
||||||
"filename": ".secrets.baseline"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"path": "detect_secrets.filters.common.is_ignored_due_to_verification_policies",
|
|
||||||
"min_level": 2
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"path": "detect_secrets.filters.heuristic.is_indirect_reference"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"path": "detect_secrets.filters.heuristic.is_likely_id_string"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"path": "detect_secrets.filters.heuristic.is_lock_file"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"path": "detect_secrets.filters.heuristic.is_not_alphanumeric_string"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"path": "detect_secrets.filters.heuristic.is_potential_uuid"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"path": "detect_secrets.filters.heuristic.is_prefixed_with_dollar_sign"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"path": "detect_secrets.filters.heuristic.is_sequential_string"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"path": "detect_secrets.filters.heuristic.is_swagger_file"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"path": "detect_secrets.filters.heuristic.is_templated_secret"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"path": "detect_secrets.filters.regex.should_exclude_file",
|
|
||||||
"pattern": [
|
|
||||||
"(^|/)pnpm-lock\\.yaml$"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"path": "detect_secrets.filters.regex.should_exclude_line",
|
|
||||||
"pattern": [
|
|
||||||
"key_content\\.include\\?\\(\"BEGIN PRIVATE KEY\"\\)",
|
|
||||||
"case \\.apiKeyEnv: \"API key \\(env var\\)\"",
|
|
||||||
"case apikey = \"apiKey\"",
|
|
||||||
"\"gateway\\.remote\\.password\"",
|
|
||||||
"\"gateway\\.auth\\.password\"",
|
|
||||||
"\"talk\\.apiKey\"",
|
|
||||||
"=== \"string\"",
|
|
||||||
"typeof remote\\?\\.password === \"string\""
|
|
||||||
]
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"results": {
|
|
||||||
".env.example": [
|
|
||||||
{
|
|
||||||
"type": "Twilio API Key",
|
|
||||||
"filename": ".env.example",
|
|
||||||
"hashed_secret": "3c7206eff845bc69cf12d904d0f95f9aec15535e",
|
|
||||||
"is_verified": false,
|
|
||||||
"line_number": 2
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"appcast.xml": [
|
|
||||||
{
|
|
||||||
"type": "Base64 High Entropy String",
|
|
||||||
"filename": "appcast.xml",
|
|
||||||
"hashed_secret": "1b1c2b73eca84e441a823c37a06c71c9fadcfe24",
|
|
||||||
"is_verified": false,
|
|
||||||
"line_number": 19
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "Base64 High Entropy String",
|
|
||||||
"filename": "appcast.xml",
|
|
||||||
"hashed_secret": "5c47736fee5151b26b3bb61bb38955da0e8937c6",
|
|
||||||
"is_verified": false,
|
|
||||||
"line_number": 35
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "Base64 High Entropy String",
|
|
||||||
"filename": "appcast.xml",
|
|
||||||
"hashed_secret": "bbbca47179268f154c63affa0ca441c6e49e650f",
|
|
||||||
"is_verified": false,
|
|
||||||
"line_number": 52
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"apps/macos/Tests/ClawdbotIPCTests/AnthropicAuthResolverTests.swift": [
|
|
||||||
{
|
|
||||||
"type": "Secret Keyword",
|
|
||||||
"filename": "apps/macos/Tests/ClawdbotIPCTests/AnthropicAuthResolverTests.swift",
|
|
||||||
"hashed_secret": "e761624445731fcb8b15da94343c6b92e507d190",
|
|
||||||
"is_verified": false,
|
|
||||||
"line_number": 26
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "Secret Keyword",
|
|
||||||
"filename": "apps/macos/Tests/ClawdbotIPCTests/AnthropicAuthResolverTests.swift",
|
|
||||||
"hashed_secret": "a23c8630c8a5fbaa21f095e0269c135c20d21689",
|
|
||||||
"is_verified": false,
|
|
||||||
"line_number": 42
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"apps/macos/Tests/ClawdbotIPCTests/ConnectionsSettingsSmokeTests.swift": [
|
|
||||||
{
|
|
||||||
"type": "Secret Keyword",
|
|
||||||
"filename": "apps/macos/Tests/ClawdbotIPCTests/ConnectionsSettingsSmokeTests.swift",
|
|
||||||
"hashed_secret": "e5e9fa1ba31ecd1ae84f75caaa474f3a663f05f4",
|
|
||||||
"is_verified": false,
|
|
||||||
"line_number": 83
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"apps/macos/Tests/ClawdbotIPCTests/TailscaleIntegrationSectionTests.swift": [
|
|
||||||
{
|
|
||||||
"type": "Secret Keyword",
|
|
||||||
"filename": "apps/macos/Tests/ClawdbotIPCTests/TailscaleIntegrationSectionTests.swift",
|
|
||||||
"hashed_secret": "e5e9fa1ba31ecd1ae84f75caaa474f3a663f05f4",
|
|
||||||
"is_verified": false,
|
|
||||||
"line_number": 27
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"docs/configuration.md": [
|
|
||||||
{
|
|
||||||
"type": "Secret Keyword",
|
|
||||||
"filename": "docs/configuration.md",
|
|
||||||
"hashed_secret": "e5e9fa1ba31ecd1ae84f75caaa474f3a663f05f4",
|
|
||||||
"is_verified": false,
|
|
||||||
"line_number": 268
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "Secret Keyword",
|
|
||||||
"filename": "docs/configuration.md",
|
|
||||||
"hashed_secret": "1188d5a8ed7edcff5144a9472af960243eacf12e",
|
|
||||||
"is_verified": false,
|
|
||||||
"line_number": 465
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "Secret Keyword",
|
|
||||||
"filename": "docs/configuration.md",
|
|
||||||
"hashed_secret": "22af290a1a3d5e941193a41a3d3a9e4ca8da5e27",
|
|
||||||
"is_verified": false,
|
|
||||||
"line_number": 718
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "Secret Keyword",
|
|
||||||
"filename": "docs/configuration.md",
|
|
||||||
"hashed_secret": "16c249e04e2be318050cb883c40137361c0c7209",
|
|
||||||
"is_verified": false,
|
|
||||||
"line_number": 760
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "Secret Keyword",
|
|
||||||
"filename": "docs/configuration.md",
|
|
||||||
"hashed_secret": "c1e6ee547fd492df1441ac492e8bb294974712bd",
|
|
||||||
"is_verified": false,
|
|
||||||
"line_number": 859
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "Secret Keyword",
|
|
||||||
"filename": "docs/configuration.md",
|
|
||||||
"hashed_secret": "45d676e7c6ab44cf4b8fa366ef2d8fccd3e6d6e6",
|
|
||||||
"is_verified": false,
|
|
||||||
"line_number": 982
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"docs/faq.md": [
|
|
||||||
{
|
|
||||||
"type": "Secret Keyword",
|
|
||||||
"filename": "docs/faq.md",
|
|
||||||
"hashed_secret": "a219d7693c25cd2d93313512e200ff3eb374d281",
|
|
||||||
"is_verified": false,
|
|
||||||
"line_number": 593
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "Secret Keyword",
|
|
||||||
"filename": "docs/faq.md",
|
|
||||||
"hashed_secret": "ec3810e10fb78db55ce38b9c18d1c3eb1db739e0",
|
|
||||||
"is_verified": false,
|
|
||||||
"line_number": 650
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"docs/skills-config.md": [
|
|
||||||
{
|
|
||||||
"type": "Secret Keyword",
|
|
||||||
"filename": "docs/skills-config.md",
|
|
||||||
"hashed_secret": "c1e6ee547fd492df1441ac492e8bb294974712bd",
|
|
||||||
"is_verified": false,
|
|
||||||
"line_number": 28
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"docs/skills.md": [
|
|
||||||
{
|
|
||||||
"type": "Secret Keyword",
|
|
||||||
"filename": "docs/skills.md",
|
|
||||||
"hashed_secret": "c1e6ee547fd492df1441ac492e8bb294974712bd",
|
|
||||||
"is_verified": false,
|
|
||||||
"line_number": 97
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"docs/tailscale.md": [
|
|
||||||
{
|
|
||||||
"type": "Secret Keyword",
|
|
||||||
"filename": "docs/tailscale.md",
|
|
||||||
"hashed_secret": "9cb0dc5383312aa15b9dc6745645bde18ff5ade9",
|
|
||||||
"is_verified": false,
|
|
||||||
"line_number": 52
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"docs/talk.md": [
|
|
||||||
{
|
|
||||||
"type": "Secret Keyword",
|
|
||||||
"filename": "docs/talk.md",
|
|
||||||
"hashed_secret": "1188d5a8ed7edcff5144a9472af960243eacf12e",
|
|
||||||
"is_verified": false,
|
|
||||||
"line_number": 50
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"docs/telegram.md": [
|
|
||||||
{
|
|
||||||
"type": "Secret Keyword",
|
|
||||||
"filename": "docs/telegram.md",
|
|
||||||
"hashed_secret": "e9fe51f94eadabf54dbf2fbbd57188b9abee436e",
|
|
||||||
"is_verified": false,
|
|
||||||
"line_number": 57
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"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
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"skills/trello/SKILL.md": [
|
|
||||||
{
|
|
||||||
"type": "Secret Keyword",
|
|
||||||
"filename": "skills/trello/SKILL.md",
|
|
||||||
"hashed_secret": "11fa7c37d697f30e6aee828b4426a10f83ab2380",
|
|
||||||
"is_verified": false,
|
|
||||||
"line_number": 18
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"src/agents/models-config.test.ts": [
|
|
||||||
{
|
|
||||||
"type": "Secret Keyword",
|
|
||||||
"filename": "src/agents/models-config.test.ts",
|
|
||||||
"hashed_secret": "7cf31e8b6cda49f70c31f1f25af05d46f924142d",
|
|
||||||
"is_verified": false,
|
|
||||||
"line_number": 25
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "Secret Keyword",
|
|
||||||
"filename": "src/agents/models-config.test.ts",
|
|
||||||
"hashed_secret": "3a81eb091f80c845232225be5663d270e90dacb7",
|
|
||||||
"is_verified": false,
|
|
||||||
"line_number": 90
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"src/agents/skills.test.ts": [
|
|
||||||
{
|
|
||||||
"type": "Secret Keyword",
|
|
||||||
"filename": "src/agents/skills.test.ts",
|
|
||||||
"hashed_secret": "3acfb2c2b433c0ea7ff107e33df91b18e52f960f",
|
|
||||||
"is_verified": false,
|
|
||||||
"line_number": 158
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "Secret Keyword",
|
|
||||||
"filename": "src/agents/skills.test.ts",
|
|
||||||
"hashed_secret": "7a85f4764bbd6daf1c3545efbbf0f279a6dc0beb",
|
|
||||||
"is_verified": false,
|
|
||||||
"line_number": 265
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "Secret Keyword",
|
|
||||||
"filename": "src/agents/skills.test.ts",
|
|
||||||
"hashed_secret": "5df3a673d724e8a1eb673a8baf623e183940804d",
|
|
||||||
"is_verified": false,
|
|
||||||
"line_number": 462
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "Secret Keyword",
|
|
||||||
"filename": "src/agents/skills.test.ts",
|
|
||||||
"hashed_secret": "8921daaa546693e52bc1f9c40bdcf15e816e0448",
|
|
||||||
"is_verified": false,
|
|
||||||
"line_number": 490
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"src/browser/target-id.test.ts": [
|
|
||||||
{
|
|
||||||
"type": "Hex High Entropy String",
|
|
||||||
"filename": "src/browser/target-id.test.ts",
|
|
||||||
"hashed_secret": "4e126c049580d66ca1549fa534d95a7263f27f46",
|
|
||||||
"is_verified": false,
|
|
||||||
"line_number": 16
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"src/commands/antigravity-oauth.ts": [
|
|
||||||
{
|
|
||||||
"type": "Base64 High Entropy String",
|
|
||||||
"filename": "src/commands/antigravity-oauth.ts",
|
|
||||||
"hashed_secret": "709d0f232b6ac4f8d24dec3e4fabfdb14257174f",
|
|
||||||
"is_verified": false,
|
|
||||||
"line_number": 17
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "Base64 High Entropy String",
|
|
||||||
"filename": "src/commands/antigravity-oauth.ts",
|
|
||||||
"hashed_secret": "3848603b8e866f62d07c206ff622279b9dcb0238",
|
|
||||||
"is_verified": false,
|
|
||||||
"line_number": 20
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"src/commands/onboard-auth.ts": [
|
|
||||||
{
|
|
||||||
"type": "Secret Keyword",
|
|
||||||
"filename": "src/commands/onboard-auth.ts",
|
|
||||||
"hashed_secret": "16c249e04e2be318050cb883c40137361c0c7209",
|
|
||||||
"is_verified": false,
|
|
||||||
"line_number": 50
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"src/config/config.test.ts": [
|
|
||||||
{
|
|
||||||
"type": "Secret Keyword",
|
|
||||||
"filename": "src/config/config.test.ts",
|
|
||||||
"hashed_secret": "bea2f7b64fab8d1d414d0449530b1e088d36d5b1",
|
|
||||||
"is_verified": false,
|
|
||||||
"line_number": 520
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"src/gateway/server.auth.test.ts": [
|
|
||||||
{
|
|
||||||
"type": "Secret Keyword",
|
|
||||||
"filename": "src/gateway/server.auth.test.ts",
|
|
||||||
"hashed_secret": "e5e9fa1ba31ecd1ae84f75caaa474f3a663f05f4",
|
|
||||||
"is_verified": false,
|
|
||||||
"line_number": 89
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "Secret Keyword",
|
|
||||||
"filename": "src/gateway/server.auth.test.ts",
|
|
||||||
"hashed_secret": "a4b48a81cdab1e1a5dd37907d6c85ca1c61ddc7c",
|
|
||||||
"is_verified": false,
|
|
||||||
"line_number": 109
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"src/infra/env.test.ts": [
|
|
||||||
{
|
|
||||||
"type": "Secret Keyword",
|
|
||||||
"filename": "src/infra/env.test.ts",
|
|
||||||
"hashed_secret": "df98a117ddabf85991b9fe0e268214dc0e1254dc",
|
|
||||||
"is_verified": false,
|
|
||||||
"line_number": 10
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "Secret Keyword",
|
|
||||||
"filename": "src/infra/env.test.ts",
|
|
||||||
"hashed_secret": "6d811dc1f59a55ca1a3d38b5042a062b9f79e8ec",
|
|
||||||
"is_verified": false,
|
|
||||||
"line_number": 25
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"src/infra/shell-env.test.ts": [
|
|
||||||
{
|
|
||||||
"type": "Secret Keyword",
|
|
||||||
"filename": "src/infra/shell-env.test.ts",
|
|
||||||
"hashed_secret": "65c10dc3549fe07424148a8a4790a3341ecbc253",
|
|
||||||
"is_verified": false,
|
|
||||||
"line_number": 35
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "Base64 High Entropy String",
|
|
||||||
"filename": "src/infra/shell-env.test.ts",
|
|
||||||
"hashed_secret": "64db6bf7f0e5a0491df4419f0eb1bbcc402989e8",
|
|
||||||
"is_verified": false,
|
|
||||||
"line_number": 56
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "Secret Keyword",
|
|
||||||
"filename": "src/infra/shell-env.test.ts",
|
|
||||||
"hashed_secret": "e013ffda590d2178607c16d11b1ea42f75ceb0e7",
|
|
||||||
"is_verified": false,
|
|
||||||
"line_number": 73
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "Base64 High Entropy String",
|
|
||||||
"filename": "src/infra/shell-env.test.ts",
|
|
||||||
"hashed_secret": "be6ee9a6bf9f2dad84a5a67d6c0576a5bacc391e",
|
|
||||||
"is_verified": false,
|
|
||||||
"line_number": 75
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"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
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"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-05T13:01:00Z"
|
|
||||||
}
|
|
||||||
@@ -48,4 +48,4 @@
|
|||||||
--allman false
|
--allman false
|
||||||
|
|
||||||
# Exclusions
|
# Exclusions
|
||||||
--exclude .build,.swiftpm,DerivedData,node_modules,dist,coverage,xcuserdata,Peekaboo,Swabble,apps/android,apps/ios,apps/shared,apps/macos/Sources/ClawdisProtocol,apps/macos/Sources/ClawdbotProtocol
|
--exclude .build,.swiftpm,DerivedData,node_modules,dist,coverage,xcuserdata,apps/macos/Sources/ClawdisProtocol,apps/macos/Sources/ClawdbotProtocol
|
||||||
@@ -17,8 +17,6 @@ excluded:
|
|||||||
- dist
|
- dist
|
||||||
- coverage
|
- coverage
|
||||||
- "*.playground"
|
- "*.playground"
|
||||||
# Generated (protocol-gen-swift.ts)
|
|
||||||
- apps/macos/Sources/ClawdbotProtocol/GatewayModels.swift
|
|
||||||
|
|
||||||
analyzer_rules:
|
analyzer_rules:
|
||||||
- unused_declaration
|
- unused_declaration
|
||||||
|
|||||||
@@ -1,52 +1,27 @@
|
|||||||
# Repository Guidelines
|
# Repository Guidelines
|
||||||
- Repo: https://github.com/clawdbot/clawdbot
|
|
||||||
- GitHub issues/comments/PR comments: use literal multiline strings or `-F - <<'EOF'` (or $'...') for real newlines; never embed "\\n".
|
|
||||||
|
|
||||||
## Project Structure & Module Organization
|
## Project Structure & Module Organization
|
||||||
- Source code: `src/` (CLI wiring in `src/cli`, commands in `src/commands`, web provider in `src/provider-web.ts`, infra in `src/infra`, media pipeline in `src/media`).
|
- Source code: `src/` (CLI wiring in `src/cli`, commands in `src/commands`, web provider in `src/provider-web.ts`, infra in `src/infra`, media pipeline in `src/media`).
|
||||||
- Tests: colocated `*.test.ts`.
|
- Tests: colocated `*.test.ts`.
|
||||||
- Docs: `docs/` (images, queue, Pi config). Built output lives in `dist/`.
|
- Docs: `docs/` (images, queue, Pi config). Built output lives in `dist/`.
|
||||||
- Plugins/extensions: live under `extensions/*` (workspace packages). Keep plugin-only deps in the extension `package.json`; do not add them to the root `package.json` unless core uses them.
|
|
||||||
- Installers served from `https://clawd.bot/*`: live in the sibling repo `../clawd.bot` (`public/install.sh`, `public/install-cli.sh`, `public/install.ps1`).
|
|
||||||
- Messaging channels: always consider **all** built-in + extension channels when refactoring shared logic (routing, allowlists, pairing, command gating, onboarding, docs).
|
|
||||||
- Core channel docs: `docs/channels/`
|
|
||||||
- Core channel code: `src/telegram`, `src/discord`, `src/slack`, `src/signal`, `src/imessage`, `src/web` (WhatsApp web), `src/channels`, `src/routing`
|
|
||||||
- Extensions (channel plugins): `extensions/*` (e.g. `extensions/msteams`, `extensions/matrix`, `extensions/zalo`, `extensions/zalouser`, `extensions/voice-call`)
|
|
||||||
|
|
||||||
## Docs Linking (Mintlify)
|
|
||||||
- Docs are hosted on Mintlify (docs.clawd.bot).
|
|
||||||
- Internal doc links in `docs/**/*.md`: root-relative, no `.md`/`.mdx` (example: `[Config](/configuration)`).
|
|
||||||
- Section cross-references: use anchors on root-relative paths (example: `[Hooks](/configuration#hooks)`).
|
|
||||||
- When Peter asks for links, reply with full `https://docs.clawd.bot/...` URLs (not root-relative).
|
|
||||||
- When you touch docs, end the reply with the `https://docs.clawd.bot/...` URLs you referenced.
|
|
||||||
- README (GitHub): keep absolute docs URLs (`https://docs.clawd.bot/...`) so links work on GitHub.
|
|
||||||
- Docs content must be generic: no personal device names/hostnames/paths; use placeholders like `user@gateway-host` and “gateway host”.
|
|
||||||
|
|
||||||
## Build, Test, and Development Commands
|
## Build, Test, and Development Commands
|
||||||
- Runtime baseline: Node **22+** (keep Node + Bun paths working).
|
|
||||||
- Install deps: `pnpm install`
|
- Install deps: `pnpm install`
|
||||||
- Also supported: `bun install` (keep `pnpm-lock.yaml` + Bun patching in sync when touching deps/patches).
|
- Run CLI in dev: `pnpm clawdbot ...` (tsx entry) or `pnpm dev` for `src/index.ts`.
|
||||||
- Prefer Bun for TypeScript execution (scripts, dev, tests): `bun <file.ts>` / `bunx <tool>`.
|
|
||||||
- Run CLI in dev: `pnpm clawdbot ...` (bun) or `pnpm dev`.
|
|
||||||
- Node remains supported for running built output (`dist/*`) and production installs.
|
|
||||||
- Type-check/build: `pnpm build` (tsc)
|
- Type-check/build: `pnpm build` (tsc)
|
||||||
- Lint/format: `pnpm lint` (oxlint), `pnpm format` (oxfmt)
|
- Lint/format: `pnpm lint` (biome check), `pnpm format` (biome format)
|
||||||
- Tests: `pnpm test` (vitest); coverage: `pnpm test:coverage`
|
- Tests: `pnpm test` (vitest); coverage: `pnpm test:coverage`
|
||||||
|
|
||||||
## Coding Style & Naming Conventions
|
## Coding Style & Naming Conventions
|
||||||
- Language: TypeScript (ESM). Prefer strict typing; avoid `any`.
|
- Language: TypeScript (ESM). Prefer strict typing; avoid `any`.
|
||||||
- Formatting/linting via Oxlint and Oxfmt; run `pnpm lint` before commits.
|
- Formatting/linting via Biome; run `pnpm lint` before commits.
|
||||||
- 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`.
|
- 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.
|
- Aim to keep files under ~700 LOC; guideline only (not a hard guardrail). Split/refactor when it improves clarity or testability.
|
||||||
- Naming: use **Clawdbot** for product/app/docs headings; use `clawdbot` for CLI command, package/binary, paths, and config keys.
|
|
||||||
|
|
||||||
## Testing Guidelines
|
## Testing Guidelines
|
||||||
- Framework: Vitest with V8 coverage thresholds (70% lines/branches/functions/statements).
|
- Framework: Vitest with V8 coverage thresholds (70% lines/branches/functions/statements).
|
||||||
- Naming: match source names with `*.test.ts`; e2e in `*.e2e.test.ts`.
|
- 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.
|
- Run `pnpm test` (or `pnpm test:coverage`) before pushing when you touch logic.
|
||||||
- Live tests (real keys): `CLAWDBOT_LIVE_TEST=1 pnpm test:live` (Clawdbot-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`.
|
|
||||||
- Pure test additions/fixes generally do **not** need a changelog entry unless they alter user-facing behavior or the user asks for one.
|
- Pure test additions/fixes generally do **not** need a changelog entry unless they alter user-facing behavior or the user asks for one.
|
||||||
- Mobile: before using a simulator, check for connected real devices (iOS + Android) and prefer them when available.
|
- Mobile: before using a simulator, check for connected real devices (iOS + Android) and prefer them when available.
|
||||||
|
|
||||||
@@ -54,81 +29,52 @@
|
|||||||
- Create commits with `scripts/committer "<msg>" <file...>`; avoid manual `git add`/`git commit` so staging stays scoped.
|
- Create commits with `scripts/committer "<msg>" <file...>`; avoid manual `git add`/`git commit` so staging stays scoped.
|
||||||
- Follow concise, action-oriented commit messages (e.g., `CLI: add verbose flag to send`).
|
- Follow concise, action-oriented commit messages (e.g., `CLI: add verbose flag to send`).
|
||||||
- Group related changes; avoid bundling unrelated refactors.
|
- Group related changes; avoid bundling unrelated refactors.
|
||||||
- Changelog workflow: keep latest released version at top (no `Unreleased`); after publishing, bump version and start a new top section.
|
|
||||||
- PRs should summarize scope, note testing performed, and mention any user-facing changes or new flags.
|
- PRs should summarize scope, note testing performed, and mention any user-facing changes or new flags.
|
||||||
- PR review flow: when given a PR link, review via `gh pr view`/`gh pr diff` and do **not** change branches.
|
|
||||||
- PR merge flow: create a temp branch from `main`, merge the PR branch into it (prefer squash unless commit history is important; use rebase/merge when it is). Always try to merge the PR unless it’s truly difficult, then use another approach. If we squash, add the PR author as a co-contributor. Apply fixes, add changelog entry (include PR # + thanks), run full gate before the final commit, commit, merge back to `main`, delete the temp branch, and end on `main`.
|
|
||||||
- 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 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.
|
|
||||||
- When merging a PR: leave a PR comment that explains exactly what we did and include the SHA hashes.
|
|
||||||
- When merging a PR from a new contributor: add their avatar to the README “Thanks to all clawtributors” thumbnail list.
|
|
||||||
- After merging a PR: run `bun scripts/update-clawtributors.ts` if the contributor is missing, then commit the regenerated README.
|
|
||||||
|
|
||||||
## Shorthand Commands
|
|
||||||
- `sync`: if working tree is dirty, commit all changes (pick a sensible Conventional Commit message), then `git pull --rebase`; if rebase conflicts and cannot resolve, stop; otherwise `git push`.
|
|
||||||
|
|
||||||
### PR Workflow (Review vs Land)
|
|
||||||
- **Review mode (PR link only):** read `gh pr view/diff`; **do not** switch branches; **do not** change code.
|
|
||||||
- **Landing mode:** 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 lint && pnpm build && pnpm test`), commit, merge back to `main`, then `git switch main` (never stay on a topic branch after landing). Important: contributor needs to be in git graph after this!
|
|
||||||
|
|
||||||
## Security & Configuration Tips
|
## Security & Configuration Tips
|
||||||
- Web provider stores creds at `~/.clawdbot/credentials/`; rerun `clawdbot login` if logged out.
|
- Web provider stores creds at `~/.clawdbot/credentials/`; rerun `clawdbot login` if logged out.
|
||||||
- Pi sessions live under `~/.clawdbot/sessions/` by default; the base directory is not configurable.
|
- Pi sessions live under `~/.clawdbot/sessions/` by default; the base directory is not configurable.
|
||||||
- Environment variables: see `~/.profile`.
|
|
||||||
- Never commit or publish real phone numbers, videos, or live configuration values. Use obviously fake placeholders in docs, tests, and examples.
|
- Never commit or publish real phone numbers, videos, or live configuration values. Use obviously fake placeholders in docs, tests, and examples.
|
||||||
- Release flow: always read `docs/reference/RELEASING.md` and `docs/platforms/mac/release.md` before any release work; do not ask routine questions once those docs answer them.
|
|
||||||
|
|
||||||
## Troubleshooting
|
|
||||||
- Rebrand/migration issues or legacy config/service warnings: run `clawdbot doctor` (see `docs/gateway/doctor.md`).
|
|
||||||
|
|
||||||
## Agent-Specific Notes
|
## Agent-Specific Notes
|
||||||
- Vocabulary: "makeup" = "mac app".
|
- Gateway currently runs only as the menubar app (launchctl shows `application.com.steipete.clawdbot.debug.*`), there is no separate LaunchAgent/helper label installed. Restart via the Clawdbot Mac app or `scripts/restart-mac.sh`; to verify/kill use `launchctl print gui/$UID | grep clawdbot` rather than expecting `com.steipete.clawdbot`. **When debugging on macOS, start/stop the gateway via the app, not ad-hoc tmux sessions; kill any temporary tunnels before handoff.**
|
||||||
- Never edit `node_modules` (global/Homebrew/npm/git installs too). Updates overwrite. Skill notes go in `tools.md` or `AGENTS.md`.
|
- macOS logs: use `./scripts/clawlog.sh` (aka `vtlog`) to query unified logs for subsystem `com.steipete.clawdbot`; it supports follow/tail/category filters and expects passwordless sudo for `/usr/bin/log`.
|
||||||
- When working on a GitHub Issue or PR, print the full URL at the end of the task.
|
- Also read the shared guardrails at `~/Projects/oracle/AGENTS.md` and `~/Projects/agent-scripts/AGENTS.MD` before making changes; align with any cross-repo rules noted there.
|
||||||
- When answering questions, respond with high-confidence answers only: verify in code; do not guess.
|
|
||||||
- Never update the Carbon dependency.
|
|
||||||
- Any dependency with `pnpm.patchedDependencies` must use an exact version (no `^`/`~`).
|
|
||||||
- Patching dependencies (pnpm patches, overrides, or vendored changes) requires explicit approval; do not do this by default.
|
|
||||||
- CLI progress: use `src/cli/progress.ts` (`osc-progress` + `@clack/prompts` spinner); don’t hand-roll spinners/bars.
|
|
||||||
- Status output: keep tables + ANSI-safe wrapping (`src/terminal/table.ts`); `status --all` = read-only/pasteable, `status --deep` = probes.
|
|
||||||
- Gateway currently runs only as the menubar app; there is no separate LaunchAgent/helper label installed. Restart via the Clawdbot Mac app or `scripts/restart-mac.sh`; to verify/kill use `launchctl print gui/$UID | grep clawdbot` rather than assuming a fixed label. **When debugging on macOS, start/stop the gateway via the app, not ad-hoc tmux sessions; kill any temporary tunnels before handoff.**
|
|
||||||
- macOS logs: use `./scripts/clawlog.sh` (aka `vtlog`) to query unified logs for the Clawdbot subsystem; it supports follow/tail/category filters and expects passwordless sudo for `/usr/bin/log`.
|
|
||||||
- If shared guardrails are available locally, review them; otherwise follow this repo's guidance.
|
|
||||||
- SwiftUI state management (iOS/macOS): prefer the `Observation` framework (`@Observable`, `@Bindable`) over `ObservableObject`/`@StateObject`; don’t introduce new `ObservableObject` unless required for compatibility, and migrate existing usages when touching related code.
|
- SwiftUI state management (iOS/macOS): prefer the `Observation` framework (`@Observable`, `@Bindable`) over `ObservableObject`/`@StateObject`; don’t introduce new `ObservableObject` unless required for compatibility, and migrate existing usages when touching related code.
|
||||||
- Connection providers: when adding a new connection, update every UI surface and docs (macOS app, web UI, mobile if applicable, onboarding/overview docs) and add matching status + configuration forms so provider lists and settings stay in sync.
|
- Connection providers: when adding a new connection, update every UI surface and docs (macOS app, web UI, mobile if applicable, onboarding/overview docs) and add matching status + configuration forms so provider lists and settings stay in sync.
|
||||||
- Version locations: `package.json` (CLI), `apps/android/app/build.gradle.kts` (versionName/versionCode), `apps/ios/Sources/Info.plist` + `apps/ios/Tests/Info.plist` (CFBundleShortVersionString/CFBundleVersion), `apps/macos/Sources/Clawdbot/Resources/Info.plist` (CFBundleShortVersionString/CFBundleVersion), `docs/install/updating.md` (pinned npm version), `docs/platforms/mac/release.md` (APP_VERSION/APP_BUILD examples), Peekaboo Xcode projects/Info.plists (MARKETING_VERSION/CURRENT_PROJECT_VERSION).
|
|
||||||
- **Restart apps:** “restart iOS/Android apps” means rebuild (recompile/install) and relaunch, not just kill/launch.
|
- **Restart apps:** “restart iOS/Android apps” means rebuild (recompile/install) and relaunch, not just kill/launch.
|
||||||
- **Device checks:** before testing, verify connected real devices (iOS/Android) before reaching for simulators/emulators.
|
- **Device checks:** before testing, verify connected real devices (iOS/Android) before reaching for simulators/emulators.
|
||||||
- iOS Team ID lookup: `security find-identity -p codesigning -v` → use Apple Development (…) TEAMID. Fallback: `defaults read com.apple.dt.Xcode IDEProvisioningTeamIdentifiers`.
|
- iOS Team ID lookup: `security find-identity -p codesigning -v` → use Apple Development (…) TEAMID. Fallback: `defaults read com.apple.dt.Xcode IDEProvisioningTeamIdentifiers`.
|
||||||
- A2UI bundle hash: `src/canvas-host/a2ui/.bundle.hash` is auto-generated; ignore unexpected changes, and only regenerate via `pnpm canvas:a2ui:bundle` (or `scripts/bundle-a2ui.sh`) when needed. Commit the hash as a separate commit.
|
- A2UI bundle hash: `src/canvas-host/a2ui/.bundle.hash` is auto-generated; ignore unexpected changes, and only regenerate via `pnpm canvas:a2ui:bundle` (or `scripts/bundle-a2ui.sh`) when needed. Commit the hash as a separate commit.
|
||||||
- Release signing/notary keys are managed outside the repo; follow internal release docs.
|
- Notary key file lives at `~/Library/CloudStorage/Dropbox/Backup/AppStore/AuthKey_NJF3NFGTS3.p8` (Sparkle keys live under `~/Library/CloudStorage/Dropbox/Backup/Sparkle`).
|
||||||
- Notary auth env vars (`APP_STORE_CONNECT_ISSUER_ID`, `APP_STORE_CONNECT_KEY_ID`, `APP_STORE_CONNECT_API_KEY_P8`) are expected in your environment (per internal release docs).
|
- Notary auth env vars (`APP_STORE_CONNECT_ISSUER_ID`, `APP_STORE_CONNECT_KEY_ID`, `APP_STORE_CONNECT_API_KEY_P8`) are in `~/.profile`.
|
||||||
- **Multi-agent safety:** do **not** create/apply/drop `git stash` entries unless explicitly requested (this includes `git pull --rebase --autostash`). Assume other agents may be working; keep unrelated WIP untouched and avoid cross-cutting state changes.
|
- **Multi-agent safety:** do **not** create/apply/drop `git stash` entries unless Peter explicitly asks (this includes `git pull --rebase --autostash`). Assume other agents may be working; keep unrelated WIP untouched and avoid cross-cutting state changes.
|
||||||
- **Multi-agent safety:** when the user says "push", you may `git pull --rebase` to integrate latest changes (never discard other agents' work). When the user says "commit", scope to your changes only. When the user says "commit all", commit everything in grouped chunks.
|
- **Multi-agent safety:** when Peter says "push", you may `git pull --rebase` to integrate latest changes (never discard other agents' work). When Peter says "commit", scope to your changes only. When Peter says "commit all", commit everything in grouped chunks.
|
||||||
- **Multi-agent safety:** do **not** create/remove/modify `git worktree` checkouts (or edit `.worktrees/*`) unless explicitly requested.
|
- **Multi-agent safety:** do **not** create/remove/modify `git worktree` checkouts (or edit `.worktrees/*`) unless Peter explicitly asks.
|
||||||
- **Multi-agent safety:** do **not** switch branches / check out a different branch unless explicitly requested.
|
- **Multi-agent safety:** do **not** switch branches / check out a different branch unless Peter explicitly asks.
|
||||||
- **Multi-agent safety:** running multiple agents is OK as long as each agent has its own session.
|
- **Multi-agent safety:** running multiple agents is OK as long as each agent has its own session.
|
||||||
- **Multi-agent safety:** when you see unrecognized files, keep going; focus on your changes and commit only those.
|
- **Multi-agent safety:** when you see unrecognized files, keep going; focus on your changes and commit only those.
|
||||||
- Lobster seam: use the shared CLI palette in `src/terminal/palette.ts` (no hardcoded colors); apply palette to onboarding/config prompts and other TTY UI output as needed.
|
- When asked to open a “session” file, open the Pi session logs under `~/.clawdbot/sessions/*.jsonl` (newest unless a specific ID is given), not the default `sessions.json`. If logs are needed from Mac Studio, SSH via Tailscale and read the same path there.
|
||||||
- **Multi-agent safety:** focus reports on your edits; avoid guard-rail disclaimers unless truly blocked; when multiple agents touch the same file, continue if safe; end with a brief “other files present” note only if relevant.
|
- Menubar dimming + restart flow mirrors Trimmy: use `scripts/restart-mac.sh` (kills all Clawdbot variants, runs `swift build`, packages, relaunches). Icon dimming depends on MenuBarExtraAccess wiring in AppMain; keep `appearsDisabled` updates intact when touching the status item.
|
||||||
- Bug investigations: read source code of relevant npm dependencies and all related local code before concluding; aim for high-confidence root cause.
|
|
||||||
- Code style: add brief comments for tricky logic; keep files under ~500 LOC when feasible (split/refactor as needed).
|
|
||||||
- Tool schema guardrails (google-antigravity): avoid `Type.Union` in tool input schemas; no `anyOf`/`oneOf`/`allOf`. Use `stringEnum`/`optionalStringEnum` (Type.Unsafe enum) for string lists, and `Type.Optional(...)` instead of `... | null`. Keep top-level tool schema as `type: "object"` with `properties`.
|
|
||||||
- Tool schema guardrails: avoid raw `format` property names in tool schemas; some validators treat `format` as a reserved keyword and reject the schema.
|
|
||||||
- When asked to open a “session” file, open the Pi session logs under `~/.clawdbot/agents/<agentId>/sessions/*.jsonl` (use the `agent=<id>` value in the Runtime line of the system prompt; newest unless a specific ID is given), not the default `sessions.json`. If logs are needed from another machine, SSH via Tailscale and read the same path there.
|
|
||||||
- Do not rebuild the macOS app over SSH; rebuilds must be run directly on the Mac.
|
- Do not rebuild the macOS app over SSH; rebuilds must be run directly on the Mac.
|
||||||
- Never send streaming/partial replies to external messaging surfaces (WhatsApp, Telegram); only final replies should be delivered there. Streaming/tool events may still go to internal UIs/control channel.
|
- Never send streaming/partial replies to external messaging surfaces (WhatsApp, Telegram); only final replies should be delivered there. Streaming/tool events may still go to internal UIs/control channel.
|
||||||
- Voice wake forwarding tips:
|
- Voice wake forwarding tips:
|
||||||
- Command template should stay `clawdbot-mac agent --message "${text}" --thinking low`; `VoiceWakeForwarder` already shell-escapes `${text}`. Don’t add extra quotes.
|
- Command template should stay `clawdbot-mac agent --message "${text}" --thinking low`; `VoiceWakeForwarder` already shell-escapes `${text}`. Don’t add extra quotes.
|
||||||
- launchd PATH is minimal; ensure the app’s launch agent PATH includes standard system paths plus your pnpm bin (typically `$HOME/Library/pnpm`) so `pnpm`/`clawdbot` binaries resolve when invoked via `clawdbot-mac`.
|
- launchd PATH is minimal; ensure the app’s launch agent sets PATH to include `/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin:/Users/steipete/Library/pnpm` so `pnpm`/`clawdbot` binaries resolve when invoked via `clawdbot-mac`.
|
||||||
- For manual `clawdbot message send` messages that include `!`, use the heredoc pattern noted below to avoid the Bash tool’s escaping.
|
- For manual `clawdbot send` messages that include `!`, use the heredoc pattern noted below to avoid the Bash tool’s escaping.
|
||||||
- Release guardrails: do not change version numbers without operator’s explicit consent; always ask permission before running any npm publish/release step.
|
|
||||||
|
|
||||||
## NPM + 1Password (publish/verify)
|
## Exclamation Mark Escaping Workaround
|
||||||
- Use the 1password skill; all `op` commands must run inside a fresh tmux session.
|
The Claude Code Bash tool escapes `!` to `\\!` in command arguments. When using `clawdbot send` with messages containing exclamation marks, use heredoc syntax:
|
||||||
- Sign in: `eval "$(op signin --account my.1password.com)"` (app unlocked + integration on).
|
|
||||||
- OTP: `op read 'op://Private/Npmjs/one-time password?attribute=otp'`.
|
```bash
|
||||||
- Publish: `npm publish --access public --otp="<otp>"` (run from the package dir).
|
# WRONG - will send "Hello\\!" with backslash
|
||||||
- Verify without local npmrc side effects: `npm view <pkg> version --userconfig "$(mktemp)"`.
|
clawdbot send --to "+1234" --message 'Hello!'
|
||||||
- Kill the tmux session after publish.
|
|
||||||
|
# CORRECT - use heredoc to avoid escaping
|
||||||
|
clawdbot send --to "+1234" --message "$(cat <<'EOF'
|
||||||
|
Hello!
|
||||||
|
EOF
|
||||||
|
)"
|
||||||
|
```
|
||||||
|
|
||||||
|
This is a Claude Code quirk, not a clawdbot bug.
|
||||||
@@ -13,7 +13,7 @@ Welcome to the lobster tank! 🦞
|
|||||||
- GitHub: [@steipete](https://github.com/steipete) · X: [@steipete](https://x.com/steipete)
|
- GitHub: [@steipete](https://github.com/steipete) · X: [@steipete](https://x.com/steipete)
|
||||||
|
|
||||||
- **Shadow** - Discord + Slack subsystem
|
- **Shadow** - Discord + Slack subsystem
|
||||||
- GitHub: [@thewilloftheshadow](https://github.com/thewilloftheshadow) · X: [@4shad0wed](https://x.com/4shad0wed)
|
- GitHub: [@4shadowed](https://github.com/4shadowed) · X: [@4shad0wed](https://x.com/4shad0wed)
|
||||||
|
|
||||||
- **Jos** - Telegram, API, Nix mode
|
- **Jos** - Telegram, API, Nix mode
|
||||||
- GitHub: [@joshp123](https://github.com/joshp123) · X: [@jjpcodes](https://x.com/jjpcodes)
|
- GitHub: [@joshp123](https://github.com/joshp123) · X: [@jjpcodes](https://x.com/jjpcodes)
|
||||||
|
|||||||
@@ -1,32 +1,13 @@
|
|||||||
FROM node:22-bookworm
|
FROM node:22-bookworm
|
||||||
|
|
||||||
# Install Bun (required for build scripts)
|
|
||||||
RUN curl -fsSL https://bun.sh/install | bash
|
|
||||||
ENV PATH="/root/.bun/bin:${PATH}"
|
|
||||||
|
|
||||||
RUN corepack enable
|
RUN corepack enable
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
ARG CLAWDBOT_DOCKER_APT_PACKAGES=""
|
COPY . .
|
||||||
RUN if [ -n "$CLAWDBOT_DOCKER_APT_PACKAGES" ]; then \
|
|
||||||
apt-get update && \
|
|
||||||
DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends $CLAWDBOT_DOCKER_APT_PACKAGES && \
|
|
||||||
apt-get clean && \
|
|
||||||
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
|
|
||||||
|
|
||||||
RUN pnpm install --frozen-lockfile
|
RUN pnpm install --frozen-lockfile
|
||||||
|
|
||||||
COPY . .
|
|
||||||
RUN pnpm build
|
RUN pnpm build
|
||||||
# Force pnpm for UI build (Bun may fail on ARM/Synology architectures)
|
|
||||||
ENV CLAWDBOT_PREFER_PNPM=1
|
|
||||||
RUN pnpm ui:install
|
RUN pnpm ui:install
|
||||||
RUN pnpm ui:build
|
RUN pnpm ui:build
|
||||||
|
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ RUN apt-get update \
|
|||||||
jq \
|
jq \
|
||||||
novnc \
|
novnc \
|
||||||
python3 \
|
python3 \
|
||||||
socat \
|
|
||||||
websockify \
|
websockify \
|
||||||
x11vnc \
|
x11vnc \
|
||||||
xvfb \
|
xvfb \
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
# 🦞 Clawdbot — Personal AI Assistant
|
# 🦞 CLAWDBOT — Personal AI Assistant
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<img src="https://raw.githubusercontent.com/clawdbot/clawdbot/main/docs/whatsapp-clawd.jpg" alt="Clawdbot" width="400">
|
<img src="https://raw.githubusercontent.com/clawdbot/clawdbot/main/docs/whatsapp-clawd.jpg" alt="CLAWDBOT" width="400">
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
@@ -11,179 +11,141 @@
|
|||||||
<p align="center">
|
<p align="center">
|
||||||
<a href="https://github.com/clawdbot/clawdbot/actions/workflows/ci.yml?branch=main"><img src="https://img.shields.io/github/actions/workflow/status/clawdbot/clawdbot/ci.yml?branch=main&style=for-the-badge" alt="CI status"></a>
|
<a href="https://github.com/clawdbot/clawdbot/actions/workflows/ci.yml?branch=main"><img src="https://img.shields.io/github/actions/workflow/status/clawdbot/clawdbot/ci.yml?branch=main&style=for-the-badge" alt="CI status"></a>
|
||||||
<a href="https://github.com/clawdbot/clawdbot/releases"><img src="https://img.shields.io/github/v/release/clawdbot/clawdbot?include_prereleases&style=for-the-badge" alt="GitHub release"></a>
|
<a href="https://github.com/clawdbot/clawdbot/releases"><img src="https://img.shields.io/github/v/release/clawdbot/clawdbot?include_prereleases&style=for-the-badge" alt="GitHub release"></a>
|
||||||
<a href="https://deepwiki.com/clawdbot/clawdbot"><img src="https://img.shields.io/badge/DeepWiki-clawdbot-111111?style=for-the-badge" alt="DeepWiki"></a>
|
|
||||||
<a href="https://discord.gg/clawd"><img src="https://img.shields.io/discord/1456350064065904867?label=Discord&logo=discord&logoColor=white&color=5865F2&style=for-the-badge" alt="Discord"></a>
|
<a href="https://discord.gg/clawd"><img src="https://img.shields.io/discord/1456350064065904867?label=Discord&logo=discord&logoColor=white&color=5865F2&style=for-the-badge" alt="Discord"></a>
|
||||||
<a href="LICENSE"><img src="https://img.shields.io/badge/License-MIT-blue.svg?style=for-the-badge" alt="MIT License"></a>
|
<a href="LICENSE"><img src="https://img.shields.io/badge/License-MIT-blue.svg?style=for-the-badge" alt="MIT License"></a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
**Clawdbot** is a *personal AI assistant* you run on your own devices.
|
**Clawdbot** is a *personal AI assistant* you run on your own devices.
|
||||||
It answers you on the channels you already use (WhatsApp, Telegram, Slack, Discord, Signal, iMessage, Microsoft Teams, WebChat), plus extension channels like BlueBubbles, Matrix, Zalo, and Zalo Personal. It can speak and listen on macOS/iOS/Android, and can render a live Canvas you control. The Gateway is just the control plane — the product is the assistant.
|
It answers you on the surfaces you already use (WhatsApp, Telegram, Slack, Discord, Signal, iMessage, WebChat), can speak and listen on macOS/iOS/Android, and can render a live Canvas you control. The Gateway is just the control plane — the product is the assistant.
|
||||||
|
|
||||||
If you want a personal, single-user assistant that feels local, fast, and always-on, this is it.
|
If you want a personal, single-user assistant that feels local, fast, and always-on, this is it.
|
||||||
|
|
||||||
[Website](https://clawdbot.com) · [Docs](https://docs.clawd.bot) · [Getting Started](https://docs.clawd.bot/start/getting-started) · [Updating](https://docs.clawd.bot/install/updating) · [Showcase](https://docs.clawd.bot/start/showcase) · [FAQ](https://docs.clawd.bot/start/faq) · [Wizard](https://docs.clawd.bot/start/wizard) · [Nix](https://github.com/clawdbot/nix-clawdbot) · [Docker](https://docs.clawd.bot/install/docker) · [Discord](https://discord.gg/clawd)
|
Website: [https://clawdbot.com](https://clawdbot.com) · Docs: [https://docs.clawdbot.com](https://docs.clawdbot.com/) · Showcase: [https://docs.clawdbot.com/showcase](https://docs.clawdbot.com/showcase) · FAQ: [https://docs.clawdbot.com/faq](https://docs.clawdbot.com/faq) · Wizard: [https://docs.clawdbot.com/wizard](https://docs.clawdbot.com/wizard) · Nix: [https://github.com/clawdbot/nix-clawdbot](https://github.com/clawdbot/nix-clawdbot) · Docker: [https://docs.clawdbot.com/docker](https://docs.clawdbot.com/docker) · Discord: [https://discord.gg/clawd](https://discord.gg/clawd)
|
||||||
|
|
||||||
Preferred setup: run the onboarding wizard (`clawdbot onboard`). It walks through gateway, workspace, channels, and skills. The CLI wizard is the recommended path and works on **macOS, Linux, and Windows (via WSL2; strongly recommended)**.
|
Preferred setup: run the onboarding wizard (`clawdbot onboard`). It walks through gateway, workspace, providers, and skills. The CLI wizard is the recommended path and works on **macOS, Windows, and Linux**.
|
||||||
Works with npm, pnpm, or bun.
|
Works with npm, pnpm, or bun.
|
||||||
New install? Start here: [Getting started](https://docs.clawd.bot/start/getting-started)
|
|
||||||
|
|
||||||
**Subscriptions (OAuth):**
|
**Subscriptions (OAuth):**
|
||||||
- **[Anthropic](https://www.anthropic.com/)** (Claude Pro/Max)
|
- **Anthropic** (Claude Pro/Max)
|
||||||
- **[OpenAI](https://openai.com/)** (ChatGPT/Codex)
|
- **OpenAI** (ChatGPT/Codex)
|
||||||
|
|
||||||
Model note: while any model is supported, I strongly recommend **Anthropic Pro/Max (100/200) + Opus 4.5** for long‑context strength and better prompt‑injection resistance. See [Onboarding](https://docs.clawd.bot/start/onboarding).
|
Model note: while any model is supported, I strongly recommend **Anthropic Pro/Max (100/200) + Opus 4.5** for long‑context strength and better prompt‑injection resistance. See [Onboarding](https://docs.clawdbot.com/onboarding).
|
||||||
|
|
||||||
## Models (selection + auth)
|
## Recommended setup (from source)
|
||||||
|
|
||||||
- Models config + CLI: [Models](https://docs.clawd.bot/concepts/models)
|
Do **not** download prebuilt binaries. Build from source.
|
||||||
- Auth profile rotation (OAuth vs API keys) + fallbacks: [Model failover](https://docs.clawd.bot/concepts/model-failover)
|
|
||||||
|
|
||||||
## Install (recommended)
|
|
||||||
|
|
||||||
Runtime: **Node ≥22**.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npm install -g clawdbot@latest
|
|
||||||
# or: pnpm add -g clawdbot@latest
|
|
||||||
|
|
||||||
clawdbot onboard --install-daemon
|
|
||||||
```
|
|
||||||
|
|
||||||
The wizard installs the Gateway daemon (launchd/systemd user service) so it stays running.
|
|
||||||
|
|
||||||
## Quick start (TL;DR)
|
|
||||||
|
|
||||||
Runtime: **Node ≥22**.
|
|
||||||
|
|
||||||
Full beginner guide (auth, pairing, channels): [Getting started](https://docs.clawd.bot/start/getting-started)
|
|
||||||
|
|
||||||
```bash
|
|
||||||
clawdbot onboard --install-daemon
|
|
||||||
|
|
||||||
clawdbot gateway --port 18789 --verbose
|
|
||||||
|
|
||||||
# Send a message
|
|
||||||
clawdbot message send --to +1234567890 --message "Hello from Clawdbot"
|
|
||||||
|
|
||||||
# Talk to the assistant (optionally deliver back to any connected channel: WhatsApp/Telegram/Slack/Discord/Signal/iMessage/BlueBubbles/Microsoft Teams/Matrix/Zalo/Zalo Personal/WebChat)
|
|
||||||
clawdbot agent --message "Ship checklist" --thinking high
|
|
||||||
```
|
|
||||||
|
|
||||||
Upgrading? [Updating guide](https://docs.clawd.bot/install/updating) (and run `clawdbot doctor`).
|
|
||||||
|
|
||||||
## From source (development)
|
|
||||||
|
|
||||||
Prefer `pnpm` for builds from source. Bun is optional for running TypeScript directly.
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
# Clone this repo
|
||||||
git clone https://github.com/clawdbot/clawdbot.git
|
git clone https://github.com/clawdbot/clawdbot.git
|
||||||
cd clawdbot
|
cd clawdbot
|
||||||
|
|
||||||
pnpm install
|
pnpm install
|
||||||
pnpm ui:build # auto-installs UI deps on first run
|
|
||||||
pnpm build
|
pnpm build
|
||||||
|
pnpm ui:build
|
||||||
|
pnpm clawdbot onboard
|
||||||
|
```
|
||||||
|
|
||||||
pnpm clawdbot onboard --install-daemon
|
## Quick start (from source)
|
||||||
|
|
||||||
|
Runtime: **Node ≥22** + **pnpm**.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm install
|
||||||
|
pnpm build
|
||||||
|
pnpm ui:build
|
||||||
|
|
||||||
|
# Recommended: run the onboarding wizard
|
||||||
|
pnpm clawdbot onboard
|
||||||
|
|
||||||
|
# Link WhatsApp (stores creds in ~/.clawdbot/credentials)
|
||||||
|
pnpm clawdbot login
|
||||||
|
|
||||||
|
# Start the gateway
|
||||||
|
pnpm clawdbot gateway --port 18789 --verbose
|
||||||
|
|
||||||
# Dev loop (auto-reload on TS changes)
|
# Dev loop (auto-reload on TS changes)
|
||||||
pnpm gateway:watch
|
pnpm gateway:watch
|
||||||
|
|
||||||
|
# Send a message
|
||||||
|
pnpm clawdbot send --to +1234567890 --message "Hello from Clawdbot"
|
||||||
|
|
||||||
|
# Talk to the assistant (optionally deliver back to WhatsApp/Telegram/Slack/Discord)
|
||||||
|
pnpm clawdbot agent --message "Ship checklist" --thinking high
|
||||||
```
|
```
|
||||||
|
|
||||||
Note: `pnpm clawdbot ...` runs TypeScript directly (via `tsx`). `pnpm build` produces `dist/` for running via Node / the packaged `clawdbot` binary.
|
Upgrading? `clawdbot doctor`.
|
||||||
|
|
||||||
## Security defaults (DM access)
|
If you run from source, prefer `pnpm clawdbot …` (not global `clawdbot`).
|
||||||
|
|
||||||
Clawdbot connects to real messaging surfaces. Treat inbound DMs as **untrusted input**.
|
|
||||||
|
|
||||||
Full security guide: [Security](https://docs.clawd.bot/gateway/security)
|
|
||||||
|
|
||||||
Default behavior on Telegram/WhatsApp/Signal/iMessage/Microsoft Teams/Discord/Slack:
|
|
||||||
- **DM pairing** (`dmPolicy="pairing"` / `channels.discord.dm.policy="pairing"` / `channels.slack.dm.policy="pairing"`): unknown senders receive a short pairing code and the bot does not process their message.
|
|
||||||
- Approve with: `clawdbot pairing approve <channel> <code>` (then the sender is added to a local allowlist store).
|
|
||||||
- Public inbound DMs require an explicit opt-in: set `dmPolicy="open"` and include `"*"` in the channel allowlist (`allowFrom` / `channels.discord.dm.allowFrom` / `channels.slack.dm.allowFrom`).
|
|
||||||
|
|
||||||
Run `clawdbot doctor` to surface risky/misconfigured DM policies.
|
|
||||||
|
|
||||||
## Highlights
|
## Highlights
|
||||||
|
|
||||||
- **[Local-first Gateway](https://docs.clawd.bot/gateway)** — single control plane for sessions, channels, tools, and events.
|
- **[Local-first Gateway](https://docs.clawdbot.com/gateway)** — single control plane for sessions, providers, tools, and events.
|
||||||
- **[Multi-channel inbox](https://docs.clawd.bot/channels)** — WhatsApp, Telegram, Slack, Discord, Signal, iMessage, BlueBubbles, Microsoft Teams, Matrix, Zalo, Zalo Personal, WebChat, macOS, iOS/Android.
|
- **[Multi-surface inbox](https://docs.clawdbot.com/surface)** — WhatsApp, Telegram, Slack, Discord, Signal, iMessage, WebChat, macOS, iOS/Android.
|
||||||
- **[Multi-agent routing](https://docs.clawd.bot/gateway/configuration)** — route inbound channels/accounts/peers to isolated agents (workspaces + per-agent sessions).
|
- **[Voice Wake](https://docs.clawdbot.com/voicewake) + [Talk Mode](https://docs.clawdbot.com/talk)** — always-on speech for macOS/iOS/Android with ElevenLabs.
|
||||||
- **[Voice Wake](https://docs.clawd.bot/nodes/voicewake) + [Talk Mode](https://docs.clawd.bot/nodes/talk)** — always-on speech for macOS/iOS/Android with ElevenLabs.
|
- **[Live Canvas](https://docs.clawdbot.com/mac/canvas)** — agent-driven visual workspace with [A2UI](https://docs.clawdbot.com/refactor/canvas-a2ui).
|
||||||
- **[Live Canvas](https://docs.clawd.bot/platforms/mac/canvas)** — agent-driven visual workspace with [A2UI](https://docs.clawd.bot/platforms/mac/canvas#canvas-a2ui).
|
- **[First-class tools](https://docs.clawdbot.com/tools)** — browser, canvas, nodes, cron, sessions, and Discord/Slack actions.
|
||||||
- **[First-class tools](https://docs.clawd.bot/tools)** — browser, canvas, nodes, cron, sessions, and Discord/Slack actions.
|
- **[Companion apps](https://docs.clawdbot.com/macos)** — macOS menu bar app + iOS/Android [nodes](https://docs.clawdbot.com/nodes).
|
||||||
- **[Companion apps](https://docs.clawd.bot/platforms/macos)** — macOS menu bar app + iOS/Android [nodes](https://docs.clawd.bot/nodes).
|
- **[Onboarding](https://docs.clawdbot.com/wizard) + [skills](https://docs.clawdbot.com/skills)** — wizard-driven setup with bundled/managed/workspace skills.
|
||||||
- **[Onboarding](https://docs.clawd.bot/start/wizard) + [skills](https://docs.clawd.bot/tools/skills)** — wizard-driven setup with bundled/managed/workspace skills.
|
|
||||||
|
|
||||||
## Star History
|
|
||||||
|
|
||||||
[](https://www.star-history.com/#clawdbot/clawdbot&type=date&legend=top-left)
|
|
||||||
|
|
||||||
## Everything we built so far
|
## Everything we built so far
|
||||||
|
|
||||||
### Core platform
|
### Core platform
|
||||||
- [Gateway WS control plane](https://docs.clawd.bot/gateway) with sessions, presence, config, cron, webhooks, [Control UI](https://docs.clawd.bot/web), and [Canvas host](https://docs.clawd.bot/platforms/mac/canvas#canvas-a2ui).
|
- [Gateway WS control plane](https://docs.clawdbot.com/gateway) with sessions, presence, config, cron, webhooks, [Control UI](https://docs.clawdbot.com/web), and [Canvas host](https://docs.clawdbot.com/refactor/canvas-a2ui).
|
||||||
- [CLI surface](https://docs.clawd.bot/tools/agent-send): gateway, agent, send, [wizard](https://docs.clawd.bot/start/wizard), and [doctor](https://docs.clawd.bot/gateway/doctor).
|
- [CLI surface](https://docs.clawdbot.com/agent-send): gateway, agent, send, [wizard](https://docs.clawdbot.com/wizard), and [doctor](https://docs.clawdbot.com/doctor).
|
||||||
- [Pi agent runtime](https://docs.clawd.bot/concepts/agent) in RPC mode with tool streaming and block streaming.
|
- [Pi agent runtime](https://docs.clawdbot.com/agent) in RPC mode with tool streaming and block streaming.
|
||||||
- [Session model](https://docs.clawd.bot/concepts/session): `main` for direct chats, group isolation, activation modes, queue modes, reply-back. Group rules: [Groups](https://docs.clawd.bot/concepts/groups).
|
- [Session model](https://docs.clawdbot.com/session): `main` for direct chats, group isolation, activation modes, queue modes, reply-back. Group rules: [Groups](https://docs.clawdbot.com/groups).
|
||||||
- [Media pipeline](https://docs.clawd.bot/nodes/images): images/audio/video, transcription hooks, size caps, temp file lifecycle. Audio details: [Audio](https://docs.clawd.bot/nodes/audio).
|
- [Media pipeline](https://docs.clawdbot.com/images): images/audio/video, transcription hooks, size caps, temp file lifecycle. Audio details: [Audio](https://docs.clawdbot.com/audio).
|
||||||
|
|
||||||
### Channels
|
### Surfaces + providers
|
||||||
- [Channels](https://docs.clawd.bot/channels): [WhatsApp](https://docs.clawd.bot/channels/whatsapp) (Baileys), [Telegram](https://docs.clawd.bot/channels/telegram) (grammY), [Slack](https://docs.clawd.bot/channels/slack) (Bolt), [Discord](https://docs.clawd.bot/channels/discord) (discord.js), [Signal](https://docs.clawd.bot/channels/signal) (signal-cli), [iMessage](https://docs.clawd.bot/channels/imessage) (imsg), [BlueBubbles](https://docs.clawd.bot/channels/bluebubbles) (extension), [Microsoft Teams](https://docs.clawd.bot/channels/msteams) (extension), [Matrix](https://docs.clawd.bot/channels/matrix) (extension), [Zalo](https://docs.clawd.bot/channels/zalo) (extension), [Zalo Personal](https://docs.clawd.bot/channels/zalouser) (extension), [WebChat](https://docs.clawd.bot/web/webchat).
|
- [Providers](https://docs.clawdbot.com/surface): [WhatsApp](https://docs.clawdbot.com/whatsapp) (Baileys), [Telegram](https://docs.clawdbot.com/telegram) (grammY), [Slack](https://docs.clawdbot.com/slack) (Bolt), [Discord](https://docs.clawdbot.com/discord) (discord.js), [Signal](https://docs.clawdbot.com/signal) (signal-cli), [iMessage](https://docs.clawdbot.com/imessage) (imsg), [WebChat](https://docs.clawdbot.com/webchat).
|
||||||
- [Group routing](https://docs.clawd.bot/concepts/group-messages): mention gating, reply tags, per-channel chunking and routing. Channel rules: [Channels](https://docs.clawd.bot/channels).
|
- [Group routing](https://docs.clawdbot.com/group-messages): mention gating, reply tags, per-surface chunking and routing. Surface rules: [Surface routing](https://docs.clawdbot.com/surface).
|
||||||
|
|
||||||
### Apps + nodes
|
### Apps + nodes
|
||||||
- [macOS app](https://docs.clawd.bot/platforms/macos): menu bar control plane, [Voice Wake](https://docs.clawd.bot/nodes/voicewake)/PTT, [Talk Mode](https://docs.clawd.bot/nodes/talk) overlay, [WebChat](https://docs.clawd.bot/web/webchat), debug tools, [remote gateway](https://docs.clawd.bot/gateway/remote) control.
|
- [macOS app](https://docs.clawdbot.com/macos): menu bar control plane, [Voice Wake](https://docs.clawdbot.com/voicewake)/PTT, [Talk Mode](https://docs.clawdbot.com/talk) overlay, [WebChat](https://docs.clawdbot.com/webchat), debug tools, [remote gateway](https://docs.clawdbot.com/remote) control.
|
||||||
- [iOS node](https://docs.clawd.bot/platforms/ios): [Canvas](https://docs.clawd.bot/platforms/mac/canvas), [Voice Wake](https://docs.clawd.bot/nodes/voicewake), [Talk Mode](https://docs.clawd.bot/nodes/talk), camera, screen recording, Bonjour pairing.
|
- [iOS node](https://docs.clawdbot.com/ios): [Canvas](https://docs.clawdbot.com/mac/canvas), [Voice Wake](https://docs.clawdbot.com/voicewake), [Talk Mode](https://docs.clawdbot.com/talk), camera, screen recording, Bonjour pairing.
|
||||||
- [Android node](https://docs.clawd.bot/platforms/android): [Canvas](https://docs.clawd.bot/platforms/mac/canvas), [Talk Mode](https://docs.clawd.bot/nodes/talk), camera, screen recording, optional SMS.
|
- [Android node](https://docs.clawdbot.com/android): [Canvas](https://docs.clawdbot.com/mac/canvas), [Talk Mode](https://docs.clawdbot.com/talk), camera, screen recording, optional SMS.
|
||||||
- [macOS node mode](https://docs.clawd.bot/nodes): system.run/notify + canvas/camera exposure.
|
- [macOS node mode](https://docs.clawdbot.com/nodes): system.run/notify + canvas/camera exposure.
|
||||||
|
|
||||||
### Tools + automation
|
### Tools + automation
|
||||||
- [Browser control](https://docs.clawd.bot/tools/browser): dedicated clawd Chrome/Chromium, snapshots, actions, uploads, profiles.
|
- [Browser control](https://docs.clawdbot.com/browser): dedicated clawd Chrome/Chromium, snapshots, actions, uploads, profiles.
|
||||||
- [Canvas](https://docs.clawd.bot/platforms/mac/canvas): [A2UI](https://docs.clawd.bot/platforms/mac/canvas#canvas-a2ui) push/reset, eval, snapshot.
|
- [Canvas](https://docs.clawdbot.com/mac/canvas): [A2UI](https://docs.clawdbot.com/refactor/canvas-a2ui) push/reset, eval, snapshot.
|
||||||
- [Nodes](https://docs.clawd.bot/nodes): camera snap/clip, screen record, [location.get](https://docs.clawd.bot/nodes/location-command), notifications.
|
- [Nodes](https://docs.clawdbot.com/nodes): camera snap/clip, screen record, [location.get](https://docs.clawdbot.com/location-command), notifications.
|
||||||
- [Cron + wakeups](https://docs.clawd.bot/automation/cron-jobs); [webhooks](https://docs.clawd.bot/automation/webhook); [Gmail Pub/Sub](https://docs.clawd.bot/automation/gmail-pubsub).
|
- [Cron + wakeups](https://docs.clawdbot.com/cron); [webhooks](https://docs.clawdbot.com/webhook); [Gmail Pub/Sub](https://docs.clawdbot.com/gmail-pubsub).
|
||||||
- [Skills platform](https://docs.clawd.bot/tools/skills): bundled, managed, and workspace skills with install gating + UI.
|
- [Skills platform](https://docs.clawdbot.com/skills): bundled, managed, and workspace skills with install gating + UI.
|
||||||
|
|
||||||
### Runtime + safety
|
|
||||||
- [Channel routing](https://docs.clawd.bot/concepts/channel-routing), [retry policy](https://docs.clawd.bot/concepts/retry), and [streaming/chunking](https://docs.clawd.bot/concepts/streaming).
|
|
||||||
- [Presence](https://docs.clawd.bot/concepts/presence), [typing indicators](https://docs.clawd.bot/concepts/typing-indicators), and [usage tracking](https://docs.clawd.bot/concepts/usage-tracking).
|
|
||||||
- [Models](https://docs.clawd.bot/concepts/models), [model failover](https://docs.clawd.bot/concepts/model-failover), and [session pruning](https://docs.clawd.bot/concepts/session-pruning).
|
|
||||||
- [Security](https://docs.clawd.bot/gateway/security) and [troubleshooting](https://docs.clawd.bot/channels/troubleshooting).
|
|
||||||
|
|
||||||
### Ops + packaging
|
### Ops + packaging
|
||||||
- [Control UI](https://docs.clawd.bot/web) + [WebChat](https://docs.clawd.bot/web/webchat) served directly from the Gateway.
|
- [Control UI](https://docs.clawdbot.com/web) + [WebChat](https://docs.clawdbot.com/webchat) served directly from the Gateway.
|
||||||
- [Tailscale Serve/Funnel](https://docs.clawd.bot/gateway/tailscale) or [SSH tunnels](https://docs.clawd.bot/gateway/remote) with token/password auth.
|
- [Tailscale Serve/Funnel](https://docs.clawdbot.com/tailscale) or [SSH tunnels](https://docs.clawdbot.com/remote) with token/password auth.
|
||||||
- [Nix mode](https://docs.clawd.bot/install/nix) for declarative config; [Docker](https://docs.clawd.bot/install/docker)-based installs.
|
- [Nix mode](https://docs.clawdbot.com/nix) for declarative config; [Docker](https://docs.clawdbot.com/docker)-based installs.
|
||||||
- [Doctor](https://docs.clawd.bot/gateway/doctor) migrations, [logging](https://docs.clawd.bot/logging).
|
- [Doctor](https://docs.clawdbot.com/doctor) migrations, [logging](https://docs.clawdbot.com/logging).
|
||||||
|
|
||||||
## How it works (short)
|
## How it works (short)
|
||||||
|
|
||||||
```
|
```
|
||||||
WhatsApp / Telegram / Slack / Discord / Signal / iMessage / BlueBubbles / Microsoft Teams / Matrix / Zalo / Zalo Personal / WebChat
|
WhatsApp / Telegram / Slack / Discord / Signal / iMessage / WebChat
|
||||||
│
|
│
|
||||||
▼
|
▼
|
||||||
┌───────────────────────────────┐
|
┌───────────────────────────────┐
|
||||||
│ Gateway │
|
│ Gateway │ ws://127.0.0.1:18789
|
||||||
│ (control plane) │
|
│ (control plane) │ bridge: tcp://0.0.0.0:18790
|
||||||
│ ws://127.0.0.1:18789 │
|
|
||||||
└──────────────┬────────────────┘
|
└──────────────┬────────────────┘
|
||||||
│
|
│
|
||||||
├─ Pi agent (RPC)
|
├─ Pi agent (RPC)
|
||||||
├─ CLI (clawdbot …)
|
├─ CLI (clawdbot …)
|
||||||
├─ WebChat UI
|
├─ WebChat UI
|
||||||
├─ macOS app
|
├─ macOS app
|
||||||
└─ iOS / Android nodes
|
└─ iOS/Android nodes
|
||||||
```
|
```
|
||||||
|
|
||||||
## Key subsystems
|
## Key subsystems
|
||||||
|
|
||||||
- **[Gateway WebSocket network](https://docs.clawd.bot/concepts/architecture)** — single WS control plane for clients, tools, and events (plus ops: [Gateway runbook](https://docs.clawd.bot/gateway)).
|
- **[Gateway WebSocket network](https://docs.clawdbot.com/architecture)** — single WS control plane for clients, tools, and events (plus ops: [Gateway runbook](https://docs.clawdbot.com/gateway)).
|
||||||
- **[Tailscale exposure](https://docs.clawd.bot/gateway/tailscale)** — Serve/Funnel for the Gateway dashboard + WS (remote access: [Remote](https://docs.clawd.bot/gateway/remote)).
|
- **[Tailscale exposure](https://docs.clawdbot.com/tailscale)** — Serve/Funnel for the Gateway dashboard + WS (remote access: [Remote](https://docs.clawdbot.com/remote)).
|
||||||
- **[Browser control](https://docs.clawd.bot/tools/browser)** — clawd‑managed Chrome/Chromium with CDP control.
|
- **[Browser control](https://docs.clawdbot.com/browser)** — clawd‑managed Chrome/Chromium with CDP control.
|
||||||
- **[Canvas + A2UI](https://docs.clawd.bot/platforms/mac/canvas)** — agent‑driven visual workspace (A2UI host: [Canvas/A2UI](https://docs.clawd.bot/platforms/mac/canvas#canvas-a2ui)).
|
- **[Canvas + A2UI](https://docs.clawdbot.com/mac/canvas)** — agent‑driven visual workspace (A2UI host: [Canvas/A2UI](https://docs.clawdbot.com/refactor/canvas-a2ui)).
|
||||||
- **[Voice Wake](https://docs.clawd.bot/nodes/voicewake) + [Talk Mode](https://docs.clawd.bot/nodes/talk)** — always‑on speech and continuous conversation.
|
- **[Voice Wake](https://docs.clawdbot.com/voicewake) + [Talk Mode](https://docs.clawdbot.com/talk)** — always‑on speech and continuous conversation.
|
||||||
- **[Nodes](https://docs.clawd.bot/nodes)** — Canvas, camera snap/clip, screen record, `location.get`, notifications, plus macOS‑only `system.run`/`system.notify`.
|
- **[Nodes](https://docs.clawdbot.com/nodes)** — Canvas, camera snap/clip, screen record, `location.get`, notifications, plus macOS‑only `system.run`/`system.notify`.
|
||||||
|
|
||||||
## Tailscale access (Gateway dashboard)
|
## Tailscale access (Gateway dashboard)
|
||||||
|
|
||||||
@@ -199,17 +161,17 @@ Notes:
|
|||||||
- Funnel refuses to start unless `gateway.auth.mode: "password"` is set.
|
- Funnel refuses to start unless `gateway.auth.mode: "password"` is set.
|
||||||
- Optional: `gateway.tailscale.resetOnExit` to undo Serve/Funnel on shutdown.
|
- Optional: `gateway.tailscale.resetOnExit` to undo Serve/Funnel on shutdown.
|
||||||
|
|
||||||
Details: [Tailscale guide](https://docs.clawd.bot/gateway/tailscale) · [Web surfaces](https://docs.clawd.bot/web)
|
Details: [Tailscale guide](https://docs.clawdbot.com/tailscale) · [Web surfaces](https://docs.clawdbot.com/web)
|
||||||
|
|
||||||
## Remote Gateway (Linux is great)
|
## Remote Gateway (Linux is great)
|
||||||
|
|
||||||
It’s perfectly fine to run the Gateway on a small Linux instance. Clients (macOS app, CLI, WebChat) can connect over **Tailscale Serve/Funnel** or **SSH tunnels**, and you can still pair device nodes (macOS/iOS/Android) to execute device‑local actions when needed.
|
It’s perfectly fine to run the Gateway on a small Linux instance. Clients (macOS app, CLI, WebChat) can connect over **Tailscale Serve/Funnel** or **SSH tunnels**, and you can still pair device nodes (macOS/iOS/Android) to execute device‑local actions when needed.
|
||||||
|
|
||||||
- **Gateway host** runs the exec tool and channel connections by default.
|
- **Gateway host** runs the bash tool and provider connections by default.
|
||||||
- **Device nodes** run device‑local actions (`system.run`, camera, screen recording, notifications) via `node.invoke`.
|
- **Device nodes** run device‑local actions (`system.run`, camera, screen recording, notifications) via `node.invoke`.
|
||||||
In short: exec runs where the Gateway lives; device actions run where the device lives.
|
In short: bash runs where the Gateway lives; device actions run where the device lives.
|
||||||
|
|
||||||
Details: [Remote access](https://docs.clawd.bot/gateway/remote) · [Nodes](https://docs.clawd.bot/nodes) · [Security](https://docs.clawd.bot/gateway/security)
|
Details: [Remote access](https://docs.clawdbot.com/remote) · [Nodes](https://docs.clawdbot.com/nodes) · [Security](https://docs.clawdbot.com/security)
|
||||||
|
|
||||||
## macOS permissions via the Gateway protocol
|
## macOS permissions via the Gateway protocol
|
||||||
|
|
||||||
@@ -224,7 +186,7 @@ Elevated bash (host permissions) is separate from macOS TCC:
|
|||||||
- Use `/elevated on|off` to toggle per‑session elevated access when enabled + allowlisted.
|
- Use `/elevated on|off` to toggle per‑session elevated access when enabled + allowlisted.
|
||||||
- Gateway persists the per‑session toggle via `sessions.patch` (WS method) alongside `thinkingLevel`, `verboseLevel`, `model`, `sendPolicy`, and `groupActivation`.
|
- Gateway persists the per‑session toggle via `sessions.patch` (WS method) alongside `thinkingLevel`, `verboseLevel`, `model`, `sendPolicy`, and `groupActivation`.
|
||||||
|
|
||||||
Details: [Nodes](https://docs.clawd.bot/nodes) · [macOS app](https://docs.clawd.bot/platforms/macos) · [Gateway protocol](https://docs.clawd.bot/concepts/architecture)
|
Details: [Nodes](https://docs.clawdbot.com/nodes) · [macOS app](https://docs.clawdbot.com/macos) · [Gateway protocol](https://docs.clawdbot.com/architecture)
|
||||||
|
|
||||||
## Agent to Agent (sessions_* tools)
|
## Agent to Agent (sessions_* tools)
|
||||||
|
|
||||||
@@ -233,28 +195,26 @@ Details: [Nodes](https://docs.clawd.bot/nodes) · [macOS app](https://docs.clawd
|
|||||||
- `sessions_history` — fetch transcript logs for a session.
|
- `sessions_history` — fetch transcript logs for a session.
|
||||||
- `sessions_send` — message another session; optional reply‑back ping‑pong + announce step (`REPLY_SKIP`, `ANNOUNCE_SKIP`).
|
- `sessions_send` — message another session; optional reply‑back ping‑pong + announce step (`REPLY_SKIP`, `ANNOUNCE_SKIP`).
|
||||||
|
|
||||||
Details: [Session tools](https://docs.clawd.bot/concepts/session-tool)
|
Details: [Session tools](https://docs.clawdbot.com/session-tool)
|
||||||
|
|
||||||
## Skills registry (ClawdHub)
|
## Skills registry (ClawdHub)
|
||||||
|
|
||||||
ClawdHub is a minimal skill registry. With ClawdHub enabled, the agent can search for skills automatically and pull in new ones as needed.
|
ClawdHub is a minimal skill registry. With ClawdHub enabled, the agent can search for skills automatically and pull in new ones as needed.
|
||||||
|
|
||||||
[ClawdHub](https://ClawdHub.com)
|
https://ClawdHub.com
|
||||||
|
|
||||||
## Chat commands
|
## Chat commands
|
||||||
|
|
||||||
Send these in WhatsApp/Telegram/Slack/Microsoft Teams/WebChat (group commands are owner-only):
|
Send these in WhatsApp/Telegram/Slack/WebChat (group commands are owner-only):
|
||||||
|
|
||||||
- `/status` — compact session status (model + tokens, cost when available)
|
- `/status` — health + session info (group shows activation mode)
|
||||||
- `/new` or `/reset` — reset the session
|
- `/new` or `/reset` — reset the session
|
||||||
- `/compact` — compact session context (summary)
|
- `/think <level>` — off|minimal|low|medium|high
|
||||||
- `/think <level>` — off|minimal|low|medium|high|xhigh (GPT-5.2 + Codex models only)
|
|
||||||
- `/verbose on|off`
|
- `/verbose on|off`
|
||||||
- `/usage off|tokens|full` — per-response usage footer
|
|
||||||
- `/restart` — restart the gateway (owner-only in groups)
|
- `/restart` — restart the gateway (owner-only in groups)
|
||||||
- `/activation mention|always` — group activation toggle (groups only)
|
- `/activation mention|always` — group activation toggle (groups only)
|
||||||
|
|
||||||
## Apps (optional)
|
## macOS app (optional)
|
||||||
|
|
||||||
The Gateway alone delivers a great experience. All apps are optional and add extra features.
|
The Gateway alone delivers a great experience. All apps are optional and add extra features.
|
||||||
|
|
||||||
@@ -280,17 +240,17 @@ Note: signed builds required for macOS permissions to stick across rebuilds (see
|
|||||||
- Voice trigger forwarding + Canvas surface.
|
- Voice trigger forwarding + Canvas surface.
|
||||||
- Controlled via `clawdbot nodes …`.
|
- Controlled via `clawdbot nodes …`.
|
||||||
|
|
||||||
Runbook: [iOS connect](https://docs.clawd.bot/platforms/ios).
|
Runbook: [iOS connect](https://docs.clawdbot.com/ios).
|
||||||
|
|
||||||
### Android node (optional)
|
### Android node (optional)
|
||||||
|
|
||||||
- Pairs via the same Bridge + pairing flow as iOS.
|
- Pairs via the same Bridge + pairing flow as iOS.
|
||||||
- Exposes Canvas, Camera, and Screen capture commands.
|
- Exposes Canvas, Camera, and Screen capture commands.
|
||||||
- Runbook: [Android connect](https://docs.clawd.bot/platforms/android).
|
- Runbook: [Android connect](https://docs.clawdbot.com/android).
|
||||||
|
|
||||||
## Agent workspace + skills
|
## Agent workspace + skills
|
||||||
|
|
||||||
- Workspace root: `~/clawd` (configurable via `agents.defaults.workspace`).
|
- Workspace root: `~/clawd` (configurable via `agent.workspace`).
|
||||||
- Injected prompt files: `AGENTS.md`, `SOUL.md`, `TOOLS.md`.
|
- Injected prompt files: `AGENTS.md`, `SOUL.md`, `TOOLS.md`.
|
||||||
- Skills: `~/clawd/skills/<skill>/SKILL.md`.
|
- Skills: `~/clawd/skills/<skill>/SKILL.md`.
|
||||||
|
|
||||||
@@ -306,71 +266,60 @@ Minimal `~/.clawdbot/clawdbot.json` (model + defaults):
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
[Full configuration reference (all keys + examples).](https://docs.clawd.bot/gateway/configuration)
|
[Full configuration reference (all keys + examples).](https://docs.clawdbot.com/configuration)
|
||||||
|
|
||||||
## Security model (important)
|
## Security model (important)
|
||||||
|
|
||||||
- **Default:** tools run on the host for the **main** session, so the agent has full access when it’s just you.
|
- **Default:** tools run on the host for the **main** session, so the agent has full access when it’s just you.
|
||||||
- **Group/channel safety:** set `agents.defaults.sandbox.mode: "non-main"` to run **non‑main sessions** (groups/channels) inside per‑session Docker sandboxes; bash then runs in Docker for those sessions.
|
- **Group/channel safety:** set `agent.sandbox.mode: "non-main"` to run **non‑main sessions** (groups/channels) inside per‑session Docker sandboxes; bash then runs in Docker for those sessions.
|
||||||
- **Sandbox defaults:** allowlist `bash`, `process`, `read`, `write`, `edit`, `sessions_list`, `sessions_history`, `sessions_send`, `sessions_spawn`; denylist `browser`, `canvas`, `nodes`, `cron`, `discord`, `gateway`.
|
- **Sandbox defaults:** allowlist `bash`, `process`, `read`, `write`, `edit`; denylist `browser`, `canvas`, `nodes`, `cron`, `discord`, `gateway`.
|
||||||
|
|
||||||
Details: [Security guide](https://docs.clawd.bot/gateway/security) · [Docker + sandboxing](https://docs.clawd.bot/install/docker) · [Sandbox config](https://docs.clawd.bot/gateway/configuration)
|
Details: [Security guide](https://docs.clawdbot.com/security) · [Docker + sandboxing](https://docs.clawdbot.com/docker) · [Sandbox config](https://docs.clawdbot.com/configuration)
|
||||||
|
|
||||||
### [WhatsApp](https://docs.clawd.bot/channels/whatsapp)
|
### [WhatsApp](https://docs.clawdbot.com/whatsapp)
|
||||||
|
|
||||||
- Link the device: `pnpm clawdbot channels login` (stores creds in `~/.clawdbot/credentials`).
|
- Link the device: `pnpm clawdbot login` (stores creds in `~/.clawdbot/credentials`).
|
||||||
- Allowlist who can talk to the assistant via `channels.whatsapp.allowFrom`.
|
- Allowlist who can talk to the assistant via `whatsapp.allowFrom`.
|
||||||
- If `channels.whatsapp.groups` is set, it becomes a group allowlist; include `"*"` to allow all.
|
|
||||||
|
|
||||||
### [Telegram](https://docs.clawd.bot/channels/telegram)
|
### [Telegram](https://docs.clawdbot.com/telegram)
|
||||||
|
|
||||||
- Set `TELEGRAM_BOT_TOKEN` or `channels.telegram.botToken` (env wins).
|
- Set `TELEGRAM_BOT_TOKEN` or `telegram.botToken` (env wins).
|
||||||
- Optional: set `channels.telegram.groups` (with `channels.telegram.groups."*".requireMention`); when set, it is a group allowlist (include `"*"` to allow all). Also `channels.telegram.allowFrom` or `channels.telegram.webhookUrl` as needed.
|
- Optional: set `telegram.groups` (with `telegram.groups."*".requireMention`), `telegram.allowFrom`, or `telegram.webhookUrl` as needed.
|
||||||
|
|
||||||
```json5
|
```json5
|
||||||
{
|
{
|
||||||
channels: {
|
telegram: {
|
||||||
telegram: {
|
botToken: "123456:ABCDEF"
|
||||||
botToken: "123456:ABCDEF"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
### [Slack](https://docs.clawd.bot/channels/slack)
|
### [Slack](https://docs.clawdbot.com/slack)
|
||||||
|
|
||||||
- Set `SLACK_BOT_TOKEN` + `SLACK_APP_TOKEN` (or `channels.slack.botToken` + `channels.slack.appToken`).
|
- Set `SLACK_BOT_TOKEN` + `SLACK_APP_TOKEN` (or `slack.botToken` + `slack.appToken`).
|
||||||
|
|
||||||
### [Discord](https://docs.clawd.bot/channels/discord)
|
### [Discord](https://docs.clawdbot.com/discord)
|
||||||
|
|
||||||
- Set `DISCORD_BOT_TOKEN` or `channels.discord.token` (env wins).
|
- Set `DISCORD_BOT_TOKEN` or `discord.token` (env wins).
|
||||||
- Optional: set `commands.native`, `commands.text`, or `commands.useAccessGroups`, plus `channels.discord.dm.allowFrom`, `channels.discord.guilds`, or `channels.discord.mediaMaxMb` as needed.
|
- Optional: set `discord.slashCommand`, `discord.dm.allowFrom`, `discord.guilds`, or `discord.mediaMaxMb` as needed.
|
||||||
|
|
||||||
```json5
|
```json5
|
||||||
{
|
{
|
||||||
channels: {
|
discord: {
|
||||||
discord: {
|
token: "1234abcd"
|
||||||
token: "1234abcd"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
### [Signal](https://docs.clawd.bot/channels/signal)
|
### [Signal](https://docs.clawdbot.com/signal)
|
||||||
|
|
||||||
- Requires `signal-cli` and a `channels.signal` config section.
|
- Requires `signal-cli` and a `signal` config section.
|
||||||
|
|
||||||
### [iMessage](https://docs.clawd.bot/channels/imessage)
|
### [iMessage](https://docs.clawdbot.com/imessage)
|
||||||
|
|
||||||
- macOS only; Messages must be signed in.
|
- macOS only; Messages must be signed in.
|
||||||
- If `channels.imessage.groups` is set, it becomes a group allowlist; include `"*"` to allow all.
|
|
||||||
|
|
||||||
### [Microsoft Teams](https://docs.clawd.bot/channels/msteams)
|
### [WebChat](https://docs.clawdbot.com/webchat)
|
||||||
|
|
||||||
- Configure a Teams app + Bot Framework, then add a `msteams` config section.
|
|
||||||
- Allowlist who can talk via `msteams.allowFrom`; group access via `msteams.groupAllowFrom` or `msteams.groupPolicy: "open"`.
|
|
||||||
|
|
||||||
### [WebChat](https://docs.clawd.bot/web/webchat)
|
|
||||||
|
|
||||||
- Uses the Gateway WebSocket; no separate WebChat port/config.
|
- Uses the Gateway WebSocket; no separate WebChat port/config.
|
||||||
|
|
||||||
@@ -389,111 +338,96 @@ Browser control (optional):
|
|||||||
## Docs
|
## Docs
|
||||||
|
|
||||||
Use these when you’re past the onboarding flow and want the deeper reference.
|
Use these when you’re past the onboarding flow and want the deeper reference.
|
||||||
- [Start with the docs index for navigation and “what’s where.”](https://docs.clawd.bot)
|
- [Start with the docs index for navigation and “what’s where.”](https://docs.clawdbot.com/)
|
||||||
- [Read the architecture overview for the gateway + protocol model.](https://docs.clawd.bot/concepts/architecture)
|
- [Read the architecture overview for the gateway + protocol model.](https://docs.clawdbot.com/architecture)
|
||||||
- [Use the full configuration reference when you need every key and example.](https://docs.clawd.bot/gateway/configuration)
|
- [Use the full configuration reference when you need every key and example.](https://docs.clawdbot.com/configuration)
|
||||||
- [Run the Gateway by the book with the operational runbook.](https://docs.clawd.bot/gateway)
|
- [Run the Gateway by the book with the operational runbook.](https://docs.clawdbot.com/gateway)
|
||||||
- [Learn how the Control UI/Web surfaces work and how to expose them safely.](https://docs.clawd.bot/web)
|
- [Learn how the Control UI/Web surfaces work and how to expose them safely.](https://docs.clawdbot.com/web)
|
||||||
- [Understand remote access over SSH tunnels or tailnets.](https://docs.clawd.bot/gateway/remote)
|
- [Understand remote access over SSH tunnels or tailnets.](https://docs.clawdbot.com/remote)
|
||||||
- [Follow the onboarding wizard flow for a guided setup.](https://docs.clawd.bot/start/wizard)
|
- [Follow the onboarding wizard flow for a guided setup.](https://docs.clawdbot.com/wizard)
|
||||||
- [Wire external triggers via the webhook surface.](https://docs.clawd.bot/automation/webhook)
|
- [Wire external triggers via the webhook surface.](https://docs.clawdbot.com/webhook)
|
||||||
- [Set up Gmail Pub/Sub triggers.](https://docs.clawd.bot/automation/gmail-pubsub)
|
- [Set up Gmail Pub/Sub triggers.](https://docs.clawdbot.com/gmail-pubsub)
|
||||||
- [Learn the macOS menu bar companion details.](https://docs.clawd.bot/platforms/mac/menu-bar)
|
- [Learn the macOS menu bar companion details.](https://docs.clawdbot.com/mac/menu-bar)
|
||||||
- [Platform guides: Windows (WSL2)](https://docs.clawd.bot/platforms/windows), [Linux](https://docs.clawd.bot/platforms/linux), [macOS](https://docs.clawd.bot/platforms/macos), [iOS](https://docs.clawd.bot/platforms/ios), [Android](https://docs.clawd.bot/platforms/android)
|
- [Platform guides: Windows](https://docs.clawdbot.com/windows), [Linux](https://docs.clawdbot.com/linux), [macOS](https://docs.clawdbot.com/macos), [iOS](https://docs.clawdbot.com/ios), [Android](https://docs.clawdbot.com/android)
|
||||||
- [Debug common failures with the troubleshooting guide.](https://docs.clawd.bot/channels/troubleshooting)
|
- [Debug common failures with the troubleshooting guide.](https://docs.clawdbot.com/troubleshooting)
|
||||||
- [Review security guidance before exposing anything.](https://docs.clawd.bot/gateway/security)
|
- [Review security guidance before exposing anything.](https://docs.clawdbot.com/security)
|
||||||
|
|
||||||
## Advanced docs (discovery + control)
|
## Advanced docs (discovery + control)
|
||||||
|
|
||||||
- [Discovery + transports](https://docs.clawd.bot/gateway/discovery)
|
- [Discovery + transports](https://docs.clawdbot.com/discovery)
|
||||||
- [Bonjour/mDNS](https://docs.clawd.bot/gateway/bonjour)
|
- [Bonjour/mDNS](https://docs.clawdbot.com/bonjour)
|
||||||
- [Gateway pairing](https://docs.clawd.bot/gateway/pairing)
|
- [Gateway pairing](https://docs.clawdbot.com/gateway/pairing)
|
||||||
- [Remote gateway README](https://docs.clawd.bot/gateway/remote-gateway-readme)
|
- [Remote gateway README](https://docs.clawdbot.com/remote-gateway-readme)
|
||||||
- [Control UI](https://docs.clawd.bot/web/control-ui)
|
- [Control UI](https://docs.clawdbot.com/control-ui)
|
||||||
- [Dashboard](https://docs.clawd.bot/web/dashboard)
|
- [Dashboard](https://docs.clawdbot.com/dashboard)
|
||||||
|
|
||||||
## Operations & troubleshooting
|
## Operations & troubleshooting
|
||||||
|
|
||||||
- [Health checks](https://docs.clawd.bot/gateway/health)
|
- [Health checks](https://docs.clawdbot.com/health)
|
||||||
- [Gateway lock](https://docs.clawd.bot/gateway/gateway-lock)
|
- [Gateway lock](https://docs.clawdbot.com/gateway-lock)
|
||||||
- [Background process](https://docs.clawd.bot/gateway/background-process)
|
- [Background process](https://docs.clawdbot.com/background-process)
|
||||||
- [Browser troubleshooting (Linux)](https://docs.clawd.bot/tools/browser-linux-troubleshooting)
|
- [Browser troubleshooting (Linux)](https://docs.clawdbot.com/browser-linux-troubleshooting)
|
||||||
- [Logging](https://docs.clawd.bot/logging)
|
- [Logging](https://docs.clawdbot.com/logging)
|
||||||
|
|
||||||
## Deep dives
|
## Deep dives
|
||||||
|
|
||||||
- [Agent loop](https://docs.clawd.bot/concepts/agent-loop)
|
- [Agent loop](https://docs.clawdbot.com/agent-loop)
|
||||||
- [Presence](https://docs.clawd.bot/concepts/presence)
|
- [Presence](https://docs.clawdbot.com/presence)
|
||||||
- [TypeBox schemas](https://docs.clawd.bot/concepts/typebox)
|
- [TypeBox schemas](https://docs.clawdbot.com/typebox)
|
||||||
- [RPC adapters](https://docs.clawd.bot/reference/rpc)
|
- [RPC adapters](https://docs.clawdbot.com/rpc)
|
||||||
- [Queue](https://docs.clawd.bot/concepts/queue)
|
- [Queue](https://docs.clawdbot.com/queue)
|
||||||
|
|
||||||
## Workspace & skills
|
## Workspace & skills
|
||||||
|
|
||||||
- [Skills config](https://docs.clawd.bot/tools/skills-config)
|
- [Skills config](https://docs.clawdbot.com/skills-config)
|
||||||
- [Default AGENTS](https://docs.clawd.bot/reference/AGENTS.default)
|
- [Default AGENTS](https://docs.clawdbot.com/AGENTS.default)
|
||||||
- [Templates: AGENTS](https://docs.clawd.bot/reference/templates/AGENTS)
|
- [Templates: AGENTS](https://docs.clawdbot.com/templates/AGENTS)
|
||||||
- [Templates: BOOTSTRAP](https://docs.clawd.bot/reference/templates/BOOTSTRAP)
|
- [Templates: BOOTSTRAP](https://docs.clawdbot.com/templates/BOOTSTRAP)
|
||||||
- [Templates: IDENTITY](https://docs.clawd.bot/reference/templates/IDENTITY)
|
- [Templates: IDENTITY](https://docs.clawdbot.com/templates/IDENTITY)
|
||||||
- [Templates: SOUL](https://docs.clawd.bot/reference/templates/SOUL)
|
- [Templates: SOUL](https://docs.clawdbot.com/templates/SOUL)
|
||||||
- [Templates: TOOLS](https://docs.clawd.bot/reference/templates/TOOLS)
|
- [Templates: TOOLS](https://docs.clawdbot.com/templates/TOOLS)
|
||||||
- [Templates: USER](https://docs.clawd.bot/reference/templates/USER)
|
- [Templates: USER](https://docs.clawdbot.com/templates/USER)
|
||||||
|
|
||||||
## Platform internals
|
## Platform internals
|
||||||
|
|
||||||
- [macOS dev setup](https://docs.clawd.bot/platforms/mac/dev-setup)
|
- [macOS dev setup](https://docs.clawdbot.com/mac/dev-setup)
|
||||||
- [macOS menu bar](https://docs.clawd.bot/platforms/mac/menu-bar)
|
- [macOS menu bar](https://docs.clawdbot.com/mac/menu-bar)
|
||||||
- [macOS voice wake](https://docs.clawd.bot/platforms/mac/voicewake)
|
- [macOS voice wake](https://docs.clawdbot.com/mac/voicewake)
|
||||||
- [iOS node](https://docs.clawd.bot/platforms/ios)
|
- [iOS node](https://docs.clawdbot.com/ios)
|
||||||
- [Android node](https://docs.clawd.bot/platforms/android)
|
- [Android node](https://docs.clawdbot.com/android)
|
||||||
- [Windows (WSL2)](https://docs.clawd.bot/platforms/windows)
|
- [Windows app](https://docs.clawdbot.com/windows)
|
||||||
- [Linux app](https://docs.clawd.bot/platforms/linux)
|
- [Linux app](https://docs.clawdbot.com/linux)
|
||||||
|
|
||||||
## Email hooks (Gmail)
|
## Email hooks (Gmail)
|
||||||
|
|
||||||
- [docs.clawd.bot/gmail-pubsub](https://docs.clawd.bot/automation/gmail-pubsub)
|
[Gmail Pub/Sub wiring (gcloud + gogcli), hook tokens, and auto-watch behavior are documented here.](https://docs.clawdbot.com/gmail-pubsub)
|
||||||
|
|
||||||
|
Gateway auto-starts the watcher when `hooks.enabled=true` and `hooks.gmail.account` is set; `clawdbot hooks gmail run` is the manual daemon wrapper if you don’t want auto-start.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
clawdbot hooks gmail setup --account you@gmail.com
|
||||||
|
clawdbot hooks gmail run
|
||||||
|
```
|
||||||
|
|
||||||
## Clawd
|
## Clawd
|
||||||
|
|
||||||
Clawdbot was built for **Clawd**, a space lobster AI assistant. 🦞
|
Clawdbot was built for **Clawd**, a space lobster AI assistant. 🦞
|
||||||
by Peter Steinberger and the community.
|
by Peter Steinberger and the community.
|
||||||
|
|
||||||
- [clawd.me](https://clawd.me)
|
- https://clawd.me
|
||||||
- [soul.md](https://soul.md)
|
- https://soul.md
|
||||||
- [steipete.me](https://steipete.me)
|
- https://steipete.me
|
||||||
|
|
||||||
## Community
|
## Community
|
||||||
|
|
||||||
See [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines, maintainers, and how to submit PRs.
|
See [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines, maintainers, and how to submit PRs.
|
||||||
AI/vibe-coded PRs welcome! 🤖
|
AI/vibe-coded PRs welcome! 🤖
|
||||||
|
|
||||||
Special thanks to @andrewting19 for the Anthropic OAuth tool-name fix.
|
|
||||||
|
|
||||||
Core contributors:
|
|
||||||
- @cpojer — Telegram onboarding UX + docs
|
|
||||||
|
|
||||||
Thanks to all clawtributors:
|
Thanks to all clawtributors:
|
||||||
|
|
||||||
<p align="left">
|
<p align="left">
|
||||||
<a href="https://github.com/steipete"><img src="https://avatars.githubusercontent.com/u/58493?v=4&s=48" width="48" height="48" alt="steipete" title="steipete"/></a> <a href="https://github.com/bohdanpodvirnyi"><img src="https://avatars.githubusercontent.com/u/31819391?v=4&s=48" width="48" height="48" alt="bohdanpodvirnyi" title="bohdanpodvirnyi"/></a> <a href="https://github.com/joaohlisboa"><img src="https://avatars.githubusercontent.com/u/8200873?v=4&s=48" width="48" height="48" alt="joaohlisboa" title="joaohlisboa"/></a> <a href="https://github.com/mneves75"><img src="https://avatars.githubusercontent.com/u/2423436?v=4&s=48" width="48" height="48" alt="mneves75" title="mneves75"/></a> <a href="https://github.com/MatthieuBizien"><img src="https://avatars.githubusercontent.com/u/173090?v=4&s=48" width="48" height="48" alt="MatthieuBizien" title="MatthieuBizien"/></a> <a href="https://github.com/rahthakor"><img src="https://avatars.githubusercontent.com/u/8470553?v=4&s=48" width="48" height="48" alt="rahthakor" title="rahthakor"/></a> <a href="https://github.com/vrknetha"><img src="https://avatars.githubusercontent.com/u/20596261?v=4&s=48" width="48" height="48" alt="vrknetha" title="vrknetha"/></a> <a href="https://github.com/radek-paclt"><img src="https://avatars.githubusercontent.com/u/50451445?v=4&s=48" width="48" height="48" alt="radek-paclt" title="radek-paclt"/></a> <a href="https://github.com/joshp123"><img src="https://avatars.githubusercontent.com/u/1497361?v=4&s=48" width="48" height="48" alt="joshp123" title="joshp123"/></a> <a href="https://github.com/mukhtharcm"><img src="https://avatars.githubusercontent.com/u/56378562?v=4&s=48" width="48" height="48" alt="mukhtharcm" title="mukhtharcm"/></a>
|
<a href="https://github.com/steipete"><img src="https://avatars.githubusercontent.com/u/58493?v=4&s=48" width="48" height="48" alt="steipete" title="steipete"/></a> <a href="https://github.com/thewilloftheshadow"><img src="https://avatars.githubusercontent.com/u/35580099?v=4&s=48" width="48" height="48" alt="thewilloftheshadow" title="thewilloftheshadow"/></a> <a href="https://github.com/mcinteerj"><img src="https://avatars.githubusercontent.com/u/3613653?v=4&s=48" width="48" height="48" alt="mcinteerj" title="mcinteerj"/></a> <a href="https://github.com/joshp123"><img src="https://avatars.githubusercontent.com/u/1497361?v=4&s=48" width="48" height="48" alt="joshp123" title="joshp123"/></a> <a href="https://github.com/joaohlisboa"><img src="https://avatars.githubusercontent.com/u/8200873?v=4&s=48" width="48" height="48" alt="joaohlisboa" title="joaohlisboa"/></a> <a href="https://github.com/petter-b"><img src="https://avatars.githubusercontent.com/u/62076402?v=4&s=48" width="48" height="48" alt="petter-b" title="petter-b"/></a> <a href="https://github.com/mukhtharcm"><img src="https://avatars.githubusercontent.com/u/56378562?v=4&s=48" width="48" height="48" alt="mukhtharcm" title="mukhtharcm"/></a> <a href="https://github.com/dan-dr"><img src="https://avatars.githubusercontent.com/u/6669808?v=4&s=48" width="48" height="48" alt="dan-dr" title="dan-dr"/></a> <a href="https://github.com/Nachx639"><img src="https://avatars.githubusercontent.com/u/71144023?v=4&s=48" width="48" height="48" alt="Nachx639" title="Nachx639"/></a> <a href="https://github.com/jeffersonwarrior"><img src="https://avatars.githubusercontent.com/u/89030989?v=4&s=48" width="48" height="48" alt="jeffersonwarrior" title="jeffersonwarrior"/></a>
|
||||||
<a href="https://github.com/maxsumrall"><img src="https://avatars.githubusercontent.com/u/628843?v=4&s=48" width="48" height="48" alt="maxsumrall" title="maxsumrall"/></a> <a href="https://github.com/xadenryan"><img src="https://avatars.githubusercontent.com/u/165437834?v=4&s=48" width="48" height="48" alt="xadenryan" title="xadenryan"/></a> <a href="https://github.com/tobiasbischoff"><img src="https://avatars.githubusercontent.com/u/711564?v=4&s=48" width="48" height="48" alt="Tobias Bischoff" title="Tobias Bischoff"/></a> <a href="https://github.com/juanpablodlc"><img src="https://avatars.githubusercontent.com/u/92012363?v=4&s=48" width="48" height="48" alt="juanpablodlc" title="juanpablodlc"/></a> <a href="https://github.com/hsrvc"><img src="https://avatars.githubusercontent.com/u/129702169?v=4&s=48" width="48" height="48" alt="hsrvc" title="hsrvc"/></a> <a href="https://github.com/magimetal"><img src="https://avatars.githubusercontent.com/u/36491250?v=4&s=48" width="48" height="48" alt="magimetal" title="magimetal"/></a> <a href="https://github.com/meaningfool"><img src="https://avatars.githubusercontent.com/u/2862331?v=4&s=48" width="48" height="48" alt="meaningfool" title="meaningfool"/></a> <a href="https://github.com/NicholasSpisak"><img src="https://avatars.githubusercontent.com/u/129075147?v=4&s=48" width="48" height="48" alt="NicholasSpisak" title="NicholasSpisak"/></a> <a href="https://github.com/AbhisekBasu1"><img src="https://avatars.githubusercontent.com/u/40645221?v=4&s=48" width="48" height="48" alt="abhisekbasu1" title="abhisekbasu1"/></a> <a href="https://github.com/claude"><img src="https://avatars.githubusercontent.com/u/81847?v=4&s=48" width="48" height="48" alt="claude" title="claude"/></a>
|
<a href="https://github.com/mbelinky"><img src="https://avatars.githubusercontent.com/u/132747814?v=4&s=48" width="48" height="48" alt="mbelinky" title="mbelinky"/></a> <a href="https://github.com/julianengel"><img src="https://avatars.githubusercontent.com/u/10634231?v=4&s=48" width="48" height="48" alt="julianengel" title="julianengel"/></a> <a href="https://github.com/CashWilliams"><img src="https://avatars.githubusercontent.com/u/613573?v=4&s=48" width="48" height="48" alt="CashWilliams" title="CashWilliams"/></a> <a href="https://github.com/omniwired"><img src="https://avatars.githubusercontent.com/u/322761?v=4&s=48" width="48" height="48" alt="omniwired" title="omniwired"/></a> <a href="https://github.com/jverdi"><img src="https://avatars.githubusercontent.com/u/345050?v=4&s=48" width="48" height="48" alt="jverdi" title="jverdi"/></a> <a href="https://github.com/Syhids"><img src="https://avatars.githubusercontent.com/u/671202?v=4&s=48" width="48" height="48" alt="Syhids" title="Syhids"/></a> <a href="https://github.com/meaningfool"><img src="https://avatars.githubusercontent.com/u/2862331?v=4&s=48" width="48" height="48" alt="meaningfool" title="meaningfool"/></a> <a href="https://github.com/rafaelreis-r"><img src="https://avatars.githubusercontent.com/u/57492577?v=4&s=48" width="48" height="48" alt="rafaelreis-r" title="rafaelreis-r"/></a> <a href="https://github.com/wstock"><img src="https://avatars.githubusercontent.com/u/1394687?v=4&s=48" width="48" height="48" alt="wstock" title="wstock"/></a> <a href="https://github.com/vsabavat"><img src="https://avatars.githubusercontent.com/u/50385532?v=4&s=48" width="48" height="48" alt="vsabavat" title="vsabavat"/></a>
|
||||||
<a href="https://github.com/jamesgroat"><img src="https://avatars.githubusercontent.com/u/2634024?v=4&s=48" width="48" height="48" alt="jamesgroat" title="jamesgroat"/></a> <a href="https://github.com/sebslight"><img src="https://avatars.githubusercontent.com/u/19554889?v=4&s=48" width="48" height="48" alt="sebslight" title="sebslight"/></a> <a href="https://github.com/Hyaxia"><img src="https://avatars.githubusercontent.com/u/36747317?v=4&s=48" width="48" height="48" alt="Hyaxia" title="Hyaxia"/></a> <a href="https://github.com/dantelex"><img src="https://avatars.githubusercontent.com/u/631543?v=4&s=48" width="48" height="48" alt="dantelex" title="dantelex"/></a> <a href="https://github.com/daveonkels"><img src="https://avatars.githubusercontent.com/u/533642?v=4&s=48" width="48" height="48" alt="daveonkels" title="daveonkels"/></a> <a href="https://github.com/mteam88"><img src="https://avatars.githubusercontent.com/u/84196639?v=4&s=48" width="48" height="48" alt="mteam88" title="mteam88"/></a> <a href="https://github.com/omniwired"><img src="https://avatars.githubusercontent.com/u/322761?v=4&s=48" width="48" height="48" alt="Eng. Juan Combetto" title="Eng. Juan Combetto"/></a> <a href="https://github.com/dbhurley"><img src="https://avatars.githubusercontent.com/u/5251425?v=4&s=48" width="48" height="48" alt="dbhurley" title="dbhurley"/></a> <a href="https://github.com/mbelinky"><img src="https://avatars.githubusercontent.com/u/132747814?v=4&s=48" width="48" height="48" alt="Mariano Belinky" title="Mariano Belinky"/></a> <a href="https://github.com/TSavo"><img src="https://avatars.githubusercontent.com/u/877990?v=4&s=48" width="48" height="48" alt="TSavo" title="TSavo"/></a>
|
<a href="https://github.com/scald"><img src="https://avatars.githubusercontent.com/u/1215913?v=4&s=48" width="48" height="48" alt="scald" title="scald"/></a> <a href="https://github.com/sreekaransrinath"><img src="https://avatars.githubusercontent.com/u/50989977?v=4&s=48" width="48" height="48" alt="sreekaransrinath" title="sreekaransrinath"/></a> <a href="https://github.com/ratulsarna"><img src="https://avatars.githubusercontent.com/u/105903728?v=4&s=48" width="48" height="48" alt="ratulsarna" title="ratulsarna"/></a> <a href="https://github.com/osolmaz"><img src="https://avatars.githubusercontent.com/u/2453968?v=4&s=48" width="48" height="48" alt="osolmaz" title="osolmaz"/></a> <a href="https://github.com/conhecendocontato"><img src="https://avatars.githubusercontent.com/u/82890727?v=4&s=48" width="48" height="48" alt="conhecendocontato" title="conhecendocontato"/></a> <a href="https://github.com/hrdwdmrbl"><img src="https://avatars.githubusercontent.com/u/554881?v=4&s=48" width="48" height="48" alt="hrdwdmrbl" title="hrdwdmrbl"/></a> <a href="https://github.com/jayhickey"><img src="https://avatars.githubusercontent.com/u/1676460?v=4&s=48" width="48" height="48" alt="jayhickey" title="jayhickey"/></a> <a href="https://github.com/jamesgroat"><img src="https://avatars.githubusercontent.com/u/2634024?v=4&s=48" width="48" height="48" alt="jamesgroat" title="jamesgroat"/></a> <a href="https://github.com/gtsifrikas"><img src="https://avatars.githubusercontent.com/u/8904378?v=4&s=48" width="48" height="48" alt="gtsifrikas" title="gtsifrikas"/></a> <a href="https://github.com/djangonavarro220"><img src="https://avatars.githubusercontent.com/u/251162586?v=4&s=48" width="48" height="48" alt="djangonavarro220" title="djangonavarro220"/></a>
|
||||||
<a href="https://github.com/julianengel"><img src="https://avatars.githubusercontent.com/u/10634231?v=4&s=48" width="48" height="48" alt="julianengel" title="julianengel"/></a> <a href="https://github.com/benithors"><img src="https://avatars.githubusercontent.com/u/20652882?v=4&s=48" width="48" height="48" alt="benithors" title="benithors"/></a> <a href="https://github.com/timolins"><img src="https://avatars.githubusercontent.com/u/1440854?v=4&s=48" width="48" height="48" alt="timolins" title="timolins"/></a> <a href="https://github.com/Nachx639"><img src="https://avatars.githubusercontent.com/u/71144023?v=4&s=48" width="48" height="48" alt="nachx639" title="nachx639"/></a> <a href="https://github.com/sreekaransrinath"><img src="https://avatars.githubusercontent.com/u/50989977?v=4&s=48" width="48" height="48" alt="sreekaransrinath" title="sreekaransrinath"/></a> <a href="https://github.com/gupsammy"><img src="https://avatars.githubusercontent.com/u/20296019?v=4&s=48" width="48" height="48" alt="gupsammy" title="gupsammy"/></a> <a href="https://github.com/cristip73"><img src="https://avatars.githubusercontent.com/u/24499421?v=4&s=48" width="48" height="48" alt="cristip73" title="cristip73"/></a> <a href="https://github.com/nachoiacovino"><img src="https://avatars.githubusercontent.com/u/50103937?v=4&s=48" width="48" height="48" alt="nachoiacovino" title="nachoiacovino"/></a> <a href="https://github.com/vsabavat"><img src="https://avatars.githubusercontent.com/u/50385532?v=4&s=48" width="48" height="48" alt="Vasanth Rao Naik Sabavat" title="Vasanth Rao Naik Sabavat"/></a> <a href="https://github.com/cpojer"><img src="https://avatars.githubusercontent.com/u/13352?v=4&s=48" width="48" height="48" alt="cpojer" title="cpojer"/></a>
|
<a href="https://github.com/azade-c"><img src="https://avatars.githubusercontent.com/u/252790079?v=4&s=48" width="48" height="48" alt="azade-c" title="azade-c"/></a> <a href="https://github.com/andranik-sahakyan"><img src="https://avatars.githubusercontent.com/u/8908029?v=4&s=48" width="48" height="48" alt="andranik-sahakyan" title="andranik-sahakyan"/></a>
|
||||||
<a href="https://github.com/lc0rp"><img src="https://avatars.githubusercontent.com/u/2609441?v=4&s=48" width="48" height="48" alt="lc0rp" title="lc0rp"/></a> <a href="https://github.com/scald"><img src="https://avatars.githubusercontent.com/u/1215913?v=4&s=48" width="48" height="48" alt="scald" title="scald"/></a> <a href="https://github.com/andranik-sahakyan"><img src="https://avatars.githubusercontent.com/u/8908029?v=4&s=48" width="48" height="48" alt="andranik-sahakyan" title="andranik-sahakyan"/></a> <a href="https://github.com/davidguttman"><img src="https://avatars.githubusercontent.com/u/431696?v=4&s=48" width="48" height="48" alt="davidguttman" title="davidguttman"/></a> <a href="https://github.com/sleontenko"><img src="https://avatars.githubusercontent.com/u/7135949?v=4&s=48" width="48" height="48" alt="sleontenko" title="sleontenko"/></a> <a href="https://github.com/sircrumpet"><img src="https://avatars.githubusercontent.com/u/4436535?v=4&s=48" width="48" height="48" alt="sircrumpet" title="sircrumpet"/></a> <a href="https://github.com/peschee"><img src="https://avatars.githubusercontent.com/u/63866?v=4&s=48" width="48" height="48" alt="peschee" title="peschee"/></a> <a href="https://github.com/rafaelreis-r"><img src="https://avatars.githubusercontent.com/u/57492577?v=4&s=48" width="48" height="48" alt="rafaelreis-r" title="rafaelreis-r"/></a> <a href="https://github.com/thewilloftheshadow"><img src="https://avatars.githubusercontent.com/u/35580099?v=4&s=48" width="48" height="48" alt="thewilloftheshadow" title="thewilloftheshadow"/></a> <a href="https://github.com/ratulsarna"><img src="https://avatars.githubusercontent.com/u/105903728?v=4&s=48" width="48" height="48" alt="ratulsarna" title="ratulsarna"/></a>
|
|
||||||
<a href="https://github.com/lutr0"><img src="https://avatars.githubusercontent.com/u/76906369?v=4&s=48" width="48" height="48" alt="lutr0" title="lutr0"/></a> <a href="https://github.com/danielz1z"><img src="https://avatars.githubusercontent.com/u/235270390?v=4&s=48" width="48" height="48" alt="danielz1z" title="danielz1z"/></a> <a href="https://github.com/gumadeiras"><img src="https://avatars.githubusercontent.com/u/5599352?v=4&s=48" width="48" height="48" alt="gumadeiras" title="gumadeiras"/></a> <a href="https://github.com/emanuelst"><img src="https://avatars.githubusercontent.com/u/9994339?v=4&s=48" width="48" height="48" alt="emanuelst" title="emanuelst"/></a> <a href="https://github.com/KristijanJovanovski"><img src="https://avatars.githubusercontent.com/u/8942284?v=4&s=48" width="48" height="48" alt="KristijanJovanovski" title="KristijanJovanovski"/></a> <a href="https://github.com/CashWilliams"><img src="https://avatars.githubusercontent.com/u/613573?v=4&s=48" width="48" height="48" alt="CashWilliams" title="CashWilliams"/></a> <a href="https://github.com/rdev"><img src="https://avatars.githubusercontent.com/u/8418866?v=4&s=48" width="48" height="48" alt="rdev" title="rdev"/></a> <a href="https://github.com/osolmaz"><img src="https://avatars.githubusercontent.com/u/2453968?v=4&s=48" width="48" height="48" alt="osolmaz" title="osolmaz"/></a> <a href="https://github.com/joshrad-dev"><img src="https://avatars.githubusercontent.com/u/62785552?v=4&s=48" width="48" height="48" alt="joshrad-dev" title="joshrad-dev"/></a> <a href="https://github.com/kiranjd"><img src="https://avatars.githubusercontent.com/u/25822851?v=4&s=48" width="48" height="48" alt="kiranjd" title="kiranjd"/></a>
|
|
||||||
<a href="https://github.com/adityashaw2"><img src="https://avatars.githubusercontent.com/u/41204444?v=4&s=48" width="48" height="48" alt="adityashaw2" title="adityashaw2"/></a> <a href="https://github.com/search?q=sheeek"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="sheeek" title="sheeek"/></a> <a href="https://github.com/artuskg"><img src="https://avatars.githubusercontent.com/u/11966157?v=4&s=48" width="48" height="48" alt="artuskg" title="artuskg"/></a> <a href="https://github.com/onutc"><img src="https://avatars.githubusercontent.com/u/152018508?v=4&s=48" width="48" height="48" alt="onutc" title="onutc"/></a> <a href="https://github.com/ManuelHettich"><img src="https://avatars.githubusercontent.com/u/17690367?v=4&s=48" width="48" height="48" alt="manuelhettich" title="manuelhettich"/></a> <a href="https://github.com/minghinmatthewlam"><img src="https://avatars.githubusercontent.com/u/14224566?v=4&s=48" width="48" height="48" alt="minghinmatthewlam" title="minghinmatthewlam"/></a> <a href="https://github.com/myfunc"><img src="https://avatars.githubusercontent.com/u/19294627?v=4&s=48" width="48" height="48" alt="myfunc" title="myfunc"/></a> <a href="https://github.com/buddyh"><img src="https://avatars.githubusercontent.com/u/31752869?v=4&s=48" width="48" height="48" alt="buddyh" title="buddyh"/></a> <a href="https://github.com/connorshea"><img src="https://avatars.githubusercontent.com/u/2977353?v=4&s=48" width="48" height="48" alt="connorshea" title="connorshea"/></a> <a href="https://github.com/mcinteerj"><img src="https://avatars.githubusercontent.com/u/3613653?v=4&s=48" width="48" height="48" alt="mcinteerj" title="mcinteerj"/></a>
|
|
||||||
<a href="https://github.com/timkrase"><img src="https://avatars.githubusercontent.com/u/38947626?v=4&s=48" width="48" height="48" alt="timkrase" title="timkrase"/></a> <a href="https://github.com/zerone0x"><img src="https://avatars.githubusercontent.com/u/39543393?v=4&s=48" width="48" height="48" alt="zerone0x" title="zerone0x"/></a> <a href="https://github.com/gerardward2007"><img src="https://avatars.githubusercontent.com/u/3002155?v=4&s=48" width="48" height="48" alt="gerardward2007" title="gerardward2007"/></a> <a href="https://github.com/obviyus"><img src="https://avatars.githubusercontent.com/u/22031114?v=4&s=48" width="48" height="48" alt="obviyus" title="obviyus"/></a> <a href="https://github.com/tosh-hamburg"><img src="https://avatars.githubusercontent.com/u/58424326?v=4&s=48" width="48" height="48" alt="tosh-hamburg" title="tosh-hamburg"/></a> <a href="https://github.com/azade-c"><img src="https://avatars.githubusercontent.com/u/252790079?v=4&s=48" width="48" height="48" alt="azade-c" title="azade-c"/></a> <a href="https://github.com/roshanasingh4"><img src="https://avatars.githubusercontent.com/u/88576930?v=4&s=48" width="48" height="48" alt="roshanasingh4" title="roshanasingh4"/></a> <a href="https://github.com/bjesuiter"><img src="https://avatars.githubusercontent.com/u/2365676?v=4&s=48" width="48" height="48" alt="bjesuiter" title="bjesuiter"/></a> <a href="https://github.com/cheeeee"><img src="https://avatars.githubusercontent.com/u/21245729?v=4&s=48" width="48" height="48" alt="cheeeee" title="cheeeee"/></a> <a href="https://github.com/j1philli"><img src="https://avatars.githubusercontent.com/u/3744255?v=4&s=48" width="48" height="48" alt="Josh Phillips" title="Josh Phillips"/></a>
|
|
||||||
<a href="https://github.com/YuriNachos"><img src="https://avatars.githubusercontent.com/u/19365375?v=4&s=48" width="48" height="48" alt="YuriNachos" title="YuriNachos"/></a> <a href="https://github.com/tyler6204"><img src="https://avatars.githubusercontent.com/u/64381258?v=4&s=48" width="48" height="48" alt="tyler6204" title="tyler6204"/></a> <a href="https://github.com/superman32432432"><img src="https://avatars.githubusercontent.com/u/7228420?v=4&s=48" width="48" height="48" alt="superman32432432" title="superman32432432"/></a> <a href="https://github.com/search?q=Yurii%20Chukhlib"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Yurii Chukhlib" title="Yurii Chukhlib"/></a> <a href="https://github.com/antons"><img src="https://avatars.githubusercontent.com/u/129705?v=4&s=48" width="48" height="48" alt="antons" title="antons"/></a> <a href="https://github.com/austinm911"><img src="https://avatars.githubusercontent.com/u/31991302?v=4&s=48" width="48" height="48" alt="austinm911" title="austinm911"/></a> <a href="https://github.com/apps/blacksmith-sh"><img src="https://avatars.githubusercontent.com/in/807020?v=4&s=48" width="48" height="48" alt="blacksmith-sh[bot]" title="blacksmith-sh[bot]"/></a> <a href="https://github.com/dan-dr"><img src="https://avatars.githubusercontent.com/u/6669808?v=4&s=48" width="48" height="48" alt="dan-dr" title="dan-dr"/></a> <a href="https://github.com/grp06"><img src="https://avatars.githubusercontent.com/u/1573959?v=4&s=48" width="48" height="48" alt="grp06" title="grp06"/></a> <a href="https://github.com/HeimdallStrategy"><img src="https://avatars.githubusercontent.com/u/223014405?v=4&s=48" width="48" height="48" alt="HeimdallStrategy" title="HeimdallStrategy"/></a>
|
|
||||||
<a href="https://github.com/imfing"><img src="https://avatars.githubusercontent.com/u/5097752?v=4&s=48" width="48" height="48" alt="imfing" title="imfing"/></a> <a href="https://github.com/jalehman"><img src="https://avatars.githubusercontent.com/u/550978?v=4&s=48" width="48" height="48" alt="jalehman" title="jalehman"/></a> <a href="https://github.com/jarvis-medmatic"><img src="https://avatars.githubusercontent.com/u/252428873?v=4&s=48" width="48" height="48" alt="jarvis-medmatic" title="jarvis-medmatic"/></a> <a href="https://github.com/kkarimi"><img src="https://avatars.githubusercontent.com/u/875218?v=4&s=48" width="48" height="48" alt="kkarimi" title="kkarimi"/></a> <a href="https://github.com/mahmoudashraf93"><img src="https://avatars.githubusercontent.com/u/9130129?v=4&s=48" width="48" height="48" alt="mahmoudashraf93" title="mahmoudashraf93"/></a> <a href="https://github.com/petter-b"><img src="https://avatars.githubusercontent.com/u/62076402?v=4&s=48" width="48" height="48" alt="petter-b" title="petter-b"/></a> <a href="https://github.com/pkrmf"><img src="https://avatars.githubusercontent.com/u/1714267?v=4&s=48" width="48" height="48" alt="pkrmf" title="pkrmf"/></a> <a href="https://github.com/RandyVentures"><img src="https://avatars.githubusercontent.com/u/149904821?v=4&s=48" width="48" height="48" alt="RandyVentures" title="RandyVentures"/></a> <a href="https://github.com/erikpr1994"><img src="https://avatars.githubusercontent.com/u/6299331?v=4&s=48" width="48" height="48" alt="erikpr1994" title="erikpr1994"/></a> <a href="https://github.com/jonasjancarik"><img src="https://avatars.githubusercontent.com/u/2459191?v=4&s=48" width="48" height="48" alt="jonasjancarik" title="jonasjancarik"/></a>
|
|
||||||
<a href="https://github.com/search?q=Keith%20the%20Silly%20Goose"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Keith the Silly Goose" title="Keith the Silly Goose"/></a> <a href="https://github.com/search?q=L36%20Server"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="L36 Server" title="L36 Server"/></a> <a href="https://github.com/search?q=Marc"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Marc" title="Marc"/></a> <a href="https://github.com/mitschabaude-bot"><img src="https://avatars.githubusercontent.com/u/247582884?v=4&s=48" width="48" height="48" alt="mitschabaude-bot" title="mitschabaude-bot"/></a> <a href="https://github.com/neist"><img src="https://avatars.githubusercontent.com/u/1029724?v=4&s=48" width="48" height="48" alt="neist" title="neist"/></a> <a href="https://github.com/ngutman"><img src="https://avatars.githubusercontent.com/u/1540134?v=4&s=48" width="48" height="48" alt="ngutman" title="ngutman"/></a> <a href="https://github.com/chrisrodz"><img src="https://avatars.githubusercontent.com/u/2967620?v=4&s=48" width="48" height="48" alt="chrisrodz" title="chrisrodz"/></a> <a href="https://github.com/search?q=Friederike%20Seiler"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Friederike Seiler" title="Friederike Seiler"/></a> <a href="https://github.com/gabriel-trigo"><img src="https://avatars.githubusercontent.com/u/38991125?v=4&s=48" width="48" height="48" alt="gabriel-trigo" title="gabriel-trigo"/></a> <a href="https://github.com/Iamadig"><img src="https://avatars.githubusercontent.com/u/102129234?v=4&s=48" width="48" height="48" alt="iamadig" title="iamadig"/></a>
|
|
||||||
<a href="https://github.com/search?q=Kit"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Kit" title="Kit"/></a> <a href="https://github.com/koala73"><img src="https://avatars.githubusercontent.com/u/996596?v=4&s=48" width="48" height="48" alt="koala73" title="koala73"/></a> <a href="https://github.com/manmal"><img src="https://avatars.githubusercontent.com/u/142797?v=4&s=48" width="48" height="48" alt="manmal" title="manmal"/></a> <a href="https://github.com/ogulcancelik"><img src="https://avatars.githubusercontent.com/u/7064011?v=4&s=48" width="48" height="48" alt="ogulcancelik" title="ogulcancelik"/></a> <a href="https://github.com/pasogott"><img src="https://avatars.githubusercontent.com/u/23458152?v=4&s=48" width="48" height="48" alt="pasogott" title="pasogott"/></a> <a href="https://github.com/petradonka"><img src="https://avatars.githubusercontent.com/u/7353770?v=4&s=48" width="48" height="48" alt="petradonka" title="petradonka"/></a> <a href="https://github.com/rubyrunsstuff"><img src="https://avatars.githubusercontent.com/u/246602379?v=4&s=48" width="48" height="48" alt="rubyrunsstuff" title="rubyrunsstuff"/></a> <a href="https://github.com/VACInc"><img src="https://avatars.githubusercontent.com/u/3279061?v=4&s=48" width="48" height="48" alt="VACInc" title="VACInc"/></a> <a href="https://github.com/wes-davis"><img src="https://avatars.githubusercontent.com/u/16506720?v=4&s=48" width="48" height="48" alt="wes-davis" title="wes-davis"/></a> <a href="https://github.com/zats"><img src="https://avatars.githubusercontent.com/u/2688806?v=4&s=48" width="48" height="48" alt="zats" title="zats"/></a>
|
|
||||||
<a href="https://github.com/search?q=Chris%20Taylor"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Chris Taylor" title="Chris Taylor"/></a> <a href="https://github.com/djangonavarro220"><img src="https://avatars.githubusercontent.com/u/251162586?v=4&s=48" width="48" height="48" alt="Django Navarro" title="Django Navarro"/></a> <a href="https://github.com/evalexpr"><img src="https://avatars.githubusercontent.com/u/23485511?v=4&s=48" width="48" height="48" alt="evalexpr" title="evalexpr"/></a> <a href="https://github.com/henrino3"><img src="https://avatars.githubusercontent.com/u/4260288?v=4&s=48" width="48" height="48" alt="henrino3" title="henrino3"/></a> <a href="https://github.com/mkbehr"><img src="https://avatars.githubusercontent.com/u/1285?v=4&s=48" width="48" height="48" alt="mkbehr" title="mkbehr"/></a> <a href="https://github.com/oswalpalash"><img src="https://avatars.githubusercontent.com/u/6431196?v=4&s=48" width="48" height="48" alt="oswalpalash" title="oswalpalash"/></a> <a href="https://github.com/pcty-nextgen-service-account"><img src="https://avatars.githubusercontent.com/u/112553441?v=4&s=48" width="48" height="48" alt="pcty-nextgen-service-account" title="pcty-nextgen-service-account"/></a> <a href="https://github.com/sibbl"><img src="https://avatars.githubusercontent.com/u/866535?v=4&s=48" width="48" height="48" alt="sibbl" title="sibbl"/></a> <a href="https://github.com/Syhids"><img src="https://avatars.githubusercontent.com/u/671202?v=4&s=48" width="48" height="48" alt="Syhids" title="Syhids"/></a> <a href="https://github.com/search?q=Aaron%20Konyer"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Aaron Konyer" title="Aaron Konyer"/></a>
|
|
||||||
<a href="https://github.com/adam91holt"><img src="https://avatars.githubusercontent.com/u/9592417?v=4&s=48" width="48" height="48" alt="adam91holt" title="adam91holt"/></a> <a href="https://github.com/erik-agens"><img src="https://avatars.githubusercontent.com/u/80908960?v=4&s=48" width="48" height="48" alt="erik-agens" title="erik-agens"/></a> <a href="https://github.com/fcatuhe"><img src="https://avatars.githubusercontent.com/u/17382215?v=4&s=48" width="48" height="48" alt="fcatuhe" title="fcatuhe"/></a> <a href="https://github.com/ivanrvpereira"><img src="https://avatars.githubusercontent.com/u/183991?v=4&s=48" width="48" height="48" alt="ivanrvpereira" title="ivanrvpereira"/></a> <a href="https://github.com/jayhickey"><img src="https://avatars.githubusercontent.com/u/1676460?v=4&s=48" width="48" height="48" alt="jayhickey" title="jayhickey"/></a> <a href="https://github.com/jeffersonwarrior"><img src="https://avatars.githubusercontent.com/u/89030989?v=4&s=48" width="48" height="48" alt="jeffersonwarrior" title="jeffersonwarrior"/></a> <a href="https://github.com/search?q=jeffersonwarrior"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="jeffersonwarrior" title="jeffersonwarrior"/></a> <a href="https://github.com/jdrhyne"><img src="https://avatars.githubusercontent.com/u/7828464?v=4&s=48" width="48" height="48" alt="Jonathan D. Rhyne (DJ-D)" title="Jonathan D. Rhyne (DJ-D)"/></a> <a href="https://github.com/jverdi"><img src="https://avatars.githubusercontent.com/u/345050?v=4&s=48" width="48" height="48" alt="jverdi" title="jverdi"/></a> <a href="https://github.com/mickahouan"><img src="https://avatars.githubusercontent.com/u/31423109?v=4&s=48" width="48" height="48" alt="mickahouan" title="mickahouan"/></a>
|
|
||||||
<a href="https://github.com/mjrussell"><img src="https://avatars.githubusercontent.com/u/1641895?v=4&s=48" width="48" height="48" alt="mjrussell" title="mjrussell"/></a> <a href="https://github.com/p6l-richard"><img src="https://avatars.githubusercontent.com/u/18185649?v=4&s=48" width="48" height="48" alt="p6l-richard" title="p6l-richard"/></a> <a href="https://github.com/philipp-spiess"><img src="https://avatars.githubusercontent.com/u/458591?v=4&s=48" width="48" height="48" alt="philipp-spiess" title="philipp-spiess"/></a> <a href="https://github.com/robaxelsen"><img src="https://avatars.githubusercontent.com/u/13132899?v=4&s=48" width="48" height="48" alt="robaxelsen" title="robaxelsen"/></a> <a href="https://github.com/search?q=Sash%20Catanzarite"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Sash Catanzarite" title="Sash Catanzarite"/></a> <a href="https://github.com/search?q=VAC"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="VAC" title="VAC"/></a> <a href="https://github.com/zknicker"><img src="https://avatars.githubusercontent.com/u/1164085?v=4&s=48" width="48" height="48" alt="zknicker" title="zknicker"/></a> <a href="https://github.com/search?q=alejandro%20maza"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="alejandro maza" title="alejandro maza"/></a> <a href="https://github.com/andrewting19"><img src="https://avatars.githubusercontent.com/u/10536704?v=4&s=48" width="48" height="48" alt="andrewting19" title="andrewting19"/></a> <a href="https://github.com/anpoirier"><img src="https://avatars.githubusercontent.com/u/1245729?v=4&s=48" width="48" height="48" alt="anpoirier" title="anpoirier"/></a>
|
|
||||||
<a href="https://github.com/Asleep123"><img src="https://avatars.githubusercontent.com/u/122379135?v=4&s=48" width="48" height="48" alt="Asleep123" title="Asleep123"/></a> <a href="https://github.com/bolismauro"><img src="https://avatars.githubusercontent.com/u/771999?v=4&s=48" width="48" height="48" alt="bolismauro" title="bolismauro"/></a> <a href="https://github.com/cash-echo-bot"><img src="https://avatars.githubusercontent.com/u/252747386?v=4&s=48" width="48" height="48" alt="cash-echo-bot" title="cash-echo-bot"/></a> <a href="https://github.com/search?q=Clawd"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Clawd" title="Clawd"/></a> <a href="https://github.com/conhecendocontato"><img src="https://avatars.githubusercontent.com/u/82890727?v=4&s=48" width="48" height="48" alt="conhecendocontato" title="conhecendocontato"/></a> <a href="https://github.com/search?q=Dimitrios%20Ploutarchos"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Dimitrios Ploutarchos" title="Dimitrios Ploutarchos"/></a> <a href="https://github.com/search?q=Drake%20Thomsen"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Drake Thomsen" title="Drake Thomsen"/></a> <a href="https://github.com/search?q=Ghost"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Ghost" title="Ghost"/></a> <a href="https://github.com/gtsifrikas"><img src="https://avatars.githubusercontent.com/u/8904378?v=4&s=48" width="48" height="48" alt="gtsifrikas" title="gtsifrikas"/></a> <a href="https://github.com/HazAT"><img src="https://avatars.githubusercontent.com/u/363802?v=4&s=48" width="48" height="48" alt="HazAT" title="HazAT"/></a>
|
|
||||||
<a href="https://github.com/hrdwdmrbl"><img src="https://avatars.githubusercontent.com/u/554881?v=4&s=48" width="48" height="48" alt="hrdwdmrbl" title="hrdwdmrbl"/></a> <a href="https://github.com/hugobarauna"><img src="https://avatars.githubusercontent.com/u/2719?v=4&s=48" width="48" height="48" alt="hugobarauna" title="hugobarauna"/></a> <a href="https://github.com/search?q=Jamie%20Openshaw"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Jamie Openshaw" title="Jamie Openshaw"/></a> <a href="https://github.com/search?q=Jarvis"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Jarvis" title="Jarvis"/></a> <a href="https://github.com/search?q=Jefferson%20Nunn"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Jefferson Nunn" title="Jefferson Nunn"/></a> <a href="https://github.com/search?q=Kevin%20Lin"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Kevin Lin" title="Kevin Lin"/></a> <a href="https://github.com/kitze"><img src="https://avatars.githubusercontent.com/u/1160594?v=4&s=48" width="48" height="48" alt="kitze" title="kitze"/></a> <a href="https://github.com/levifig"><img src="https://avatars.githubusercontent.com/u/1605?v=4&s=48" width="48" height="48" alt="levifig" title="levifig"/></a> <a href="https://github.com/search?q=Lloyd"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Lloyd" title="Lloyd"/></a> <a href="https://github.com/longmaba"><img src="https://avatars.githubusercontent.com/u/9361500?v=4&s=48" width="48" height="48" alt="longmaba" title="longmaba"/></a>
|
|
||||||
<a href="https://github.com/loukotal"><img src="https://avatars.githubusercontent.com/u/18210858?v=4&s=48" width="48" height="48" alt="loukotal" title="loukotal"/></a> <a href="https://github.com/martinpucik"><img src="https://avatars.githubusercontent.com/u/5503097?v=4&s=48" width="48" height="48" alt="martinpucik" title="martinpucik"/></a> <a href="https://github.com/search?q=Miles"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Miles" title="Miles"/></a> <a href="https://github.com/mrdbstn"><img src="https://avatars.githubusercontent.com/u/58957632?v=4&s=48" width="48" height="48" alt="mrdbstn" title="mrdbstn"/></a> <a href="https://github.com/MSch"><img src="https://avatars.githubusercontent.com/u/7475?v=4&s=48" width="48" height="48" alt="MSch" title="MSch"/></a> <a href="https://github.com/search?q=Mustafa%20Tag%20Eldeen"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Mustafa Tag Eldeen" title="Mustafa Tag Eldeen"/></a> <a href="https://github.com/ndraiman"><img src="https://avatars.githubusercontent.com/u/12609607?v=4&s=48" width="48" height="48" alt="ndraiman" title="ndraiman"/></a> <a href="https://github.com/nexty5870"><img src="https://avatars.githubusercontent.com/u/3869659?v=4&s=48" width="48" height="48" alt="nexty5870" title="nexty5870"/></a> <a href="https://github.com/prathamdby"><img src="https://avatars.githubusercontent.com/u/134331217?v=4&s=48" width="48" height="48" alt="prathamdby" title="prathamdby"/></a> <a href="https://github.com/reeltimeapps"><img src="https://avatars.githubusercontent.com/u/637338?v=4&s=48" width="48" height="48" alt="reeltimeapps" title="reeltimeapps"/></a>
|
|
||||||
<a href="https://github.com/RLTCmpe"><img src="https://avatars.githubusercontent.com/u/10762242?v=4&s=48" width="48" height="48" alt="RLTCmpe" title="RLTCmpe"/></a> <a href="https://github.com/rodrigouroz"><img src="https://avatars.githubusercontent.com/u/384037?v=4&s=48" width="48" height="48" alt="rodrigouroz" title="rodrigouroz"/></a> <a href="https://github.com/search?q=Rolf%20Fredheim"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Rolf Fredheim" title="Rolf Fredheim"/></a> <a href="https://github.com/search?q=Rony%20Kelner"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Rony Kelner" title="Rony Kelner"/></a> <a href="https://github.com/search?q=Samrat%20Jha"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Samrat Jha" title="Samrat Jha"/></a> <a href="https://github.com/siraht"><img src="https://avatars.githubusercontent.com/u/73152895?v=4&s=48" width="48" height="48" alt="siraht" title="siraht"/></a> <a href="https://github.com/snopoke"><img src="https://avatars.githubusercontent.com/u/249606?v=4&s=48" width="48" height="48" alt="snopoke" title="snopoke"/></a> <a href="https://github.com/suminhthanh"><img src="https://avatars.githubusercontent.com/u/2907636?v=4&s=48" width="48" height="48" alt="suminhthanh" title="suminhthanh"/></a> <a href="https://github.com/search?q=The%20Admiral"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="The Admiral" title="The Admiral"/></a> <a href="https://github.com/thesash"><img src="https://avatars.githubusercontent.com/u/1166151?v=4&s=48" width="48" height="48" alt="thesash" title="thesash"/></a>
|
|
||||||
<a href="https://github.com/search?q=Ubuntu"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Ubuntu" title="Ubuntu"/></a> <a href="https://github.com/voidserf"><img src="https://avatars.githubusercontent.com/u/477673?v=4&s=48" width="48" height="48" alt="voidserf" title="voidserf"/></a> <a href="https://github.com/wstock"><img src="https://avatars.githubusercontent.com/u/1394687?v=4&s=48" width="48" height="48" alt="wstock" title="wstock"/></a> <a href="https://github.com/search?q=Zach%20Knickerbocker"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Zach Knickerbocker" title="Zach Knickerbocker"/></a> <a href="https://github.com/Alphonse-arianee"><img src="https://avatars.githubusercontent.com/u/254457365?v=4&s=48" width="48" height="48" alt="Alphonse-arianee" title="Alphonse-arianee"/></a> <a href="https://github.com/search?q=Azade"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Azade" title="Azade"/></a> <a href="https://github.com/carlulsoe"><img src="https://avatars.githubusercontent.com/u/34673973?v=4&s=48" width="48" height="48" alt="carlulsoe" title="carlulsoe"/></a> <a href="https://github.com/search?q=ddyo"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="ddyo" title="ddyo"/></a> <a href="https://github.com/search?q=Erik"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Erik" title="Erik"/></a> <a href="https://github.com/latitudeki5223"><img src="https://avatars.githubusercontent.com/u/119656367?v=4&s=48" width="48" height="48" alt="latitudeki5223" title="latitudeki5223"/></a>
|
|
||||||
<a href="https://github.com/search?q=Manuel%20Maly"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Manuel Maly" title="Manuel Maly"/></a> <a href="https://github.com/search?q=Mourad%20Boustani"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Mourad Boustani" title="Mourad Boustani"/></a> <a href="https://github.com/pcty-nextgen-ios-builder"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="pcty-nextgen-ios-builder" title="pcty-nextgen-ios-builder"/></a> <a href="https://github.com/search?q=Quentin"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Quentin" title="Quentin"/></a> <a href="https://github.com/search?q=Randy%20Torres"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Randy Torres" title="Randy Torres"/></a> <a href="https://github.com/rhjoh"><img src="https://avatars.githubusercontent.com/u/105699450?v=4&s=48" width="48" height="48" alt="rhjoh" title="rhjoh"/></a> <a href="https://github.com/ronak-guliani"><img src="https://avatars.githubusercontent.com/u/23518228?v=4&s=48" width="48" height="48" alt="ronak-guliani" title="ronak-guliani"/></a> <a href="https://github.com/search?q=William%20Stock"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="William Stock" title="William Stock"/></a>
|
|
||||||
</p>
|
</p>
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
# Security Policy
|
|
||||||
|
|
||||||
If you believe you’ve found a security issue in Clawdbot, please report it privately.
|
|
||||||
|
|
||||||
## Reporting
|
|
||||||
|
|
||||||
- Email: `steipete@gmail.com`
|
|
||||||
- What to include: reproduction steps, impact assessment, and (if possible) a minimal PoC.
|
|
||||||
|
|
||||||
## Operational Guidance
|
|
||||||
|
|
||||||
For threat model + hardening guidance (including `clawdbot security audit --deep` and `--fix`), see:
|
|
||||||
|
|
||||||
- `https://docs.clawd.bot/gateway/security`
|
|
||||||
|
|
||||||
@@ -6,7 +6,7 @@
|
|||||||
"kind" : "remoteSourceControl",
|
"kind" : "remoteSourceControl",
|
||||||
"location" : "https://github.com/steipete/ElevenLabsKit",
|
"location" : "https://github.com/steipete/ElevenLabsKit",
|
||||||
"state" : {
|
"state" : {
|
||||||
"revision" : "7e3c948d8340abe3977014f3de020edf221e9269",
|
"revision" : "c8679fbd37416a8780fe43be88a497ff16209e2d",
|
||||||
"version" : "0.1.0"
|
"version" : "0.1.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ let package = Package(
|
|||||||
.executable(name: "swabble", targets: ["SwabbleCLI"]),
|
.executable(name: "swabble", targets: ["SwabbleCLI"]),
|
||||||
],
|
],
|
||||||
dependencies: [
|
dependencies: [
|
||||||
.package(url: "https://github.com/steipete/Commander.git", exact: "0.2.1"),
|
.package(path: "../Peekaboo/Commander"),
|
||||||
.package(url: "https://github.com/apple/swift-testing", from: "0.99.0"),
|
.package(url: "https://github.com/apple/swift-testing", from: "0.99.0"),
|
||||||
],
|
],
|
||||||
targets: [
|
targets: [
|
||||||
|
|||||||
@@ -34,7 +34,8 @@ extension AttributedString {
|
|||||||
var ranges: [Range<AttributedString.Index>] = []
|
var ranges: [Range<AttributedString.Index>] = []
|
||||||
for wordRange in wordRanges {
|
for wordRange in wordRanges {
|
||||||
if let lastRange = ranges.last,
|
if let lastRange = ranges.last,
|
||||||
self[lastRange].characters.count + self[wordRange].characters.count <= maxLength {
|
self[lastRange].characters.count + self[wordRange].characters.count <= maxLength
|
||||||
|
{
|
||||||
ranges[ranges.count - 1] = lastRange.lowerBound..<wordRange.upperBound
|
ranges[ranges.count - 1] = lastRange.lowerBound..<wordRange.upperBound
|
||||||
} else {
|
} else {
|
||||||
ranges.append(wordRange)
|
ranges.append(wordRange)
|
||||||
|
|||||||
@@ -13,7 +13,8 @@ public actor TranscriptsStore {
|
|||||||
try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
|
try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
|
||||||
fileURL = dir.appendingPathComponent("transcripts.log")
|
fileURL = dir.appendingPathComponent("transcripts.log")
|
||||||
if let data = try? Data(contentsOf: fileURL),
|
if let data = try? Data(contentsOf: fileURL),
|
||||||
let text = String(data: data, encoding: .utf8) {
|
let text = String(data: data, encoding: .utf8)
|
||||||
|
{
|
||||||
entries = text.split(separator: "\n").map(String.init).suffix(limit)
|
entries = text.split(separator: "\n").map(String.init).suffix(limit)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ public struct WakeWordSegment: Sendable, Equatable {
|
|||||||
self.range = range
|
self.range = range
|
||||||
}
|
}
|
||||||
|
|
||||||
public var end: TimeInterval { start + duration }
|
public var end: TimeInterval { self.start + self.duration }
|
||||||
}
|
}
|
||||||
|
|
||||||
public struct WakeWordGateConfig: Sendable, Equatable {
|
public struct WakeWordGateConfig: Sendable, Equatable {
|
||||||
@@ -24,7 +24,8 @@ public struct WakeWordGateConfig: Sendable, Equatable {
|
|||||||
public init(
|
public init(
|
||||||
triggers: [String],
|
triggers: [String],
|
||||||
minPostTriggerGap: TimeInterval = 0.45,
|
minPostTriggerGap: TimeInterval = 0.45,
|
||||||
minCommandLength: Int = 1) {
|
minCommandLength: Int = 1)
|
||||||
|
{
|
||||||
self.triggers = triggers
|
self.triggers = triggers
|
||||||
self.minPostTriggerGap = minPostTriggerGap
|
self.minPostTriggerGap = minPostTriggerGap
|
||||||
self.minCommandLength = minCommandLength
|
self.minCommandLength = minCommandLength
|
||||||
@@ -56,24 +57,18 @@ public enum WakeWordGate {
|
|||||||
let tokens: [String]
|
let tokens: [String]
|
||||||
}
|
}
|
||||||
|
|
||||||
private struct MatchCandidate {
|
|
||||||
let index: Int
|
|
||||||
let triggerEnd: TimeInterval
|
|
||||||
let gap: TimeInterval
|
|
||||||
}
|
|
||||||
|
|
||||||
public static func match(
|
public static func match(
|
||||||
transcript: String,
|
transcript: String,
|
||||||
segments: [WakeWordSegment],
|
segments: [WakeWordSegment],
|
||||||
config: WakeWordGateConfig)
|
config: WakeWordGateConfig)
|
||||||
-> WakeWordGateMatch? {
|
-> WakeWordGateMatch? {
|
||||||
let triggerTokens = normalizeTriggers(config.triggers)
|
let triggerTokens = self.normalizeTriggers(config.triggers)
|
||||||
guard !triggerTokens.isEmpty else { return nil }
|
guard !triggerTokens.isEmpty else { return nil }
|
||||||
|
|
||||||
let tokens = normalizeSegments(segments)
|
let tokens = self.normalizeSegments(segments)
|
||||||
guard !tokens.isEmpty else { return nil }
|
guard !tokens.isEmpty else { return nil }
|
||||||
|
|
||||||
var best: MatchCandidate?
|
var best: (index: Int, triggerEnd: TimeInterval, gap: TimeInterval)?
|
||||||
|
|
||||||
for trigger in triggerTokens {
|
for trigger in triggerTokens {
|
||||||
let count = trigger.tokens.count
|
let count = trigger.tokens.count
|
||||||
@@ -89,12 +84,12 @@ public enum WakeWordGate {
|
|||||||
|
|
||||||
if let best, i <= best.index { continue }
|
if let best, i <= best.index { continue }
|
||||||
|
|
||||||
best = MatchCandidate(index: i, triggerEnd: triggerEnd, gap: gap)
|
best = (i, triggerEnd, gap)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
guard let best else { return nil }
|
guard let best else { return nil }
|
||||||
let command = commandText(transcript: transcript, segments: segments, triggerEndTime: best.triggerEnd)
|
let command = self.commandText(transcript: transcript, segments: segments, triggerEndTime: best.triggerEnd)
|
||||||
.trimmingCharacters(in: Self.whitespaceAndPunctuation)
|
.trimmingCharacters(in: Self.whitespaceAndPunctuation)
|
||||||
guard command.count >= config.minCommandLength else { return nil }
|
guard command.count >= config.minCommandLength else { return nil }
|
||||||
return WakeWordGateMatch(triggerEndTime: best.triggerEnd, postGap: best.gap, command: command)
|
return WakeWordGateMatch(triggerEndTime: best.triggerEnd, postGap: best.gap, command: command)
|
||||||
@@ -116,7 +111,7 @@ public enum WakeWordGate {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let text = segments
|
let text = segments
|
||||||
.filter { $0.start >= threshold && !normalizeToken($0.text).isEmpty }
|
.filter { $0.start >= threshold && !self.normalizeToken($0.text).isEmpty }
|
||||||
.map(\.text)
|
.map(\.text)
|
||||||
.joined(separator: " ")
|
.joined(separator: " ")
|
||||||
return text.trimmingCharacters(in: Self.whitespaceAndPunctuation)
|
return text.trimmingCharacters(in: Self.whitespaceAndPunctuation)
|
||||||
@@ -126,7 +121,7 @@ public enum WakeWordGate {
|
|||||||
guard !text.isEmpty else { return false }
|
guard !text.isEmpty else { return false }
|
||||||
let normalized = text.lowercased()
|
let normalized = text.lowercased()
|
||||||
for trigger in triggers {
|
for trigger in triggers {
|
||||||
let token = trigger.trimmingCharacters(in: whitespaceAndPunctuation).lowercased()
|
let token = trigger.trimmingCharacters(in: self.whitespaceAndPunctuation).lowercased()
|
||||||
if token.isEmpty { continue }
|
if token.isEmpty { continue }
|
||||||
if normalized.contains(token) { return true }
|
if normalized.contains(token) { return true }
|
||||||
}
|
}
|
||||||
@@ -136,11 +131,11 @@ public enum WakeWordGate {
|
|||||||
public static func stripWake(text: String, triggers: [String]) -> String {
|
public static func stripWake(text: String, triggers: [String]) -> String {
|
||||||
var out = text
|
var out = text
|
||||||
for trigger in triggers {
|
for trigger in triggers {
|
||||||
let token = trigger.trimmingCharacters(in: whitespaceAndPunctuation)
|
let token = trigger.trimmingCharacters(in: self.whitespaceAndPunctuation)
|
||||||
guard !token.isEmpty else { continue }
|
guard !token.isEmpty else { continue }
|
||||||
out = out.replacingOccurrences(of: token, with: "", options: [.caseInsensitive])
|
out = out.replacingOccurrences(of: token, with: "", options: [.caseInsensitive])
|
||||||
}
|
}
|
||||||
return out.trimmingCharacters(in: whitespaceAndPunctuation)
|
return out.trimmingCharacters(in: self.whitespaceAndPunctuation)
|
||||||
}
|
}
|
||||||
|
|
||||||
private static func normalizeTriggers(_ triggers: [String]) -> [TriggerTokens] {
|
private static func normalizeTriggers(_ triggers: [String]) -> [TriggerTokens] {
|
||||||
@@ -148,7 +143,7 @@ public enum WakeWordGate {
|
|||||||
for trigger in triggers {
|
for trigger in triggers {
|
||||||
let tokens = trigger
|
let tokens = trigger
|
||||||
.split(whereSeparator: { $0.isWhitespace })
|
.split(whereSeparator: { $0.isWhitespace })
|
||||||
.map { normalizeToken(String($0)) }
|
.map { self.normalizeToken(String($0)) }
|
||||||
.filter { !$0.isEmpty }
|
.filter { !$0.isEmpty }
|
||||||
if tokens.isEmpty { continue }
|
if tokens.isEmpty { continue }
|
||||||
output.append(TriggerTokens(tokens: tokens))
|
output.append(TriggerTokens(tokens: tokens))
|
||||||
@@ -158,7 +153,7 @@ public enum WakeWordGate {
|
|||||||
|
|
||||||
private static func normalizeSegments(_ segments: [WakeWordSegment]) -> [Token] {
|
private static func normalizeSegments(_ segments: [WakeWordSegment]) -> [Token] {
|
||||||
segments.compactMap { segment in
|
segments.compactMap { segment in
|
||||||
let normalized = normalizeToken(segment.text)
|
let normalized = self.normalizeToken(segment.text)
|
||||||
guard !normalized.isEmpty else { return nil }
|
guard !normalized.isEmpty else { return nil }
|
||||||
return Token(
|
return Token(
|
||||||
normalized: normalized,
|
normalized: normalized,
|
||||||
@@ -171,7 +166,7 @@ public enum WakeWordGate {
|
|||||||
|
|
||||||
private static func normalizeToken(_ token: String) -> String {
|
private static func normalizeToken(_ token: String) -> String {
|
||||||
token
|
token
|
||||||
.trimmingCharacters(in: whitespaceAndPunctuation)
|
.trimmingCharacters(in: self.whitespaceAndPunctuation)
|
||||||
.lowercased()
|
.lowercased()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ enum CLIRegistry {
|
|||||||
subcommands: [
|
subcommands: [
|
||||||
descriptor(for: ServiceInstall.self),
|
descriptor(for: ServiceInstall.self),
|
||||||
descriptor(for: ServiceUninstall.self),
|
descriptor(for: ServiceUninstall.self),
|
||||||
descriptor(for: ServiceStatus.self)
|
descriptor(for: ServiceStatus.self),
|
||||||
])
|
])
|
||||||
let doctorDesc = descriptor(for: DoctorCommand.self)
|
let doctorDesc = descriptor(for: DoctorCommand.self)
|
||||||
let setupDesc = descriptor(for: SetupCommand.self)
|
let setupDesc = descriptor(for: SetupCommand.self)
|
||||||
@@ -54,7 +54,7 @@ enum CLIRegistry {
|
|||||||
startDesc,
|
startDesc,
|
||||||
stopDesc,
|
stopDesc,
|
||||||
restartDesc,
|
restartDesc,
|
||||||
statusDesc
|
statusDesc,
|
||||||
])
|
])
|
||||||
return [root]
|
return [root]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ private enum LaunchdHelper {
|
|||||||
"Label": label,
|
"Label": label,
|
||||||
"ProgramArguments": [executable, "serve"],
|
"ProgramArguments": [executable, "serve"],
|
||||||
"RunAtLoad": true,
|
"RunAtLoad": true,
|
||||||
"KeepAlive": true
|
"KeepAlive": true,
|
||||||
]
|
]
|
||||||
let data = try PropertyListSerialization.data(fromPropertyList: plist, format: .xml, options: 0)
|
let data = try PropertyListSerialization.data(fromPropertyList: plist, format: .xml, options: 0)
|
||||||
try data.write(to: plistURL)
|
try data.write(to: plistURL)
|
||||||
|
|||||||
@@ -25,123 +25,78 @@ private func dispatch(invocation: CommandInvocation) async throws {
|
|||||||
|
|
||||||
switch first {
|
switch first {
|
||||||
case "swabble":
|
case "swabble":
|
||||||
try await dispatchSwabble(parsed: parsed, path: path)
|
guard path.count >= 2 else { throw CommanderProgramError.missingSubcommand(command: "swabble") }
|
||||||
|
let sub = path[1]
|
||||||
|
switch sub {
|
||||||
|
case "serve":
|
||||||
|
var cmd = ServeCommand(parsed: parsed)
|
||||||
|
try await cmd.run()
|
||||||
|
case "transcribe":
|
||||||
|
var cmd = TranscribeCommand(parsed: parsed)
|
||||||
|
try await cmd.run()
|
||||||
|
case "test-hook":
|
||||||
|
var cmd = TestHookCommand(parsed: parsed)
|
||||||
|
try await cmd.run()
|
||||||
|
case "mic":
|
||||||
|
guard path.count >= 3 else { throw CommanderProgramError.missingSubcommand(command: "mic") }
|
||||||
|
let micSub = path[2]
|
||||||
|
if micSub == "list" {
|
||||||
|
var cmd = MicList(parsed: parsed)
|
||||||
|
try await cmd.run()
|
||||||
|
} else if micSub == "set" {
|
||||||
|
var cmd = MicSet(parsed: parsed)
|
||||||
|
try await cmd.run()
|
||||||
|
} else {
|
||||||
|
throw CommanderProgramError.unknownSubcommand(command: "mic", name: micSub)
|
||||||
|
}
|
||||||
|
case "service":
|
||||||
|
guard path.count >= 3 else { throw CommanderProgramError.missingSubcommand(command: "service") }
|
||||||
|
let svcSub = path[2]
|
||||||
|
switch svcSub {
|
||||||
|
case "install":
|
||||||
|
var cmd = ServiceInstall()
|
||||||
|
try await cmd.run()
|
||||||
|
case "uninstall":
|
||||||
|
var cmd = ServiceUninstall()
|
||||||
|
try await cmd.run()
|
||||||
|
case "status":
|
||||||
|
var cmd = ServiceStatus()
|
||||||
|
try await cmd.run()
|
||||||
|
default:
|
||||||
|
throw CommanderProgramError.unknownSubcommand(command: "service", name: svcSub)
|
||||||
|
}
|
||||||
|
case "doctor":
|
||||||
|
var cmd = DoctorCommand(parsed: parsed)
|
||||||
|
try await cmd.run()
|
||||||
|
case "setup":
|
||||||
|
var cmd = SetupCommand(parsed: parsed)
|
||||||
|
try await cmd.run()
|
||||||
|
case "health":
|
||||||
|
var cmd = HealthCommand(parsed: parsed)
|
||||||
|
try await cmd.run()
|
||||||
|
case "tail-log":
|
||||||
|
var cmd = TailLogCommand(parsed: parsed)
|
||||||
|
try await cmd.run()
|
||||||
|
case "start":
|
||||||
|
var cmd = StartCommand()
|
||||||
|
try await cmd.run()
|
||||||
|
case "stop":
|
||||||
|
var cmd = StopCommand()
|
||||||
|
try await cmd.run()
|
||||||
|
case "restart":
|
||||||
|
var cmd = RestartCommand()
|
||||||
|
try await cmd.run()
|
||||||
|
case "status":
|
||||||
|
var cmd = StatusCommand()
|
||||||
|
try await cmd.run()
|
||||||
|
default:
|
||||||
|
throw CommanderProgramError.unknownSubcommand(command: "swabble", name: sub)
|
||||||
|
}
|
||||||
default:
|
default:
|
||||||
throw CommanderProgramError.unknownCommand(first)
|
throw CommanderProgramError.unknownCommand(first)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@available(macOS 26.0, *)
|
|
||||||
@MainActor
|
|
||||||
private func dispatchSwabble(parsed: ParsedValues, path: [String]) async throws {
|
|
||||||
let sub = try subcommand(path, index: 1, command: "swabble")
|
|
||||||
switch sub {
|
|
||||||
case "mic":
|
|
||||||
try await dispatchMic(parsed: parsed, path: path)
|
|
||||||
case "service":
|
|
||||||
try await dispatchService(path: path)
|
|
||||||
default:
|
|
||||||
let handlers = swabbleHandlers(parsed: parsed)
|
|
||||||
guard let handler = handlers[sub] else {
|
|
||||||
throw CommanderProgramError.unknownSubcommand(command: "swabble", name: sub)
|
|
||||||
}
|
|
||||||
try await handler()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@available(macOS 26.0, *)
|
|
||||||
@MainActor
|
|
||||||
private func swabbleHandlers(parsed: ParsedValues) -> [String: () async throws -> Void] {
|
|
||||||
[
|
|
||||||
"serve": {
|
|
||||||
var cmd = ServeCommand(parsed: parsed)
|
|
||||||
try await cmd.run()
|
|
||||||
},
|
|
||||||
"transcribe": {
|
|
||||||
var cmd = TranscribeCommand(parsed: parsed)
|
|
||||||
try await cmd.run()
|
|
||||||
},
|
|
||||||
"test-hook": {
|
|
||||||
var cmd = TestHookCommand(parsed: parsed)
|
|
||||||
try await cmd.run()
|
|
||||||
},
|
|
||||||
"doctor": {
|
|
||||||
var cmd = DoctorCommand(parsed: parsed)
|
|
||||||
try await cmd.run()
|
|
||||||
},
|
|
||||||
"setup": {
|
|
||||||
var cmd = SetupCommand(parsed: parsed)
|
|
||||||
try await cmd.run()
|
|
||||||
},
|
|
||||||
"health": {
|
|
||||||
var cmd = HealthCommand(parsed: parsed)
|
|
||||||
try await cmd.run()
|
|
||||||
},
|
|
||||||
"tail-log": {
|
|
||||||
var cmd = TailLogCommand(parsed: parsed)
|
|
||||||
try await cmd.run()
|
|
||||||
},
|
|
||||||
"start": {
|
|
||||||
var cmd = StartCommand()
|
|
||||||
try await cmd.run()
|
|
||||||
},
|
|
||||||
"stop": {
|
|
||||||
var cmd = StopCommand()
|
|
||||||
try await cmd.run()
|
|
||||||
},
|
|
||||||
"restart": {
|
|
||||||
var cmd = RestartCommand()
|
|
||||||
try await cmd.run()
|
|
||||||
},
|
|
||||||
"status": {
|
|
||||||
var cmd = StatusCommand()
|
|
||||||
try await cmd.run()
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
@available(macOS 26.0, *)
|
|
||||||
@MainActor
|
|
||||||
private func dispatchMic(parsed: ParsedValues, path: [String]) async throws {
|
|
||||||
let micSub = try subcommand(path, index: 2, command: "mic")
|
|
||||||
switch micSub {
|
|
||||||
case "list":
|
|
||||||
var cmd = MicList(parsed: parsed)
|
|
||||||
try await cmd.run()
|
|
||||||
case "set":
|
|
||||||
var cmd = MicSet(parsed: parsed)
|
|
||||||
try await cmd.run()
|
|
||||||
default:
|
|
||||||
throw CommanderProgramError.unknownSubcommand(command: "mic", name: micSub)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@available(macOS 26.0, *)
|
|
||||||
@MainActor
|
|
||||||
private func dispatchService(path: [String]) async throws {
|
|
||||||
let svcSub = try subcommand(path, index: 2, command: "service")
|
|
||||||
switch svcSub {
|
|
||||||
case "install":
|
|
||||||
var cmd = ServiceInstall()
|
|
||||||
try await cmd.run()
|
|
||||||
case "uninstall":
|
|
||||||
var cmd = ServiceUninstall()
|
|
||||||
try await cmd.run()
|
|
||||||
case "status":
|
|
||||||
var cmd = ServiceStatus()
|
|
||||||
try await cmd.run()
|
|
||||||
default:
|
|
||||||
throw CommanderProgramError.unknownSubcommand(command: "service", name: svcSub)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func subcommand(_ path: [String], index: Int, command: String) throws -> String {
|
|
||||||
guard path.count > index else {
|
|
||||||
throw CommanderProgramError.missingSubcommand(command: command)
|
|
||||||
}
|
|
||||||
return path[index]
|
|
||||||
}
|
|
||||||
|
|
||||||
if #available(macOS 26.0, *) {
|
if #available(macOS 26.0, *) {
|
||||||
let exitCode = await runCLI()
|
let exitCode = await runCLI()
|
||||||
exit(exitCode)
|
exit(exitCode)
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
import SwabbleKit
|
|
||||||
import Testing
|
import Testing
|
||||||
|
import SwabbleKit
|
||||||
|
|
||||||
@Suite struct WakeWordGateTests {
|
@Suite struct WakeWordGateTests {
|
||||||
@Test func matchRequiresGapAfterTrigger() {
|
@Test func matchRequiresGapAfterTrigger() {
|
||||||
|
|||||||
@@ -1,275 +1,55 @@
|
|||||||
<?xml version="1.0" standalone="yes"?>
|
<?xml version="1.0" standalone="yes"?>
|
||||||
<rss xmlns:sparkle="http://www.andymatuschak.org/xml-namespaces/sparkle" version="2.0">
|
<rss xmlns:sparkle="http://www.andymatuschak.org/xml-namespaces/sparkle" version="2.0">
|
||||||
<channel>
|
<channel>
|
||||||
<title>Clawdbot</title>
|
<title>Clawdis</title>
|
||||||
<item>
|
<item>
|
||||||
<title>2026.1.16-2</title>
|
<title>2026.1.5-3</title>
|
||||||
<pubDate>Sat, 17 Jan 2026 12:46:22 +0000</pubDate>
|
<pubDate>Mon, 05 Jan 2026 04:30:46 +0100</pubDate>
|
||||||
<link>https://raw.githubusercontent.com/clawdbot/clawdbot/main/appcast.xml</link>
|
<link>https://raw.githubusercontent.com/clawdbot/clawdbot/main/appcast.xml</link>
|
||||||
<sparkle:version>6273</sparkle:version>
|
<sparkle:version>3095</sparkle:version>
|
||||||
<sparkle:shortVersionString>2026.1.16-2</sparkle:shortVersionString>
|
<sparkle:shortVersionString>2026.1.5-3</sparkle:shortVersionString>
|
||||||
<sparkle:minimumSystemVersion>15.0</sparkle:minimumSystemVersion>
|
<sparkle:minimumSystemVersion>15.0</sparkle:minimumSystemVersion>
|
||||||
<description><![CDATA[<h2>Clawdbot 2026.1.16-2</h2>
|
<description><![CDATA[<h2>Clawdbot 2026.1.5-3</h2>
|
||||||
<h3>Changes</h3>
|
|
||||||
<ul>
|
|
||||||
<li>CLI: stamp build commit into dist metadata so banners show the commit in npm installs.</li>
|
|
||||||
</ul>
|
|
||||||
<p><a href="https://github.com/clawdbot/clawdbot/blob/main/CHANGELOG.md">View full changelog</a></p>
|
|
||||||
]]></description>
|
|
||||||
<enclosure url="https://github.com/clawdbot/clawdbot/releases/download/v2026.1.16-2/Clawdbot-2026.1.16-2.zip" length="21399591" type="application/octet-stream" sparkle:edSignature="zelT+KzN32cXsihbFniPF5Heq0hkwFfL3Agrh/AaoKUkr7kJAFarkGSOZRTWZ9y+DvOluzn2wHHjVigRjMzrBA=="/>
|
|
||||||
</item>
|
|
||||||
<item>
|
|
||||||
<title>2026.1.15</title>
|
|
||||||
<pubDate>Fri, 16 Jan 2026 10:31:53 +0000</pubDate>
|
|
||||||
<link>https://raw.githubusercontent.com/clawdbot/clawdbot/main/appcast.xml</link>
|
|
||||||
<sparkle:version>5998</sparkle:version>
|
|
||||||
<sparkle:shortVersionString>2026.1.15</sparkle:shortVersionString>
|
|
||||||
<sparkle:minimumSystemVersion>15.0</sparkle:minimumSystemVersion>
|
|
||||||
<description><![CDATA[<h2>Clawdbot 2026.1.15</h2>
|
|
||||||
<h3>Highlights</h3>
|
|
||||||
<ul>
|
|
||||||
<li>Plugins: add provider auth registry + <code>clawdbot models auth login</code> for plugin-driven OAuth/API key flows.</li>
|
|
||||||
<li>Browser: improve remote CDP/Browserless support (auth passthrough, <code>wss</code> upgrade, timeouts, clearer errors).</li>
|
|
||||||
<li>Heartbeat: per-agent configuration + 24h duplicate suppression. (#980) — thanks @voidserf.</li>
|
|
||||||
<li>Security: audit warns on weak model tiers; app nodes store auth tokens encrypted (Keychain/SecurePrefs).</li>
|
|
||||||
</ul>
|
|
||||||
<h3>Breaking</h3>
|
|
||||||
<ul>
|
|
||||||
<li><strong>BREAKING:</strong> iOS minimum version is now 18.0 to support Textual markdown rendering in native chat. (#702)</li>
|
|
||||||
<li><strong>BREAKING:</strong> Microsoft Teams is now a plugin; install <code>@clawdbot/msteams</code> via <code>clawdbot plugins install @clawdbot/msteams</code>.</li>
|
|
||||||
</ul>
|
|
||||||
<h3>Changes</h3>
|
|
||||||
<ul>
|
|
||||||
<li>CLI: set process titles to <code>clawdbot-<command></code> for clearer process listings.</li>
|
|
||||||
<li>CLI/macOS: sync remote SSH target/identity to config and let <code>gateway status</code> auto-infer SSH targets (ssh-config aware).</li>
|
|
||||||
<li>Heartbeat: tighten prompt guidance + suppress duplicate alerts for 24h. (#980) — thanks @voidserf.</li>
|
|
||||||
<li>Sessions/Security: add <code>session.dmScope</code> for multi-user DM isolation and audit warnings. (#948) — thanks @Alphonse-arianee.</li>
|
|
||||||
<li>Plugins: add provider auth registry + <code>clawdbot models auth login</code> for plugin-driven OAuth/API key flows.</li>
|
|
||||||
<li>Onboarding: switch channels setup to a single-select loop with per-channel actions and disabled hints in the picker.</li>
|
|
||||||
<li>TUI: show provider/model labels for the active session and default model.</li>
|
|
||||||
<li>Heartbeat: add per-agent heartbeat configuration and multi-agent docs example.</li>
|
|
||||||
<li>UI: show gateway auth guidance + doc link on unauthorized Control UI connections.</li>
|
|
||||||
<li>Security: warn on weak model tiers (Haiku, below GPT-5, below Claude 4.5) in <code>clawdbot security audit</code>.</li>
|
|
||||||
<li>Apps: store node auth tokens encrypted (Keychain/SecurePrefs).</li>
|
|
||||||
<li>Daemon: share profile/state-dir resolution across service helpers and honor <code>CLAWDBOT_STATE_DIR</code> for Windows task scripts.</li>
|
|
||||||
<li>Docs: clarify multi-gateway rescue bot guidance. (#969) — thanks @bjesuiter.</li>
|
|
||||||
<li>Agents: add Current Date & Time system prompt section with configurable time format (auto/12/24).</li>
|
|
||||||
<li>Tools: normalize Slack/Discord message timestamps with <code>timestampMs</code>/<code>timestampUtc</code> while keeping raw provider fields.</li>
|
|
||||||
<li>macOS: add <code>system.which</code> for prompt-free remote skill discovery (with gateway fallback to <code>system.run</code>).</li>
|
|
||||||
<li>Docs: add Date & Time guide and update prompt/timezone configuration docs.</li>
|
|
||||||
<li>Messages: debounce rapid inbound messages across channels with per-connector overrides. (#971) — thanks @juanpablodlc.</li>
|
|
||||||
<li>Messages: allow media-only sends (CLI/tool) and show Telegram voice recording status for voice notes. (#957) — thanks @rdev.</li>
|
|
||||||
<li>Auth/Status: keep auth profiles sticky per session (rotate on compaction/new), surface provider usage headers in <code>/status</code> and <code>clawdbot models status</code>, and update docs.</li>
|
|
||||||
<li>CLI: add <code>--json</code> output for <code>clawdbot daemon</code> lifecycle/install commands.</li>
|
|
||||||
<li>Memory: make <code>node-llama-cpp</code> an optional dependency (avoid Node 25 install failures) and improve local-embeddings fallback/errors.</li>
|
|
||||||
<li>Browser: add <code>snapshot refs=aria</code> (Playwright aria-ref ids) for self-resolving refs across <code>snapshot</code> → <code>act</code>.</li>
|
|
||||||
<li>Browser: <code>profile="chrome"</code> now defaults to host control and returns clearer “attach a tab” errors.</li>
|
|
||||||
<li>Browser: prefer stable Chrome for auto-detect, with Brave/Edge fallbacks and updated docs. (#983) — thanks @cpojer.</li>
|
|
||||||
<li>Browser: increase remote CDP reachability timeouts + add <code>remoteCdpTimeoutMs</code>/<code>remoteCdpHandshakeTimeoutMs</code>.</li>
|
|
||||||
<li>Browser: preserve auth/query tokens for remote CDP endpoints and pass Basic auth for CDP HTTP/WS. (#895) — thanks @mukhtharcm.</li>
|
|
||||||
<li>Telegram: add bidirectional reaction support with configurable notifications and agent guidance. (#964) — thanks @bohdanpodvirnyi.</li>
|
|
||||||
<li>Telegram: allow custom commands in the bot menu (merged with native; conflicts ignored). (#860) — thanks @nachoiacovino.</li>
|
|
||||||
<li>Discord: allow allowlisted guilds without channel lists to receive messages when <code>groupPolicy="allowlist"</code>. — thanks @thewilloftheshadow.</li>
|
|
||||||
<li>Discord: allow emoji/sticker uploads + channel actions in config defaults. (#870) — thanks @JDIVE.</li>
|
|
||||||
</ul>
|
|
||||||
<h3>Fixes</h3>
|
<h3>Fixes</h3>
|
||||||
<ul>
|
<ul>
|
||||||
<li>Fix: list model picker entries as provider/model pairs for explicit selection. (#970) — thanks @mcinteerj.</li>
|
<li>NPM package: include missing runtime dist folders (slack/signal/imessage/tui/wizard/control-ui/daemon) to avoid <code>ERR_MODULE_NOT_FOUND</code> in Node 25 npx installs.</li>
|
||||||
<li>Fix: align OpenAI image-gen defaults with DALL-E 3 standard quality and document output formats. (#880) — thanks @mkbehr.</li>
|
|
||||||
<li>Fix: persist <code>gateway.mode=local</code> after selecting Local run mode in <code>clawdbot configure</code>, even if no other sections are chosen.</li>
|
|
||||||
<li>Daemon: fix profile-aware service label resolution (env-driven) and add coverage for launchd/systemd/schtasks. (#969) — thanks @bjesuiter.</li>
|
|
||||||
<li>Agents: avoid false positives when logging unsupported Google tool schema keywords.</li>
|
|
||||||
<li>Agents: skip Gemini history downgrades for google-antigravity to preserve tool calls. (#894) — thanks @mukhtharcm.</li>
|
|
||||||
<li>Status: restore usage summary line for current provider when no OAuth profiles exist.</li>
|
|
||||||
<li>Fix: guard model fallback against undefined provider/model values. (#954) — thanks @roshanasingh4.</li>
|
|
||||||
<li>Fix: refactor session store updates, add chat.inject, and harden subagent cleanup flow. (#944) — thanks @tyler6204.</li>
|
|
||||||
<li>Fix: clean up suspended CLI processes across backends. (#978) — thanks @Nachx639.</li>
|
|
||||||
<li>Fix: support MiniMax coding plan usage responses with <code>model_remains</code>/<code>current_interval_*</code> payloads.</li>
|
|
||||||
<li>Fix: suppress WhatsApp pairing replies for historical catch-up DMs on initial link. (#904)</li>
|
|
||||||
<li>Browser: extension mode recovers when only one tab is attached (stale targetId fallback).</li>
|
|
||||||
<li>Browser: fix <code>tab not found</code> for extension relay snapshots/actions when Playwright blocks <code>newCDPSession</code> (use the single available Page).</li>
|
|
||||||
<li>Browser: upgrade <code>ws</code> → <code>wss</code> when remote CDP uses <code>https</code> (fixes Browserless handshake).</li>
|
|
||||||
<li>Telegram: skip <code>message_thread_id=1</code> for General topic sends while keeping typing indicators. (#848) — thanks @azade-c.</li>
|
|
||||||
<li>Fix: sanitize user-facing error text + strip <code><final></code> tags across reply pipelines. (#975) — thanks @ThomsenDrake.</li>
|
|
||||||
<li>Fix: normalize pairing CLI aliases, allow extension channels, and harden Zalo webhook payload parsing. (#991) — thanks @longmaba.</li>
|
|
||||||
<li>Fix: allow local Tailscale Serve hostnames without treating tailnet clients as direct. (#885) — thanks @oswalpalash.</li>
|
|
||||||
<li>Fix: reset sessions after role-ordering conflicts to recover from consecutive user turns. (#998)</li>
|
|
||||||
</ul>
|
</ul>
|
||||||
<p><a href="https://github.com/clawdbot/clawdbot/blob/main/CHANGELOG.md">View full changelog</a></p>
|
<p><a href="https://github.com/clawdbot/clawdbot/blob/main/CHANGELOG.md">View full changelog</a></p>
|
||||||
]]></description>
|
]]></description>
|
||||||
<enclosure url="https://github.com/clawdbot/clawdbot/releases/download/v2026.1.15/Clawdbot-2026.1.15.zip" length="12127276" type="application/octet-stream" sparkle:edSignature="o79vwTbtW/d91NQFRVfUDhsv6D4zIw7IkhY0N1iLImMu94BURgLcecA6z7Smy3bMobPwOyzN8yfm6mA/Rt8FCA=="/>
|
<enclosure url="https://github.com/clawdbot/clawdbot/releases/download/v2026.1.5-3/Clawdbot-2026.1.5-3.zip" length="160800596" type="application/octet-stream" sparkle:edSignature="P8U3nvIFpbGmRItT/NGPmJ/i370OMVvDHYQL/znYsLI0MrbGfXgMGEvR5A0uwW+cJevlX/hrJLiY51zo4rAMBg=="/>
|
||||||
</item>
|
</item>
|
||||||
<item>
|
<item>
|
||||||
<title>2026.1.14-1</title>
|
<title>2026.1.5-3</title>
|
||||||
<pubDate>Thu, 15 Jan 2026 11:14:40 +0000</pubDate>
|
<pubDate>Mon, 05 Jan 2026 03:57:59 +0100</pubDate>
|
||||||
<link>https://raw.githubusercontent.com/clawdbot/clawdbot/main/appcast.xml</link>
|
<link>https://raw.githubusercontent.com/clawdbot/clawdbot/main/appcast.xml</link>
|
||||||
<sparkle:version>5825</sparkle:version>
|
<sparkle:version>3091</sparkle:version>
|
||||||
<sparkle:shortVersionString>2026.1.14-1</sparkle:shortVersionString>
|
<sparkle:shortVersionString>2026.1.5-3</sparkle:shortVersionString>
|
||||||
<sparkle:minimumSystemVersion>15.0</sparkle:minimumSystemVersion>
|
<sparkle:minimumSystemVersion>15.0</sparkle:minimumSystemVersion>
|
||||||
<description><![CDATA[<h2>Clawdbot 2026.1.14-1</h2>
|
<description><![CDATA[<h2>Clawdbot 2026.1.5-3</h2>
|
||||||
<h3>Highlights</h3>
|
|
||||||
<ul>
|
|
||||||
<li>Web search: <code>web_search</code>/<code>web_fetch</code> tools (Brave API) + first-time setup in onboarding/configure.</li>
|
|
||||||
<li>Browser control: Chrome extension relay takeover mode + remote browser control via <code>clawdbot browser serve</code>.</li>
|
|
||||||
<li>Plugins: channel plugins (gateway HTTP hooks) + Zalo plugin + onboarding install flow. (#854) — thanks @longmaba.</li>
|
|
||||||
<li>Security: expanded <code>clawdbot security audit</code> (+ <code>--fix</code>), detect-secrets CI scan, and a <code>SECURITY.md</code> reporting policy.</li>
|
|
||||||
</ul>
|
|
||||||
<h3>Changes</h3>
|
|
||||||
<h4>Web Tools</h4>
|
|
||||||
<ul>
|
|
||||||
<li>Tools: add <code>web_search</code>/<code>web_fetch</code> (Brave API), including helpful setup hints when the key is missing.</li>
|
|
||||||
<li>Tools: enable <code>web_fetch</code> by default (unless explicitly disabled in config).</li>
|
|
||||||
<li>CLI/Docs: add <code>clawdbot configure --section web</code> for storing Brave API keys and update onboarding tips.</li>
|
|
||||||
</ul>
|
|
||||||
<h4>Browser / Control UI</h4>
|
|
||||||
<ul>
|
|
||||||
<li>Browser: add Chrome extension relay takeover mode (toolbar button) + <code>clawdbot browser serve</code> remote control + <code>browser.controlToken</code>.</li>
|
|
||||||
<li>Browser: ship a built-in <code>chrome</code> profile for extension relay and start the relay automatically when running locally.</li>
|
|
||||||
<li>Browser: default <code>browser.defaultProfile</code> to <code>chrome</code> (existing Chrome takeover mode).</li>
|
|
||||||
<li>Browser: add <code>clawdbot browser extension install/path</code> and copy extension path to clipboard.</li>
|
|
||||||
<li>Browser: add <code>snapshot refs=aria</code> (Playwright aria-ref ids) for self-resolving refs across <code>snapshot</code> → <code>act</code>.</li>
|
|
||||||
<li>Browser: <code>profile="chrome"</code> now defaults to host control and returns clearer “attach a tab” errors.</li>
|
|
||||||
<li>Browser: extension mode recovers when only one tab is attached (stale targetId fallback).</li>
|
|
||||||
<li>Control UI: show raw any-map entries in config views; move Docs link into the left nav.</li>
|
|
||||||
</ul>
|
|
||||||
<h4>Plugins</h4>
|
|
||||||
<ul>
|
|
||||||
<li>Plugins: add plugin HTTP hooks + loader updates to support channel plugins. (#854) — thanks @longmaba.</li>
|
|
||||||
<li>Plugins: add onboarding plugin install flow. (#854) — thanks @longmaba.</li>
|
|
||||||
<li>Channels: add Matrix plugin (external) with docs + onboarding hooks.</li>
|
|
||||||
<li>Voice Call: add Plivo provider (no SDK dependency). (#846) — thanks @vrknetha.</li>
|
|
||||||
</ul>
|
|
||||||
<h4>Security</h4>
|
|
||||||
<ul>
|
|
||||||
<li>Security: expand <code>clawdbot security audit</code> checks and publish a <code>SECURITY.md</code> reporting policy.</li>
|
|
||||||
<li>Security: extend <code>clawdbot security audit --fix</code> to tighten more sensitive state paths.</li>
|
|
||||||
<li>Security: add detect-secrets CI scan and baseline guidance. (#227) — thanks @Hyaxia.</li>
|
|
||||||
</ul>
|
|
||||||
<h4>Onboarding / Daemon</h4>
|
|
||||||
<ul>
|
|
||||||
<li>Onboarding: add a security checkpoint prompt (docs link + sandboxing hint); require <code>--accept-risk</code> for <code>--non-interactive</code>.</li>
|
|
||||||
<li>Daemon: support profile-aware service names for multi-gateway setups. (#671) — thanks @bjesuiter.</li>
|
|
||||||
</ul>
|
|
||||||
<h4>Auth / Usage / Config</h4>
|
|
||||||
<ul>
|
|
||||||
<li>Usage: add MiniMax coding plan usage tracking.</li>
|
|
||||||
<li>Auth: label Claude Code CLI auth options. (#915) — thanks @SeanZoR.</li>
|
|
||||||
<li>Agents: add optional auth-profile copy prompt on <code>agents add</code> and improve auth error messaging.</li>
|
|
||||||
<li>Auth: add dynamic template variables to <code>messages.responsePrefix</code>. (#928) — thanks @sebslight.</li>
|
|
||||||
<li>Config: add <code>channels.<provider>.configWrites</code> gating for channel-initiated config writes; migrate Slack channel IDs.</li>
|
|
||||||
</ul>
|
|
||||||
<h4>Channels</h4>
|
|
||||||
<ul>
|
|
||||||
<li>Telegram: add message delete action in the message tool. (#903) — thanks @sleontenko.</li>
|
|
||||||
<li>WhatsApp: add <code>channels.whatsapp.sendReadReceipts</code> to disable auto read receipts. (#882) — thanks @chrisrodz.</li>
|
|
||||||
</ul>
|
|
||||||
<h4>Docs</h4>
|
|
||||||
<ul>
|
|
||||||
<li>Docs: clarify per-agent auth stores, sandboxed skill binaries, and elevated semantics.</li>
|
|
||||||
<li>Docs: add FAQ entries for missing provider auth after adding agents and Gemini thinking signature errors.</li>
|
|
||||||
<li>Docs: expand gateway security hardening guidance and incident response checklist.</li>
|
|
||||||
<li>Docs: document DM history limits for channel DMs. (#883) — thanks @pkrmf.</li>
|
|
||||||
<li>Docs: standardize Claude Code CLI naming across docs and prompts. (follow-up to #915)</li>
|
|
||||||
<li>Docs: add per-command CLI doc pages and link them from <code>clawdbot <command> --help</code>.</li>
|
|
||||||
<li>Docs: add multi-gateway guide (sidebar + nav).</li>
|
|
||||||
</ul>
|
|
||||||
<h3>Fixes</h3>
|
<h3>Fixes</h3>
|
||||||
<h4>Gateway / Daemon / Sessions</h4>
|
|
||||||
<ul>
|
<ul>
|
||||||
<li>Gateway: forward termination signals to respawned CLI child processes to avoid orphaned systemd runs. (#933) — thanks @roshanasingh4.</li>
|
<li>NPM package: include missing runtime dist folders (slack/signal/imessage/tui/wizard/control-ui/daemon) to avoid <code>ERR_MODULE_NOT_FOUND</code> in Node 25 npx installs.</li>
|
||||||
<li>Gateway/UI: ship session defaults in the hello snapshot so the Control UI canonicalizes main session keys (no bare <code>main</code> alias).</li>
|
|
||||||
<li>Agents: skip thinking/final tag stripping inside Markdown code spans. (#939) — thanks @ngutman.</li>
|
|
||||||
<li>Browser: add tests for snapshot labels/efficient query params and labeled image responses.</li>
|
|
||||||
<li>Browser: persist role snapshot refs per CDP target so <code>snapshot</code> → <code>act</code> clicks work even if Playwright returns a different Page instance.</li>
|
|
||||||
<li>macOS: ensure launchd log directory exists with a test-only override. (#909) — thanks @roshanasingh4.</li>
|
|
||||||
<li>macOS: format ConnectionsStore config to satisfy SwiftFormat lint. (#852) — thanks @mneves75.</li>
|
|
||||||
<li>Packaging: run <code>pnpm build</code> on <code>prepack</code> so npm publishes include fresh <code>dist/</code> output.</li>
|
|
||||||
<li>Telegram: register dock native commands with underscores to avoid <code>BOT_COMMAND_INVALID</code> (#929, fixes #901) — thanks @grp06.</li>
|
|
||||||
<li>Google: downgrade unsigned thinking blocks before send to avoid missing signature errors.</li>
|
|
||||||
<li>Agents: make user time zone and 24-hour time explicit in the system prompt. (#859) — thanks @CashWilliams.</li>
|
|
||||||
<li>Agents: strip downgraded tool call text without eating adjacent replies and filter thinking-tag leaks. (#905) — thanks @erikpr1994.</li>
|
|
||||||
<li>Agents: cap tool call IDs for OpenAI/OpenRouter to avoid request rejections. (#875) — thanks @j1philli.</li>
|
|
||||||
<li>Doctor: avoid re-adding WhatsApp config when only legacy ack reactions are set. (#927, fixes #900) — thanks @grp06.</li>
|
|
||||||
<li>Agents: scrub tuple <code>items</code> schemas for Gemini tool calls. (#926, fixes #746) — thanks @grp06.</li>
|
|
||||||
<li>Agents: stabilize sub-agent announce status from runtime outcomes and normalize Result/Notes. (#835) — thanks @roshanasingh4.</li>
|
|
||||||
<li>Apps: use canonical main session keys from gateway defaults across macOS/iOS/Android to avoid creating bare <code>main</code> sessions.</li>
|
|
||||||
<li>Embedded runner: suppress raw API error payloads from replies. (#924) — thanks @grp06.</li>
|
|
||||||
<li>Auth: normalize Claude Code CLI profile mode to oauth and auto-migrate config. (#855) — thanks @sebslight.</li>
|
|
||||||
<li>Daemon: clear persisted launchd disabled state before bootstrap (fixes <code>daemon install</code> after uninstall). (#849) — thanks @ndraiman.</li>
|
|
||||||
<li>Sessions: return deep clones (<code>structuredClone</code>) so cached session entries can't be mutated. (#934) — thanks @ronak-guliani.</li>
|
|
||||||
<li>Heartbeat: keep <code>updatedAt</code> monotonic when restoring heartbeat sessions. (#934) — thanks @ronak-guliani.</li>
|
|
||||||
<li>Agent: clear run context after CLI runs (<code>clearAgentRunContext</code>) to avoid runaway contexts. (#934) — thanks @ronak-guliani.</li>
|
|
||||||
<li>Gateway/Dev: ensure <code>pnpm gateway:dev</code> always uses the dev profile config + state (<code>~/.clawdbot-dev</code>).</li>
|
|
||||||
</ul>
|
|
||||||
<h4>CLI / Onboarding</h4>
|
|
||||||
<ul>
|
|
||||||
<li>Onboarding: show web search setup at the end (not the beginning).</li>
|
|
||||||
<li>Onboarding: show daemon install/restart progress (avoid “blinking cursor”) and fix daemon install output formatting.</li>
|
|
||||||
<li>Health: colorize “not configured” provider lines for easier scanning.</li>
|
|
||||||
</ul>
|
|
||||||
<h4>Control UI / TUI</h4>
|
|
||||||
<ul>
|
|
||||||
<li>Control UI: load cron run history on job selection and clarify empty-state messaging. (#866)</li>
|
|
||||||
<li>UI: use application-defined WebSocket close code and fix dashboard auth query items. (#918) — thanks @rahthakor.</li>
|
|
||||||
<li>UI: always apply <code>?token=</code> from URL (fixes unauthorized after re-onboard).</li>
|
|
||||||
<li>Browser: add tests for snapshot labels/efficient query params and labeled image responses.</li>
|
|
||||||
<li>TUI: render picker overlays via the overlay stack so /models and /settings display. (#921) — thanks @grizzdank.</li>
|
|
||||||
<li>TUI: add a bright spinner + elapsed time in the status line for send/stream/run states.</li>
|
|
||||||
<li>TUI: show LLM error messages (rate limits, auth, etc.) instead of <code>(no output)</code>.</li>
|
|
||||||
</ul>
|
|
||||||
<h4>Agents / Auth / Tools / Sandbox</h4>
|
|
||||||
<ul>
|
|
||||||
<li>Agents: make user time zone and 24-hour time explicit in the system prompt. (#859) — thanks @CashWilliams.</li>
|
|
||||||
<li>Agents: strip downgraded tool call text without eating adjacent replies and filter thinking-tag leaks. (#905) — thanks @erikpr1994.</li>
|
|
||||||
<li>Agents: cap tool call IDs for OpenAI/OpenRouter to avoid request rejections. (#875) — thanks @j1philli.</li>
|
|
||||||
<li>Agents: scrub tuple <code>items</code> schemas for Gemini tool calls. (#926, fixes #746) — thanks @grp06.</li>
|
|
||||||
<li>Agents: stabilize sub-agent announce status from runtime outcomes and normalize Result/Notes. (#835) — thanks @roshanasingh4.</li>
|
|
||||||
<li>Auth: normalize Claude Code CLI profile mode to oauth and auto-migrate config. (#855) — thanks @sebslight.</li>
|
|
||||||
<li>Embedded runner: suppress raw API error payloads from replies. (#924) — thanks @grp06.</li>
|
|
||||||
<li>Logging: tolerate <code>EIO</code> from console writes to avoid gateway crashes. (#925, fixes #878) — thanks @grp06.</li>
|
|
||||||
<li>Sandbox: restore <code>docker.binds</code> config validation and preserve configured PATH for <code>docker exec</code>. (#873) — thanks @akonyer.</li>
|
|
||||||
<li>Google: downgrade unsigned thinking blocks before send to avoid missing signature errors.</li>
|
|
||||||
</ul>
|
|
||||||
<h4>macOS / Apps</h4>
|
|
||||||
<ul>
|
|
||||||
<li>macOS: ensure launchd log directory exists with a test-only override. (#909) — thanks @roshanasingh4.</li>
|
|
||||||
<li>macOS: format ConnectionsStore config to satisfy SwiftFormat lint. (#852) — thanks @mneves75.</li>
|
|
||||||
<li>macOS: pass auth token/password to dashboard URL for authenticated access. (#918) — thanks @rahthakor.</li>
|
|
||||||
<li>macOS: reuse launchd gateway auth and skip wizard when gateway config already exists. (#917)</li>
|
|
||||||
<li>Apps: use canonical main session keys from gateway defaults across macOS/iOS/Android to avoid creating bare <code>main</code> sessions.</li>
|
|
||||||
<li>macOS: fix cron preview/testing payload to use <code>channel</code> key. (#867) — thanks @wes-davis.</li>
|
|
||||||
<li>macOS: update cron testing channel arg. (#896) — thanks @ngutman.</li>
|
|
||||||
</ul>
|
|
||||||
<h4>Channels / Messaging</h4>
|
|
||||||
<ul>
|
|
||||||
<li>Slack: isolate thread history and avoid inheriting channel transcripts for new threads by default. (#758)</li>
|
|
||||||
<li>Slack: respect <code>channels.slack.requireMention</code> default when resolving channel mention gating. (#850) — thanks @evalexpr.</li>
|
|
||||||
<li>Slack: drop Socket Mode events with mismatched <code>api_app_id</code>/<code>team_id</code>. (#889) — thanks @roshanasingh4.</li>
|
|
||||||
<li>Commands: add native command argument menus across Discord/Slack/Telegram. (#936) — thanks @thewilloftheshadow.</li>
|
|
||||||
<li>Discord: isolate autoThread thread context. (#856) — thanks @davidguttman.</li>
|
|
||||||
<li>Telegram: honor <code>channels.telegram.timeoutSeconds</code> for grammY API requests. (#863) — thanks @Snaver.</li>
|
|
||||||
<li>Telegram: aggregate split inbound messages into one prompt (reduces “one reply per fragment”).</li>
|
|
||||||
<li>Telegram: let control commands bypass per-chat sequentialization; always allow abort triggers.</li>
|
|
||||||
<li>Telegram: split long captions into media + follow-up text messages. (#907) — thanks @jalehman.</li>
|
|
||||||
<li>Telegram: migrate group config when supergroups change chat IDs. (#906) — thanks @sleontenko.</li>
|
|
||||||
<li>Telegram: register dock native commands with underscores to avoid <code>BOT_COMMAND_INVALID</code> (#929, fixes #901) — thanks @grp06.</li>
|
|
||||||
<li>Messaging: unify markdown formatting + format-first chunking for Slack/Telegram/Signal. (#920) — thanks @TheSethRose.</li>
|
|
||||||
<li>iMessage: prefer handle routing for direct-message replies; include imsg RPC error details. (#935)</li>
|
|
||||||
<li>WhatsApp: fix context isolation using wrong ID (was bot's number, now conversation ID). (#911) — thanks @tristanmanchester.</li>
|
|
||||||
<li>WhatsApp: normalize user JIDs with device suffix for allowlist checks in groups. (#838) — thanks @peschee.</li>
|
|
||||||
<li>WhatsApp: harden owner command auth.</li>
|
|
||||||
<li>Auto-reply: treat trailing <code>NO_REPLY</code> tokens as silent replies.</li>
|
|
||||||
</ul>
|
|
||||||
<h4>Config / Doctor / Packaging</h4>
|
|
||||||
<ul>
|
|
||||||
<li>Config: prevent partial config writes from clobbering unrelated settings (base hash guard + merge patch for connection saves).</li>
|
|
||||||
<li>Config/Doctor: remove legacy Clawdis env fallbacks and config/service migrations (Clawdbot-only).</li>
|
|
||||||
<li>Doctor: avoid re-adding WhatsApp config when only legacy ack reactions are set. (#927, fixes #900) — thanks @grp06.</li>
|
|
||||||
<li>Packaging: run <code>pnpm build</code> on <code>prepack</code> so npm publishes include fresh <code>dist/</code> output.</li>
|
|
||||||
</ul>
|
</ul>
|
||||||
<p><a href="https://github.com/clawdbot/clawdbot/blob/main/CHANGELOG.md">View full changelog</a></p>
|
<p><a href="https://github.com/clawdbot/clawdbot/blob/main/CHANGELOG.md">View full changelog</a></p>
|
||||||
]]></description>
|
]]></description>
|
||||||
<enclosure url="https://github.com/clawdbot/clawdbot/releases/download/v2026.1.14-1/Clawdbot-2026.1.14-1.zip" length="19887144" type="application/octet-stream" sparkle:edSignature="1irKxBLt2eRtns34m/8JsjL/ZzhZQNjahwrxtArTvzaCnidS/MEnpD4nV2SHnhuo8g+fJZQpV9NoCAoEOAinCw=="/>
|
<enclosure url="https://github.com/clawdbot/clawdbot/releases/download/v2026.1.5-3/Clawdbot-2026.1.5-3.zip" length="160797048" type="application/octet-stream" sparkle:edSignature="5KYFg0SW7liwLxLJbfzd2KsAxbX06gMH0rH/W3a4V0p4N48hjz4AsSrfFLdGZSnW+6XaJjC3MN6Ynh+l7kffDQ=="/>
|
||||||
|
</item>
|
||||||
|
<item>
|
||||||
|
<title>2026.1.5-2</title>
|
||||||
|
<pubDate>Mon, 05 Jan 2026 03:51:30 +0100</pubDate>
|
||||||
|
<link>https://raw.githubusercontent.com/clawdbot/clawdbot/main/appcast.xml</link>
|
||||||
|
<sparkle:version>3089</sparkle:version>
|
||||||
|
<sparkle:shortVersionString>2026.1.5-2</sparkle:shortVersionString>
|
||||||
|
<sparkle:minimumSystemVersion>15.0</sparkle:minimumSystemVersion>
|
||||||
|
<description><![CDATA[<h2>Clawdbot 2026.1.5-2</h2>
|
||||||
|
<h3>Fixes</h3>
|
||||||
|
<ul>
|
||||||
|
<li>NPM package: include <code>dist/sessions</code> so <code>clawdbot agent</code> resolves session helpers in npx installs.</li>
|
||||||
|
<li>Node 25: avoid unsupported directory import by targeting <code>qrcode-terminal/vendor/QRCode/*.js</code> modules.</li>
|
||||||
|
</ul>
|
||||||
|
<p><a href="https://github.com/clawdbot/clawdbot/blob/main/CHANGELOG.md">View full changelog</a></p>
|
||||||
|
]]></description>
|
||||||
|
<enclosure url="https://github.com/clawdbot/clawdbot/releases/download/v2026.1.5-2/Clawdbot-2026.1.5-2.zip" length="150250417" type="application/octet-stream" sparkle:edSignature="ntHNmwyHrv6cPk6NAKOT3AUkwdt5ZadrGU6mJK4GmVxi44uIMT3ZXluvnqK9SxXQwA0H0dXjiGMS/cg8NbgqDA=="/>
|
||||||
</item>
|
</item>
|
||||||
</channel>
|
</channel>
|
||||||
</rss>
|
</rss>
|
||||||
@@ -1,5 +1,3 @@
|
|||||||
import com.android.build.api.variant.impl.VariantOutputImpl
|
|
||||||
|
|
||||||
plugins {
|
plugins {
|
||||||
id("com.android.application")
|
id("com.android.application")
|
||||||
id("org.jetbrains.kotlin.android")
|
id("org.jetbrains.kotlin.android")
|
||||||
@@ -21,8 +19,8 @@ android {
|
|||||||
applicationId = "com.clawdbot.android"
|
applicationId = "com.clawdbot.android"
|
||||||
minSdk = 31
|
minSdk = 31
|
||||||
targetSdk = 36
|
targetSdk = 36
|
||||||
versionCode = 202601114
|
versionCode = 1
|
||||||
versionName = "2026.1.11-4"
|
versionName = "2.0.0-beta3"
|
||||||
}
|
}
|
||||||
|
|
||||||
buildTypes {
|
buildTypes {
|
||||||
@@ -49,7 +47,6 @@ android {
|
|||||||
|
|
||||||
lint {
|
lint {
|
||||||
disable += setOf("IconLauncherShape")
|
disable += setOf("IconLauncherShape")
|
||||||
warningsAsErrors = true
|
|
||||||
}
|
}
|
||||||
|
|
||||||
testOptions {
|
testOptions {
|
||||||
@@ -57,23 +54,9 @@ android {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
androidComponents {
|
|
||||||
onVariants { variant ->
|
|
||||||
variant.outputs
|
|
||||||
.filterIsInstance<VariantOutputImpl>()
|
|
||||||
.forEach { output ->
|
|
||||||
val versionName = output.versionName.orNull ?: "0"
|
|
||||||
val buildType = variant.buildType
|
|
||||||
|
|
||||||
val outputFileName = "clawdbot-${versionName}-${buildType}.apk"
|
|
||||||
output.outputFileName = outputFileName
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
kotlin {
|
kotlin {
|
||||||
compilerOptions {
|
compilerOptions {
|
||||||
jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17)
|
jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17)
|
||||||
allWarningsAsErrors.set(true)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -102,7 +85,6 @@ dependencies {
|
|||||||
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.9.0")
|
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.9.0")
|
||||||
|
|
||||||
implementation("androidx.security:security-crypto:1.1.0")
|
implementation("androidx.security:security-crypto:1.1.0")
|
||||||
implementation("androidx.exifinterface:exifinterface:1.4.2")
|
|
||||||
|
|
||||||
// CameraX (for node.invoke camera.* parity)
|
// CameraX (for node.invoke camera.* parity)
|
||||||
implementation("androidx.camera:camera-core:1.5.2")
|
implementation("androidx.camera:camera-core:1.5.2")
|
||||||
@@ -119,7 +101,7 @@ dependencies {
|
|||||||
testImplementation("io.kotest:kotest-runner-junit5-jvm:6.0.7")
|
testImplementation("io.kotest:kotest-runner-junit5-jvm:6.0.7")
|
||||||
testImplementation("io.kotest:kotest-assertions-core-jvm:6.0.7")
|
testImplementation("io.kotest:kotest-assertions-core-jvm:6.0.7")
|
||||||
testImplementation("org.robolectric:robolectric:4.16")
|
testImplementation("org.robolectric:robolectric:4.16")
|
||||||
testRuntimeOnly("org.junit.vintage:junit-vintage-engine:6.0.2")
|
testRuntimeOnly("org.junit.vintage:junit-vintage-engine:6.0.1")
|
||||||
}
|
}
|
||||||
|
|
||||||
tasks.withType<Test>().configureEach {
|
tasks.withType<Test>().configureEach {
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
"element",
|
"element",
|
||||||
"node",
|
"node",
|
||||||
"nodeId",
|
"nodeId",
|
||||||
"id",
|
"jobId",
|
||||||
"requestId",
|
"requestId",
|
||||||
"to",
|
"to",
|
||||||
"channelId",
|
"channelId",
|
||||||
@@ -136,10 +136,10 @@
|
|||||||
"label": "add",
|
"label": "add",
|
||||||
"detailKeys": ["job.name", "job.id", "job.schedule", "job.cron"]
|
"detailKeys": ["job.name", "job.id", "job.schedule", "job.cron"]
|
||||||
},
|
},
|
||||||
"update": { "label": "update", "detailKeys": ["id"] },
|
"update": { "label": "update", "detailKeys": ["jobId"] },
|
||||||
"remove": { "label": "remove", "detailKeys": ["id"] },
|
"remove": { "label": "remove", "detailKeys": ["jobId"] },
|
||||||
"run": { "label": "run", "detailKeys": ["id"] },
|
"run": { "label": "run", "detailKeys": ["jobId"] },
|
||||||
"runs": { "label": "runs", "detailKeys": ["id"] },
|
"runs": { "label": "runs", "detailKeys": ["jobId"] },
|
||||||
"wake": { "label": "wake", "detailKeys": ["text", "mode"] }
|
"wake": { "label": "wake", "detailKeys": ["text", "mode"] }
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -27,7 +27,6 @@ class MainViewModel(app: Application) : AndroidViewModel(app) {
|
|||||||
val remoteAddress: StateFlow<String?> = runtime.remoteAddress
|
val remoteAddress: StateFlow<String?> = runtime.remoteAddress
|
||||||
val isForeground: StateFlow<Boolean> = runtime.isForeground
|
val isForeground: StateFlow<Boolean> = runtime.isForeground
|
||||||
val seamColorArgb: StateFlow<Long> = runtime.seamColorArgb
|
val seamColorArgb: StateFlow<Long> = runtime.seamColorArgb
|
||||||
val mainSessionKey: StateFlow<String> = runtime.mainSessionKey
|
|
||||||
|
|
||||||
val cameraHud: StateFlow<CameraHudState?> = runtime.cameraHud
|
val cameraHud: StateFlow<CameraHudState?> = runtime.cameraHud
|
||||||
val cameraFlashToken: StateFlow<Long> = runtime.cameraFlashToken
|
val cameraFlashToken: StateFlow<Long> = runtime.cameraFlashToken
|
||||||
@@ -139,7 +138,7 @@ class MainViewModel(app: Application) : AndroidViewModel(app) {
|
|||||||
runtime.handleCanvasA2UIActionFromWebView(payloadJson)
|
runtime.handleCanvasA2UIActionFromWebView(payloadJson)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun loadChat(sessionKey: String) {
|
fun loadChat(sessionKey: String = "main") {
|
||||||
runtime.loadChat(sessionKey)
|
runtime.loadChat(sessionKey)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,26 +1,8 @@
|
|||||||
package com.clawdbot.android
|
package com.clawdbot.android
|
||||||
|
|
||||||
import android.app.Application
|
import android.app.Application
|
||||||
import android.os.StrictMode
|
|
||||||
|
|
||||||
class NodeApp : Application() {
|
class NodeApp : Application() {
|
||||||
val runtime: NodeRuntime by lazy { NodeRuntime(this) }
|
val runtime: NodeRuntime by lazy { NodeRuntime(this) }
|
||||||
|
|
||||||
override fun onCreate() {
|
|
||||||
super.onCreate()
|
|
||||||
if (BuildConfig.DEBUG) {
|
|
||||||
StrictMode.setThreadPolicy(
|
|
||||||
StrictMode.ThreadPolicy.Builder()
|
|
||||||
.detectAll()
|
|
||||||
.penaltyLog()
|
|
||||||
.build(),
|
|
||||||
)
|
|
||||||
StrictMode.setVmPolicy(
|
|
||||||
StrictMode.VmPolicy.Builder()
|
|
||||||
.detectAll()
|
|
||||||
.penaltyLog()
|
|
||||||
.build(),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -16,7 +16,6 @@ import com.clawdbot.android.bridge.BridgeDiscovery
|
|||||||
import com.clawdbot.android.bridge.BridgeEndpoint
|
import com.clawdbot.android.bridge.BridgeEndpoint
|
||||||
import com.clawdbot.android.bridge.BridgePairingClient
|
import com.clawdbot.android.bridge.BridgePairingClient
|
||||||
import com.clawdbot.android.bridge.BridgeSession
|
import com.clawdbot.android.bridge.BridgeSession
|
||||||
import com.clawdbot.android.bridge.BridgeTlsParams
|
|
||||||
import com.clawdbot.android.node.CameraCaptureManager
|
import com.clawdbot.android.node.CameraCaptureManager
|
||||||
import com.clawdbot.android.node.LocationCaptureManager
|
import com.clawdbot.android.node.LocationCaptureManager
|
||||||
import com.clawdbot.android.BuildConfig
|
import com.clawdbot.android.BuildConfig
|
||||||
@@ -79,7 +78,7 @@ class NodeRuntime(context: Context) {
|
|||||||
payloadJson =
|
payloadJson =
|
||||||
buildJsonObject {
|
buildJsonObject {
|
||||||
put("message", JsonPrimitive(command))
|
put("message", JsonPrimitive(command))
|
||||||
put("sessionKey", JsonPrimitive(resolveMainSessionKey()))
|
put("sessionKey", JsonPrimitive(mainSessionKey.value))
|
||||||
put("thinking", JsonPrimitive(chatThinkingLevel.value))
|
put("thinking", JsonPrimitive(chatThinkingLevel.value))
|
||||||
put("deliver", JsonPrimitive(false))
|
put("deliver", JsonPrimitive(false))
|
||||||
}.toString(),
|
}.toString(),
|
||||||
@@ -143,13 +142,12 @@ class NodeRuntime(context: Context) {
|
|||||||
private val session =
|
private val session =
|
||||||
BridgeSession(
|
BridgeSession(
|
||||||
scope = scope,
|
scope = scope,
|
||||||
onConnected = { name, remote, mainSessionKey ->
|
onConnected = { name, remote ->
|
||||||
_statusText.value = "Connected"
|
_statusText.value = "Connected"
|
||||||
_serverName.value = name
|
_serverName.value = name
|
||||||
_remoteAddress.value = remote
|
_remoteAddress.value = remote
|
||||||
_isConnected.value = true
|
_isConnected.value = true
|
||||||
_seamColorArgb.value = DEFAULT_SEAM_COLOR_ARGB
|
_seamColorArgb.value = DEFAULT_SEAM_COLOR_ARGB
|
||||||
applyMainSessionKey(mainSessionKey)
|
|
||||||
scope.launch { refreshBrandingFromGateway() }
|
scope.launch { refreshBrandingFromGateway() }
|
||||||
scope.launch { refreshWakeWordsFromGateway() }
|
scope.launch { refreshWakeWordsFromGateway() }
|
||||||
maybeNavigateToA2uiOnConnect()
|
maybeNavigateToA2uiOnConnect()
|
||||||
@@ -161,9 +159,6 @@ class NodeRuntime(context: Context) {
|
|||||||
onInvoke = { req ->
|
onInvoke = { req ->
|
||||||
handleInvoke(req.command, req.paramsJson)
|
handleInvoke(req.command, req.paramsJson)
|
||||||
},
|
},
|
||||||
onTlsFingerprint = { stableId, fingerprint ->
|
|
||||||
prefs.saveBridgeTlsFingerprint(stableId, fingerprint)
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
|
|
||||||
private val chat = ChatController(scope = scope, session = session, json = json)
|
private val chat = ChatController(scope = scope, session = session, json = json)
|
||||||
@@ -177,31 +172,11 @@ class NodeRuntime(context: Context) {
|
|||||||
_remoteAddress.value = null
|
_remoteAddress.value = null
|
||||||
_isConnected.value = false
|
_isConnected.value = false
|
||||||
_seamColorArgb.value = DEFAULT_SEAM_COLOR_ARGB
|
_seamColorArgb.value = DEFAULT_SEAM_COLOR_ARGB
|
||||||
if (!isCanonicalMainSessionKey(_mainSessionKey.value)) {
|
_mainSessionKey.value = "main"
|
||||||
_mainSessionKey.value = "main"
|
|
||||||
}
|
|
||||||
val mainKey = resolveMainSessionKey()
|
|
||||||
talkMode.setMainSessionKey(mainKey)
|
|
||||||
chat.applyMainSessionKey(mainKey)
|
|
||||||
chat.onDisconnected(message)
|
chat.onDisconnected(message)
|
||||||
showLocalCanvasOnDisconnect()
|
showLocalCanvasOnDisconnect()
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun applyMainSessionKey(candidate: String?) {
|
|
||||||
val trimmed = candidate?.trim().orEmpty()
|
|
||||||
if (trimmed.isEmpty()) return
|
|
||||||
if (isCanonicalMainSessionKey(_mainSessionKey.value)) return
|
|
||||||
if (_mainSessionKey.value == trimmed) return
|
|
||||||
_mainSessionKey.value = trimmed
|
|
||||||
talkMode.setMainSessionKey(trimmed)
|
|
||||||
chat.applyMainSessionKey(trimmed)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun resolveMainSessionKey(): String {
|
|
||||||
val trimmed = _mainSessionKey.value.trim()
|
|
||||||
return if (trimmed.isEmpty()) "main" else trimmed
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun maybeNavigateToA2uiOnConnect() {
|
private fun maybeNavigateToA2uiOnConnect() {
|
||||||
val a2uiUrl = resolveA2uiHostUrl() ?: return
|
val a2uiUrl = resolveA2uiHostUrl() ?: return
|
||||||
val current = canvas.currentUrl()?.trim().orEmpty()
|
val current = canvas.currentUrl()?.trim().orEmpty()
|
||||||
@@ -492,17 +467,12 @@ class NodeRuntime(context: Context) {
|
|||||||
scope.launch {
|
scope.launch {
|
||||||
_statusText.value = "Connecting…"
|
_statusText.value = "Connecting…"
|
||||||
val storedToken = prefs.loadBridgeToken()
|
val storedToken = prefs.loadBridgeToken()
|
||||||
val tls = resolveTlsParams(endpoint)
|
|
||||||
val resolved =
|
val resolved =
|
||||||
if (storedToken.isNullOrBlank()) {
|
if (storedToken.isNullOrBlank()) {
|
||||||
_statusText.value = "Pairing…"
|
_statusText.value = "Pairing…"
|
||||||
BridgePairingClient().pairAndHello(
|
BridgePairingClient().pairAndHello(
|
||||||
endpoint = endpoint,
|
endpoint = endpoint,
|
||||||
hello = buildPairingHello(token = null),
|
hello = buildPairingHello(token = null),
|
||||||
tls = tls,
|
|
||||||
onTlsFingerprint = { fingerprint ->
|
|
||||||
prefs.saveBridgeTlsFingerprint(endpoint.stableId, fingerprint)
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
BridgePairingClient.PairResult(ok = true, token = storedToken.trim())
|
BridgePairingClient.PairResult(ok = true, token = storedToken.trim())
|
||||||
@@ -519,7 +489,6 @@ class NodeRuntime(context: Context) {
|
|||||||
session.connect(
|
session.connect(
|
||||||
endpoint = endpoint,
|
endpoint = endpoint,
|
||||||
hello = buildSessionHello(token = authToken),
|
hello = buildSessionHello(token = authToken),
|
||||||
tls = tls,
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -566,41 +535,6 @@ class NodeRuntime(context: Context) {
|
|||||||
session.disconnect()
|
session.disconnect()
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun resolveTlsParams(endpoint: BridgeEndpoint): BridgeTlsParams? {
|
|
||||||
val stored = prefs.loadBridgeTlsFingerprint(endpoint.stableId)
|
|
||||||
val hinted = endpoint.tlsEnabled || !endpoint.tlsFingerprintSha256.isNullOrBlank()
|
|
||||||
val manual = endpoint.stableId.startsWith("manual|")
|
|
||||||
|
|
||||||
if (hinted) {
|
|
||||||
return BridgeTlsParams(
|
|
||||||
required = true,
|
|
||||||
expectedFingerprint = endpoint.tlsFingerprintSha256 ?: stored,
|
|
||||||
allowTOFU = stored == null,
|
|
||||||
stableId = endpoint.stableId,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!stored.isNullOrBlank()) {
|
|
||||||
return BridgeTlsParams(
|
|
||||||
required = true,
|
|
||||||
expectedFingerprint = stored,
|
|
||||||
allowTOFU = false,
|
|
||||||
stableId = endpoint.stableId,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (manual) {
|
|
||||||
return BridgeTlsParams(
|
|
||||||
required = false,
|
|
||||||
expectedFingerprint = null,
|
|
||||||
allowTOFU = true,
|
|
||||||
stableId = endpoint.stableId,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
fun handleCanvasA2UIActionFromWebView(payloadJson: String) {
|
fun handleCanvasA2UIActionFromWebView(payloadJson: String) {
|
||||||
scope.launch {
|
scope.launch {
|
||||||
val trimmed = payloadJson.trim()
|
val trimmed = payloadJson.trim()
|
||||||
@@ -625,7 +559,7 @@ class NodeRuntime(context: Context) {
|
|||||||
(userActionObj["sourceComponentId"] as? JsonPrimitive)?.content?.trim().orEmpty().ifEmpty { "-" }
|
(userActionObj["sourceComponentId"] as? JsonPrimitive)?.content?.trim().orEmpty().ifEmpty { "-" }
|
||||||
val contextJson = (userActionObj["context"] as? JsonObject)?.toString()
|
val contextJson = (userActionObj["context"] as? JsonObject)?.toString()
|
||||||
|
|
||||||
val sessionKey = resolveMainSessionKey()
|
val sessionKey = "main"
|
||||||
val message =
|
val message =
|
||||||
ClawdbotCanvasA2UIAction.formatAgentMessage(
|
ClawdbotCanvasA2UIAction.formatAgentMessage(
|
||||||
actionName = name,
|
actionName = name,
|
||||||
@@ -673,9 +607,8 @@ class NodeRuntime(context: Context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun loadChat(sessionKey: String) {
|
fun loadChat(sessionKey: String = "main") {
|
||||||
val key = sessionKey.trim().ifEmpty { resolveMainSessionKey() }
|
chat.load(sessionKey)
|
||||||
chat.load(key)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun refreshChat() {
|
fun refreshChat() {
|
||||||
@@ -767,8 +700,8 @@ class NodeRuntime(context: Context) {
|
|||||||
val ui = config?.get("ui").asObjectOrNull()
|
val ui = config?.get("ui").asObjectOrNull()
|
||||||
val raw = ui?.get("seamColor").asStringOrNull()?.trim()
|
val raw = ui?.get("seamColor").asStringOrNull()?.trim()
|
||||||
val sessionCfg = config?.get("session").asObjectOrNull()
|
val sessionCfg = config?.get("session").asObjectOrNull()
|
||||||
val mainKey = normalizeMainKey(sessionCfg?.get("mainKey").asStringOrNull())
|
val rawMainKey = sessionCfg?.get("mainKey").asStringOrNull()?.trim()
|
||||||
applyMainSessionKey(mainKey)
|
_mainSessionKey.value = rawMainKey?.takeIf { it.isNotEmpty() } ?: "main"
|
||||||
|
|
||||||
val parsed = parseHexColorArgb(raw)
|
val parsed = parseHexColorArgb(raw)
|
||||||
_seamColorArgb.value = parsed ?: DEFAULT_SEAM_COLOR_ARGB
|
_seamColorArgb.value = parsed ?: DEFAULT_SEAM_COLOR_ARGB
|
||||||
|
|||||||
@@ -147,16 +147,6 @@ class SecurePrefs(context: Context) {
|
|||||||
prefs.edit { putString(key, token.trim()) }
|
prefs.edit { putString(key, token.trim()) }
|
||||||
}
|
}
|
||||||
|
|
||||||
fun loadBridgeTlsFingerprint(stableId: String): String? {
|
|
||||||
val key = "bridge.tls.$stableId"
|
|
||||||
return prefs.getString(key, null)?.trim()?.takeIf { it.isNotEmpty() }
|
|
||||||
}
|
|
||||||
|
|
||||||
fun saveBridgeTlsFingerprint(stableId: String, fingerprint: String) {
|
|
||||||
val key = "bridge.tls.$stableId"
|
|
||||||
prefs.edit { putString(key, fingerprint.trim()) }
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun loadOrCreateInstanceId(): String {
|
private fun loadOrCreateInstanceId(): String {
|
||||||
val existing = prefs.getString("node.instanceId", null)?.trim()
|
val existing = prefs.getString("node.instanceId", null)?.trim()
|
||||||
if (!existing.isNullOrBlank()) return existing
|
if (!existing.isNullOrBlank()) return existing
|
||||||
|
|||||||
@@ -1,13 +0,0 @@
|
|||||||
package com.clawdbot.android
|
|
||||||
|
|
||||||
internal fun normalizeMainKey(raw: String?): String {
|
|
||||||
val trimmed = raw?.trim()
|
|
||||||
return if (!trimmed.isNullOrEmpty()) trimmed else "main"
|
|
||||||
}
|
|
||||||
|
|
||||||
internal fun isCanonicalMainSessionKey(raw: String?): Boolean {
|
|
||||||
val trimmed = raw?.trim().orEmpty()
|
|
||||||
if (trimmed.isEmpty()) return false
|
|
||||||
if (trimmed == "global") return true
|
|
||||||
return trimmed.startsWith("agent:")
|
|
||||||
}
|
|
||||||
@@ -143,8 +143,6 @@ class BridgeDiscovery(
|
|||||||
val gatewayPort = txtInt(resolved, "gatewayPort")
|
val gatewayPort = txtInt(resolved, "gatewayPort")
|
||||||
val bridgePort = txtInt(resolved, "bridgePort")
|
val bridgePort = txtInt(resolved, "bridgePort")
|
||||||
val canvasPort = txtInt(resolved, "canvasPort")
|
val canvasPort = txtInt(resolved, "canvasPort")
|
||||||
val tlsEnabled = txtBool(resolved, "bridgeTls")
|
|
||||||
val tlsFingerprint = txt(resolved, "bridgeTlsSha256")
|
|
||||||
val id = stableId(serviceName, "local.")
|
val id = stableId(serviceName, "local.")
|
||||||
localById[id] =
|
localById[id] =
|
||||||
BridgeEndpoint(
|
BridgeEndpoint(
|
||||||
@@ -157,8 +155,6 @@ class BridgeDiscovery(
|
|||||||
gatewayPort = gatewayPort,
|
gatewayPort = gatewayPort,
|
||||||
bridgePort = bridgePort,
|
bridgePort = bridgePort,
|
||||||
canvasPort = canvasPort,
|
canvasPort = canvasPort,
|
||||||
tlsEnabled = tlsEnabled,
|
|
||||||
tlsFingerprintSha256 = tlsFingerprint,
|
|
||||||
)
|
)
|
||||||
publish()
|
publish()
|
||||||
}
|
}
|
||||||
@@ -213,11 +209,6 @@ class BridgeDiscovery(
|
|||||||
return txt(info, key)?.toIntOrNull()
|
return txt(info, key)?.toIntOrNull()
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun txtBool(info: NsdServiceInfo, key: String): Boolean {
|
|
||||||
val raw = txt(info, key)?.trim()?.lowercase() ?: return false
|
|
||||||
return raw == "1" || raw == "true" || raw == "yes"
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun refreshUnicast(domain: String) {
|
private suspend fun refreshUnicast(domain: String) {
|
||||||
val ptrName = "${serviceType}${domain}"
|
val ptrName = "${serviceType}${domain}"
|
||||||
val ptrMsg = lookupUnicastMessage(ptrName, Type.PTR) ?: return
|
val ptrMsg = lookupUnicastMessage(ptrName, Type.PTR) ?: return
|
||||||
@@ -261,8 +252,6 @@ class BridgeDiscovery(
|
|||||||
val gatewayPort = txtIntValue(txt, "gatewayPort")
|
val gatewayPort = txtIntValue(txt, "gatewayPort")
|
||||||
val bridgePort = txtIntValue(txt, "bridgePort")
|
val bridgePort = txtIntValue(txt, "bridgePort")
|
||||||
val canvasPort = txtIntValue(txt, "canvasPort")
|
val canvasPort = txtIntValue(txt, "canvasPort")
|
||||||
val tlsEnabled = txtBoolValue(txt, "bridgeTls")
|
|
||||||
val tlsFingerprint = txtValue(txt, "bridgeTlsSha256")
|
|
||||||
val id = stableId(instanceName, domain)
|
val id = stableId(instanceName, domain)
|
||||||
next[id] =
|
next[id] =
|
||||||
BridgeEndpoint(
|
BridgeEndpoint(
|
||||||
@@ -275,8 +264,6 @@ class BridgeDiscovery(
|
|||||||
gatewayPort = gatewayPort,
|
gatewayPort = gatewayPort,
|
||||||
bridgePort = bridgePort,
|
bridgePort = bridgePort,
|
||||||
canvasPort = canvasPort,
|
canvasPort = canvasPort,
|
||||||
tlsEnabled = tlsEnabled,
|
|
||||||
tlsFingerprintSha256 = tlsFingerprint,
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -487,11 +474,6 @@ class BridgeDiscovery(
|
|||||||
return txtValue(records, key)?.toIntOrNull()
|
return txtValue(records, key)?.toIntOrNull()
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun txtBoolValue(records: List<TXTRecord>, key: String): Boolean {
|
|
||||||
val raw = txtValue(records, key)?.trim()?.lowercase() ?: return false
|
|
||||||
return raw == "1" || raw == "true" || raw == "yes"
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun decodeDnsTxtString(raw: String): String {
|
private fun decodeDnsTxtString(raw: String): String {
|
||||||
// dnsjava treats TXT as opaque bytes and decodes as ISO-8859-1 to preserve bytes.
|
// dnsjava treats TXT as opaque bytes and decodes as ISO-8859-1 to preserve bytes.
|
||||||
// Our TXT payload is UTF-8 (written by the gateway), so re-decode when possible.
|
// Our TXT payload is UTF-8 (written by the gateway), so re-decode when possible.
|
||||||
|
|||||||
@@ -10,8 +10,6 @@ data class BridgeEndpoint(
|
|||||||
val gatewayPort: Int? = null,
|
val gatewayPort: Int? = null,
|
||||||
val bridgePort: Int? = null,
|
val bridgePort: Int? = null,
|
||||||
val canvasPort: Int? = null,
|
val canvasPort: Int? = null,
|
||||||
val tlsEnabled: Boolean = false,
|
|
||||||
val tlsFingerprintSha256: String? = null,
|
|
||||||
) {
|
) {
|
||||||
companion object {
|
companion object {
|
||||||
fun manual(host: String, port: Int): BridgeEndpoint =
|
fun manual(host: String, port: Int): BridgeEndpoint =
|
||||||
@@ -20,8 +18,6 @@ data class BridgeEndpoint(
|
|||||||
name = "$host:$port",
|
name = "$host:$port",
|
||||||
host = host,
|
host = host,
|
||||||
port = port,
|
port = port,
|
||||||
tlsEnabled = false,
|
|
||||||
tlsFingerprintSha256 = null,
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -14,6 +14,7 @@ import java.io.BufferedWriter
|
|||||||
import java.io.InputStreamReader
|
import java.io.InputStreamReader
|
||||||
import java.io.OutputStreamWriter
|
import java.io.OutputStreamWriter
|
||||||
import java.net.InetSocketAddress
|
import java.net.InetSocketAddress
|
||||||
|
import java.net.Socket
|
||||||
|
|
||||||
class BridgePairingClient {
|
class BridgePairingClient {
|
||||||
private val json = Json { ignoreUnknownKeys = true }
|
private val json = Json { ignoreUnknownKeys = true }
|
||||||
@@ -32,120 +33,95 @@ class BridgePairingClient {
|
|||||||
|
|
||||||
data class PairResult(val ok: Boolean, val token: String?, val error: String? = null)
|
data class PairResult(val ok: Boolean, val token: String?, val error: String? = null)
|
||||||
|
|
||||||
suspend fun pairAndHello(
|
suspend fun pairAndHello(endpoint: BridgeEndpoint, hello: Hello): PairResult =
|
||||||
endpoint: BridgeEndpoint,
|
|
||||||
hello: Hello,
|
|
||||||
tls: BridgeTlsParams? = null,
|
|
||||||
onTlsFingerprint: ((String) -> Unit)? = null,
|
|
||||||
): PairResult =
|
|
||||||
withContext(Dispatchers.IO) {
|
withContext(Dispatchers.IO) {
|
||||||
if (tls != null) {
|
val socket = Socket()
|
||||||
try {
|
socket.tcpNoDelay = true
|
||||||
return@withContext pairAndHelloWithTls(endpoint, hello, tls, onTlsFingerprint)
|
try {
|
||||||
} catch (e: Exception) {
|
socket.connect(InetSocketAddress(endpoint.host, endpoint.port), 8_000)
|
||||||
if (tls.required) throw e
|
socket.soTimeout = 60_000
|
||||||
|
|
||||||
|
val reader = BufferedReader(InputStreamReader(socket.getInputStream(), Charsets.UTF_8))
|
||||||
|
val writer = BufferedWriter(OutputStreamWriter(socket.getOutputStream(), Charsets.UTF_8))
|
||||||
|
|
||||||
|
fun send(line: String) {
|
||||||
|
writer.write(line)
|
||||||
|
writer.write("\n")
|
||||||
|
writer.flush()
|
||||||
}
|
}
|
||||||
}
|
|
||||||
pairAndHelloWithTls(endpoint, hello, null, null)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun pairAndHelloWithTls(
|
fun sendJson(obj: JsonObject) = send(obj.toString())
|
||||||
endpoint: BridgeEndpoint,
|
|
||||||
hello: Hello,
|
|
||||||
tls: BridgeTlsParams?,
|
|
||||||
onTlsFingerprint: ((String) -> Unit)?,
|
|
||||||
): PairResult {
|
|
||||||
val socket =
|
|
||||||
createBridgeSocket(tls) { fingerprint ->
|
|
||||||
onTlsFingerprint?.invoke(fingerprint)
|
|
||||||
}
|
|
||||||
socket.tcpNoDelay = true
|
|
||||||
try {
|
|
||||||
socket.connect(InetSocketAddress(endpoint.host, endpoint.port), 8_000)
|
|
||||||
socket.soTimeout = 60_000
|
|
||||||
startTlsHandshakeIfNeeded(socket)
|
|
||||||
|
|
||||||
val reader = BufferedReader(InputStreamReader(socket.getInputStream(), Charsets.UTF_8))
|
sendJson(
|
||||||
val writer = BufferedWriter(OutputStreamWriter(socket.getOutputStream(), Charsets.UTF_8))
|
buildJsonObject {
|
||||||
|
put("type", JsonPrimitive("hello"))
|
||||||
|
put("nodeId", JsonPrimitive(hello.nodeId))
|
||||||
|
hello.displayName?.let { put("displayName", JsonPrimitive(it)) }
|
||||||
|
hello.token?.let { put("token", JsonPrimitive(it)) }
|
||||||
|
hello.platform?.let { put("platform", JsonPrimitive(it)) }
|
||||||
|
hello.version?.let { put("version", JsonPrimitive(it)) }
|
||||||
|
hello.deviceFamily?.let { put("deviceFamily", JsonPrimitive(it)) }
|
||||||
|
hello.modelIdentifier?.let { put("modelIdentifier", JsonPrimitive(it)) }
|
||||||
|
hello.caps?.let { put("caps", JsonArray(it.map(::JsonPrimitive))) }
|
||||||
|
hello.commands?.let { put("commands", JsonArray(it.map(::JsonPrimitive))) }
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
fun send(line: String) {
|
val firstObj = json.parseToJsonElement(reader.readLine()).asObjectOrNull()
|
||||||
writer.write(line)
|
?: return@withContext PairResult(ok = false, token = null, error = "unexpected bridge response")
|
||||||
writer.write("\n")
|
when (firstObj["type"].asStringOrNull()) {
|
||||||
writer.flush()
|
"hello-ok" -> PairResult(ok = true, token = hello.token)
|
||||||
}
|
"error" -> {
|
||||||
|
val code = firstObj["code"].asStringOrNull() ?: "UNAVAILABLE"
|
||||||
|
val message = firstObj["message"].asStringOrNull() ?: "pairing required"
|
||||||
|
if (code != "NOT_PAIRED" && code != "UNAUTHORIZED") {
|
||||||
|
return@withContext PairResult(ok = false, token = null, error = "$code: $message")
|
||||||
|
}
|
||||||
|
|
||||||
fun sendJson(obj: JsonObject) = send(obj.toString())
|
sendJson(
|
||||||
|
buildJsonObject {
|
||||||
|
put("type", JsonPrimitive("pair-request"))
|
||||||
|
put("nodeId", JsonPrimitive(hello.nodeId))
|
||||||
|
hello.displayName?.let { put("displayName", JsonPrimitive(it)) }
|
||||||
|
hello.platform?.let { put("platform", JsonPrimitive(it)) }
|
||||||
|
hello.version?.let { put("version", JsonPrimitive(it)) }
|
||||||
|
hello.deviceFamily?.let { put("deviceFamily", JsonPrimitive(it)) }
|
||||||
|
hello.modelIdentifier?.let { put("modelIdentifier", JsonPrimitive(it)) }
|
||||||
|
hello.caps?.let { put("caps", JsonArray(it.map(::JsonPrimitive))) }
|
||||||
|
hello.commands?.let { put("commands", JsonArray(it.map(::JsonPrimitive))) }
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
sendJson(
|
while (true) {
|
||||||
buildJsonObject {
|
val nextLine = reader.readLine() ?: break
|
||||||
put("type", JsonPrimitive("hello"))
|
val next = json.parseToJsonElement(nextLine).asObjectOrNull() ?: continue
|
||||||
put("nodeId", JsonPrimitive(hello.nodeId))
|
when (next["type"].asStringOrNull()) {
|
||||||
hello.displayName?.let { put("displayName", JsonPrimitive(it)) }
|
"pair-ok" -> {
|
||||||
hello.token?.let { put("token", JsonPrimitive(it)) }
|
val token = next["token"].asStringOrNull()
|
||||||
hello.platform?.let { put("platform", JsonPrimitive(it)) }
|
return@withContext PairResult(ok = !token.isNullOrBlank(), token = token)
|
||||||
hello.version?.let { put("version", JsonPrimitive(it)) }
|
}
|
||||||
hello.deviceFamily?.let { put("deviceFamily", JsonPrimitive(it)) }
|
"error" -> {
|
||||||
hello.modelIdentifier?.let { put("modelIdentifier", JsonPrimitive(it)) }
|
val c = next["code"].asStringOrNull() ?: "UNAVAILABLE"
|
||||||
hello.caps?.let { put("caps", JsonArray(it.map(::JsonPrimitive))) }
|
val m = next["message"].asStringOrNull() ?: "pairing failed"
|
||||||
hello.commands?.let { put("commands", JsonArray(it.map(::JsonPrimitive))) }
|
return@withContext PairResult(ok = false, token = null, error = "$c: $m")
|
||||||
},
|
}
|
||||||
)
|
|
||||||
|
|
||||||
val firstObj = json.parseToJsonElement(reader.readLine()).asObjectOrNull()
|
|
||||||
?: return PairResult(ok = false, token = null, error = "unexpected bridge response")
|
|
||||||
return when (firstObj["type"].asStringOrNull()) {
|
|
||||||
"hello-ok" -> PairResult(ok = true, token = hello.token)
|
|
||||||
"error" -> {
|
|
||||||
val code = firstObj["code"].asStringOrNull() ?: "UNAVAILABLE"
|
|
||||||
val message = firstObj["message"].asStringOrNull() ?: "pairing required"
|
|
||||||
if (code != "NOT_PAIRED" && code != "UNAUTHORIZED") {
|
|
||||||
return PairResult(ok = false, token = null, error = "$code: $message")
|
|
||||||
}
|
|
||||||
|
|
||||||
sendJson(
|
|
||||||
buildJsonObject {
|
|
||||||
put("type", JsonPrimitive("pair-request"))
|
|
||||||
put("nodeId", JsonPrimitive(hello.nodeId))
|
|
||||||
hello.displayName?.let { put("displayName", JsonPrimitive(it)) }
|
|
||||||
hello.platform?.let { put("platform", JsonPrimitive(it)) }
|
|
||||||
hello.version?.let { put("version", JsonPrimitive(it)) }
|
|
||||||
hello.deviceFamily?.let { put("deviceFamily", JsonPrimitive(it)) }
|
|
||||||
hello.modelIdentifier?.let { put("modelIdentifier", JsonPrimitive(it)) }
|
|
||||||
hello.caps?.let { put("caps", JsonArray(it.map(::JsonPrimitive))) }
|
|
||||||
hello.commands?.let { put("commands", JsonArray(it.map(::JsonPrimitive))) }
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
while (true) {
|
|
||||||
val nextLine = reader.readLine() ?: break
|
|
||||||
val next = json.parseToJsonElement(nextLine).asObjectOrNull() ?: continue
|
|
||||||
when (next["type"].asStringOrNull()) {
|
|
||||||
"pair-ok" -> {
|
|
||||||
val token = next["token"].asStringOrNull()
|
|
||||||
return PairResult(ok = !token.isNullOrBlank(), token = token)
|
|
||||||
}
|
|
||||||
"error" -> {
|
|
||||||
val c = next["code"].asStringOrNull() ?: "UNAVAILABLE"
|
|
||||||
val m = next["message"].asStringOrNull() ?: "pairing failed"
|
|
||||||
return PairResult(ok = false, token = null, error = "$c: $m")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
PairResult(ok = false, token = null, error = "pairing failed")
|
||||||
}
|
}
|
||||||
PairResult(ok = false, token = null, error = "pairing failed")
|
else -> PairResult(ok = false, token = null, error = "unexpected bridge response")
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
val message = e.message?.trim().orEmpty().ifEmpty { "gateway unreachable" }
|
||||||
|
PairResult(ok = false, token = null, error = message)
|
||||||
|
} finally {
|
||||||
|
try {
|
||||||
|
socket.close()
|
||||||
|
} catch (_: Throwable) {
|
||||||
|
// ignore
|
||||||
}
|
}
|
||||||
else -> PairResult(ok = false, token = null, error = "unexpected bridge response")
|
|
||||||
}
|
|
||||||
} catch (e: Exception) {
|
|
||||||
val message = e.message?.trim().orEmpty().ifEmpty { "gateway unreachable" }
|
|
||||||
return PairResult(ok = false, token = null, error = message)
|
|
||||||
} finally {
|
|
||||||
try {
|
|
||||||
socket.close()
|
|
||||||
} catch (_: Throwable) {
|
|
||||||
// ignore
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun JsonElement?.asObjectOrNull(): JsonObject? = this as? JsonObject
|
private fun JsonElement?.asObjectOrNull(): JsonObject? = this as? JsonObject
|
||||||
|
|||||||
@@ -31,11 +31,10 @@ import java.util.concurrent.ConcurrentHashMap
|
|||||||
|
|
||||||
class BridgeSession(
|
class BridgeSession(
|
||||||
private val scope: CoroutineScope,
|
private val scope: CoroutineScope,
|
||||||
private val onConnected: (serverName: String, remoteAddress: String?, mainSessionKey: String?) -> Unit,
|
private val onConnected: (serverName: String, remoteAddress: String?) -> Unit,
|
||||||
private val onDisconnected: (message: String) -> Unit,
|
private val onDisconnected: (message: String) -> Unit,
|
||||||
private val onEvent: (event: String, payloadJson: String?) -> Unit,
|
private val onEvent: (event: String, payloadJson: String?) -> Unit,
|
||||||
private val onInvoke: suspend (InvokeRequest) -> InvokeResult,
|
private val onInvoke: suspend (InvokeRequest) -> InvokeResult,
|
||||||
private val onTlsFingerprint: ((stableId: String, fingerprint: String) -> Unit)? = null,
|
|
||||||
) {
|
) {
|
||||||
data class Hello(
|
data class Hello(
|
||||||
val nodeId: String,
|
val nodeId: String,
|
||||||
@@ -65,19 +64,12 @@ class BridgeSession(
|
|||||||
private val writeLock = Mutex()
|
private val writeLock = Mutex()
|
||||||
private val pending = ConcurrentHashMap<String, CompletableDeferred<RpcResponse>>()
|
private val pending = ConcurrentHashMap<String, CompletableDeferred<RpcResponse>>()
|
||||||
@Volatile private var canvasHostUrl: String? = null
|
@Volatile private var canvasHostUrl: String? = null
|
||||||
@Volatile private var mainSessionKey: String? = null
|
|
||||||
|
|
||||||
private data class DesiredConnection(
|
private var desired: Pair<BridgeEndpoint, Hello>? = null
|
||||||
val endpoint: BridgeEndpoint,
|
|
||||||
val hello: Hello,
|
|
||||||
val tls: BridgeTlsParams?,
|
|
||||||
)
|
|
||||||
|
|
||||||
private var desired: DesiredConnection? = null
|
|
||||||
private var job: Job? = null
|
private var job: Job? = null
|
||||||
|
|
||||||
fun connect(endpoint: BridgeEndpoint, hello: Hello, tls: BridgeTlsParams? = null) {
|
fun connect(endpoint: BridgeEndpoint, hello: Hello) {
|
||||||
desired = DesiredConnection(endpoint, hello, tls)
|
desired = endpoint to hello
|
||||||
if (job == null) {
|
if (job == null) {
|
||||||
job = scope.launch(Dispatchers.IO) { runLoop() }
|
job = scope.launch(Dispatchers.IO) { runLoop() }
|
||||||
}
|
}
|
||||||
@@ -85,7 +77,7 @@ class BridgeSession(
|
|||||||
|
|
||||||
suspend fun updateHello(hello: Hello) {
|
suspend fun updateHello(hello: Hello) {
|
||||||
val target = desired ?: return
|
val target = desired ?: return
|
||||||
desired = target.copy(hello = hello)
|
desired = target.first to hello
|
||||||
val conn = currentConnection ?: return
|
val conn = currentConnection ?: return
|
||||||
conn.sendJson(buildHelloJson(hello))
|
conn.sendJson(buildHelloJson(hello))
|
||||||
}
|
}
|
||||||
@@ -98,13 +90,11 @@ class BridgeSession(
|
|||||||
job?.cancelAndJoin()
|
job?.cancelAndJoin()
|
||||||
job = null
|
job = null
|
||||||
canvasHostUrl = null
|
canvasHostUrl = null
|
||||||
mainSessionKey = null
|
|
||||||
onDisconnected("Offline")
|
onDisconnected("Offline")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun currentCanvasHostUrl(): String? = canvasHostUrl
|
fun currentCanvasHostUrl(): String? = canvasHostUrl
|
||||||
fun currentMainSessionKey(): String? = mainSessionKey
|
|
||||||
|
|
||||||
suspend fun sendEvent(event: String, payloadJson: String?) {
|
suspend fun sendEvent(event: String, payloadJson: String?) {
|
||||||
val conn = currentConnection ?: return
|
val conn = currentConnection ?: return
|
||||||
@@ -172,10 +162,10 @@ class BridgeSession(
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
val (endpoint, hello, tls) = target
|
val (endpoint, hello) = target
|
||||||
try {
|
try {
|
||||||
onDisconnected(if (attempt == 0) "Connecting…" else "Reconnecting…")
|
onDisconnected(if (attempt == 0) "Connecting…" else "Reconnecting…")
|
||||||
connectOnce(endpoint, hello, tls)
|
connectOnce(endpoint, hello)
|
||||||
attempt = 0
|
attempt = 0
|
||||||
} catch (err: Throwable) {
|
} catch (err: Throwable) {
|
||||||
attempt += 1
|
attempt += 1
|
||||||
@@ -199,76 +189,58 @@ class BridgeSession(
|
|||||||
return InvokeResult.error(code = "UNAVAILABLE", message = msg)
|
return InvokeResult.error(code = "UNAVAILABLE", message = msg)
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun connectOnce(endpoint: BridgeEndpoint, hello: Hello, tls: BridgeTlsParams?) =
|
private suspend fun connectOnce(endpoint: BridgeEndpoint, hello: Hello) =
|
||||||
withContext(Dispatchers.IO) {
|
withContext(Dispatchers.IO) {
|
||||||
if (tls != null) {
|
val socket = Socket()
|
||||||
try {
|
socket.tcpNoDelay = true
|
||||||
connectWithSocket(endpoint, hello, tls)
|
socket.connect(InetSocketAddress(endpoint.host, endpoint.port), 8_000)
|
||||||
return@withContext
|
socket.soTimeout = 0
|
||||||
} catch (err: Throwable) {
|
|
||||||
if (tls.required) throw err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
connectWithSocket(endpoint, hello, null)
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun connectWithSocket(endpoint: BridgeEndpoint, hello: Hello, tls: BridgeTlsParams?) {
|
val reader = BufferedReader(InputStreamReader(socket.getInputStream(), Charsets.UTF_8))
|
||||||
val socket =
|
val writer = BufferedWriter(OutputStreamWriter(socket.getOutputStream(), Charsets.UTF_8))
|
||||||
createBridgeSocket(tls) { fingerprint ->
|
|
||||||
onTlsFingerprint?.invoke(tls?.stableId ?: endpoint.stableId, fingerprint)
|
|
||||||
}
|
|
||||||
socket.tcpNoDelay = true
|
|
||||||
socket.connect(InetSocketAddress(endpoint.host, endpoint.port), 8_000)
|
|
||||||
socket.soTimeout = 0
|
|
||||||
startTlsHandshakeIfNeeded(socket)
|
|
||||||
|
|
||||||
val reader = BufferedReader(InputStreamReader(socket.getInputStream(), Charsets.UTF_8))
|
val conn = Connection(socket, reader, writer, writeLock)
|
||||||
val writer = BufferedWriter(OutputStreamWriter(socket.getOutputStream(), Charsets.UTF_8))
|
currentConnection = conn
|
||||||
|
|
||||||
val conn = Connection(socket, reader, writer, writeLock)
|
try {
|
||||||
currentConnection = conn
|
conn.sendJson(buildHelloJson(hello))
|
||||||
|
|
||||||
try {
|
val firstLine = reader.readLine() ?: throw IllegalStateException("bridge closed connection")
|
||||||
conn.sendJson(buildHelloJson(hello))
|
val first = json.parseToJsonElement(firstLine).asObjectOrNull()
|
||||||
|
?: throw IllegalStateException("unexpected bridge response")
|
||||||
val firstLine = reader.readLine() ?: throw IllegalStateException("bridge closed connection")
|
when (first["type"].asStringOrNull()) {
|
||||||
val first = json.parseToJsonElement(firstLine).asObjectOrNull()
|
"hello-ok" -> {
|
||||||
?: throw IllegalStateException("unexpected bridge response")
|
val name = first["serverName"].asStringOrNull() ?: "Bridge"
|
||||||
when (first["type"].asStringOrNull()) {
|
val rawCanvasUrl = first["canvasHostUrl"].asStringOrNull()?.trim()?.ifEmpty { null }
|
||||||
"hello-ok" -> {
|
canvasHostUrl = normalizeCanvasHostUrl(rawCanvasUrl, endpoint)
|
||||||
val name = first["serverName"].asStringOrNull() ?: "Bridge"
|
if (BuildConfig.DEBUG) {
|
||||||
val rawCanvasUrl = first["canvasHostUrl"].asStringOrNull()?.trim()?.ifEmpty { null }
|
// Local JVM unit tests use android.jar stubs; Log.d can throw "not mocked".
|
||||||
val rawMainSessionKey = first["mainSessionKey"].asStringOrNull()?.trim()?.ifEmpty { null }
|
runCatching {
|
||||||
canvasHostUrl = normalizeCanvasHostUrl(rawCanvasUrl, endpoint)
|
android.util.Log.d(
|
||||||
mainSessionKey = rawMainSessionKey
|
"ClawdbotBridge",
|
||||||
if (BuildConfig.DEBUG) {
|
"canvasHostUrl resolved=${canvasHostUrl ?: "none"} (raw=${rawCanvasUrl ?: "none"})",
|
||||||
// Local JVM unit tests use android.jar stubs; Log.d can throw "not mocked".
|
)
|
||||||
runCatching {
|
}
|
||||||
android.util.Log.d(
|
|
||||||
"ClawdbotBridge",
|
|
||||||
"canvasHostUrl resolved=${canvasHostUrl ?: "none"} (raw=${rawCanvasUrl ?: "none"})",
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
onConnected(name, conn.remoteAddress)
|
||||||
}
|
}
|
||||||
onConnected(name, conn.remoteAddress, rawMainSessionKey)
|
"error" -> {
|
||||||
|
val code = first["code"].asStringOrNull() ?: "UNAVAILABLE"
|
||||||
|
val msg = first["message"].asStringOrNull() ?: "connect failed"
|
||||||
|
throw IllegalStateException("$code: $msg")
|
||||||
|
}
|
||||||
|
else -> throw IllegalStateException("unexpected bridge response")
|
||||||
}
|
}
|
||||||
"error" -> {
|
|
||||||
val code = first["code"].asStringOrNull() ?: "UNAVAILABLE"
|
|
||||||
val msg = first["message"].asStringOrNull() ?: "connect failed"
|
|
||||||
throw IllegalStateException("$code: $msg")
|
|
||||||
}
|
|
||||||
else -> throw IllegalStateException("unexpected bridge response")
|
|
||||||
}
|
|
||||||
|
|
||||||
while (scope.isActive) {
|
while (scope.isActive) {
|
||||||
val line = reader.readLine() ?: break
|
val line = reader.readLine() ?: break
|
||||||
val frame = json.parseToJsonElement(line).asObjectOrNull() ?: continue
|
val frame = json.parseToJsonElement(line).asObjectOrNull() ?: continue
|
||||||
when (frame["type"].asStringOrNull()) {
|
when (frame["type"].asStringOrNull()) {
|
||||||
"event" -> {
|
"event" -> {
|
||||||
val event = frame["event"].asStringOrNull() ?: continue
|
val event = frame["event"].asStringOrNull() ?: return@withContext
|
||||||
val payload = frame["payloadJSON"].asStringOrNull()
|
val payload = frame["payloadJSON"].asStringOrNull()
|
||||||
onEvent(event, payload)
|
onEvent(event, payload)
|
||||||
}
|
}
|
||||||
"ping" -> {
|
"ping" -> {
|
||||||
val id = frame["id"].asStringOrNull() ?: ""
|
val id = frame["id"].asStringOrNull() ?: ""
|
||||||
conn.sendJson(buildJsonObject { put("type", JsonPrimitive("pong")); put("id", JsonPrimitive(id)) })
|
conn.sendJson(buildJsonObject { put("type", JsonPrimitive("pong")); put("id", JsonPrimitive(id)) })
|
||||||
@@ -314,20 +286,20 @@ class BridgeSession(
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
"invoke-res" -> {
|
"invoke-res" -> {
|
||||||
// gateway->node only (ignore)
|
// gateway->node only (ignore)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
} finally {
|
||||||
|
currentConnection = null
|
||||||
|
for ((_, waiter) in pending) {
|
||||||
|
waiter.cancel()
|
||||||
|
}
|
||||||
|
pending.clear()
|
||||||
|
conn.closeQuietly()
|
||||||
}
|
}
|
||||||
} finally {
|
|
||||||
currentConnection = null
|
|
||||||
for ((_, waiter) in pending) {
|
|
||||||
waiter.cancel()
|
|
||||||
}
|
|
||||||
pending.clear()
|
|
||||||
conn.closeQuietly()
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
private fun buildHelloJson(hello: Hello): JsonObject =
|
private fun buildHelloJson(hello: Hello): JsonObject =
|
||||||
buildJsonObject {
|
buildJsonObject {
|
||||||
|
|||||||
@@ -1,81 +0,0 @@
|
|||||||
package com.clawdbot.android.bridge
|
|
||||||
|
|
||||||
import android.annotation.SuppressLint
|
|
||||||
import java.net.Socket
|
|
||||||
import java.security.MessageDigest
|
|
||||||
import java.security.SecureRandom
|
|
||||||
import java.security.cert.CertificateException
|
|
||||||
import java.security.cert.X509Certificate
|
|
||||||
import javax.net.ssl.SSLContext
|
|
||||||
import javax.net.ssl.SSLSocket
|
|
||||||
import javax.net.ssl.TrustManagerFactory
|
|
||||||
import javax.net.ssl.X509TrustManager
|
|
||||||
|
|
||||||
data class BridgeTlsParams(
|
|
||||||
val required: Boolean,
|
|
||||||
val expectedFingerprint: String?,
|
|
||||||
val allowTOFU: Boolean,
|
|
||||||
val stableId: String,
|
|
||||||
)
|
|
||||||
|
|
||||||
fun createBridgeSocket(params: BridgeTlsParams?, onStore: ((String) -> Unit)? = null): Socket {
|
|
||||||
if (params == null) return Socket()
|
|
||||||
val expected = params.expectedFingerprint?.let(::normalizeFingerprint)
|
|
||||||
val defaultTrust = defaultTrustManager()
|
|
||||||
@SuppressLint("CustomX509TrustManager")
|
|
||||||
val trustManager =
|
|
||||||
object : X509TrustManager {
|
|
||||||
override fun checkClientTrusted(chain: Array<X509Certificate>, authType: String) {
|
|
||||||
defaultTrust.checkClientTrusted(chain, authType)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun checkServerTrusted(chain: Array<X509Certificate>, authType: String) {
|
|
||||||
if (chain.isEmpty()) throw CertificateException("empty certificate chain")
|
|
||||||
val fingerprint = sha256Hex(chain[0].encoded)
|
|
||||||
if (expected != null) {
|
|
||||||
if (fingerprint != expected) {
|
|
||||||
throw CertificateException("bridge TLS fingerprint mismatch")
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (params.allowTOFU) {
|
|
||||||
onStore?.invoke(fingerprint)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
defaultTrust.checkServerTrusted(chain, authType)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun getAcceptedIssuers(): Array<X509Certificate> = defaultTrust.acceptedIssuers
|
|
||||||
}
|
|
||||||
|
|
||||||
val context = SSLContext.getInstance("TLS")
|
|
||||||
context.init(null, arrayOf(trustManager), SecureRandom())
|
|
||||||
return context.socketFactory.createSocket()
|
|
||||||
}
|
|
||||||
|
|
||||||
fun startTlsHandshakeIfNeeded(socket: Socket) {
|
|
||||||
if (socket is SSLSocket) {
|
|
||||||
socket.startHandshake()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun defaultTrustManager(): X509TrustManager {
|
|
||||||
val factory = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm())
|
|
||||||
factory.init(null as java.security.KeyStore?)
|
|
||||||
val trust =
|
|
||||||
factory.trustManagers.firstOrNull { it is X509TrustManager } as? X509TrustManager
|
|
||||||
return trust ?: throw IllegalStateException("No default X509TrustManager found")
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun sha256Hex(data: ByteArray): String {
|
|
||||||
val digest = MessageDigest.getInstance("SHA-256").digest(data)
|
|
||||||
val out = StringBuilder(digest.size * 2)
|
|
||||||
for (byte in digest) {
|
|
||||||
out.append(String.format("%02x", byte))
|
|
||||||
}
|
|
||||||
return out.toString()
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun normalizeFingerprint(raw: String): String {
|
|
||||||
return raw.lowercase().filter { it in '0'..'9' || it in 'a'..'f' }
|
|
||||||
}
|
|
||||||
@@ -71,21 +71,12 @@ class ChatController(
|
|||||||
_sessionId.value = null
|
_sessionId.value = null
|
||||||
}
|
}
|
||||||
|
|
||||||
fun load(sessionKey: String) {
|
fun load(sessionKey: String = "main") {
|
||||||
val key = sessionKey.trim().ifEmpty { "main" }
|
val key = sessionKey.trim().ifEmpty { "main" }
|
||||||
_sessionKey.value = key
|
_sessionKey.value = key
|
||||||
scope.launch { bootstrap(forceHealth = true) }
|
scope.launch { bootstrap(forceHealth = true) }
|
||||||
}
|
}
|
||||||
|
|
||||||
fun applyMainSessionKey(mainSessionKey: String) {
|
|
||||||
val trimmed = mainSessionKey.trim()
|
|
||||||
if (trimmed.isEmpty()) return
|
|
||||||
if (_sessionKey.value == trimmed) return
|
|
||||||
if (_sessionKey.value != "main") return
|
|
||||||
_sessionKey.value = trimmed
|
|
||||||
scope.launch { bootstrap(forceHealth = true) }
|
|
||||||
}
|
|
||||||
|
|
||||||
fun refresh() {
|
fun refresh() {
|
||||||
scope.launch { bootstrap(forceHealth = true) }
|
scope.launch { bootstrap(forceHealth = true) }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,10 +5,8 @@ import android.content.Context
|
|||||||
import android.annotation.SuppressLint
|
import android.annotation.SuppressLint
|
||||||
import android.graphics.Bitmap
|
import android.graphics.Bitmap
|
||||||
import android.graphics.BitmapFactory
|
import android.graphics.BitmapFactory
|
||||||
import android.graphics.Matrix
|
|
||||||
import android.util.Base64
|
import android.util.Base64
|
||||||
import android.content.pm.PackageManager
|
import android.content.pm.PackageManager
|
||||||
import androidx.exifinterface.media.ExifInterface
|
|
||||||
import androidx.lifecycle.LifecycleOwner
|
import androidx.lifecycle.LifecycleOwner
|
||||||
import androidx.camera.core.CameraSelector
|
import androidx.camera.core.CameraSelector
|
||||||
import androidx.camera.core.ImageCapture
|
import androidx.camera.core.ImageCapture
|
||||||
@@ -88,19 +86,18 @@ class CameraCaptureManager(private val context: Context) {
|
|||||||
provider.unbindAll()
|
provider.unbindAll()
|
||||||
provider.bindToLifecycle(owner, selector, capture)
|
provider.bindToLifecycle(owner, selector, capture)
|
||||||
|
|
||||||
val (bytes, orientation) = capture.takeJpegWithExif(context.mainExecutor())
|
val bytes = capture.takeJpegBytes(context.mainExecutor())
|
||||||
val decoded = BitmapFactory.decodeByteArray(bytes, 0, bytes.size)
|
val decoded = BitmapFactory.decodeByteArray(bytes, 0, bytes.size)
|
||||||
?: throw IllegalStateException("UNAVAILABLE: failed to decode captured image")
|
?: throw IllegalStateException("UNAVAILABLE: failed to decode captured image")
|
||||||
val rotated = rotateBitmapByExif(decoded, orientation)
|
|
||||||
val scaled =
|
val scaled =
|
||||||
if (maxWidth != null && maxWidth > 0 && rotated.width > maxWidth) {
|
if (maxWidth != null && maxWidth > 0 && decoded.width > maxWidth) {
|
||||||
val h =
|
val h =
|
||||||
(rotated.height.toDouble() * (maxWidth.toDouble() / rotated.width.toDouble()))
|
(decoded.height.toDouble() * (maxWidth.toDouble() / decoded.width.toDouble()))
|
||||||
.toInt()
|
.toInt()
|
||||||
.coerceAtLeast(1)
|
.coerceAtLeast(1)
|
||||||
rotated.scale(maxWidth, h)
|
decoded.scale(maxWidth, h)
|
||||||
} else {
|
} else {
|
||||||
rotated
|
decoded
|
||||||
}
|
}
|
||||||
|
|
||||||
val maxPayloadBytes = 5 * 1024 * 1024
|
val maxPayloadBytes = 5 * 1024 * 1024
|
||||||
@@ -197,31 +194,6 @@ class CameraCaptureManager(private val context: Context) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun rotateBitmapByExif(bitmap: Bitmap, orientation: Int): Bitmap {
|
|
||||||
val matrix = Matrix()
|
|
||||||
when (orientation) {
|
|
||||||
ExifInterface.ORIENTATION_ROTATE_90 -> matrix.postRotate(90f)
|
|
||||||
ExifInterface.ORIENTATION_ROTATE_180 -> matrix.postRotate(180f)
|
|
||||||
ExifInterface.ORIENTATION_ROTATE_270 -> matrix.postRotate(270f)
|
|
||||||
ExifInterface.ORIENTATION_FLIP_HORIZONTAL -> matrix.postScale(-1f, 1f)
|
|
||||||
ExifInterface.ORIENTATION_FLIP_VERTICAL -> matrix.postScale(1f, -1f)
|
|
||||||
ExifInterface.ORIENTATION_TRANSPOSE -> {
|
|
||||||
matrix.postRotate(90f)
|
|
||||||
matrix.postScale(-1f, 1f)
|
|
||||||
}
|
|
||||||
ExifInterface.ORIENTATION_TRANSVERSE -> {
|
|
||||||
matrix.postRotate(-90f)
|
|
||||||
matrix.postScale(-1f, 1f)
|
|
||||||
}
|
|
||||||
else -> return bitmap
|
|
||||||
}
|
|
||||||
val rotated = Bitmap.createBitmap(bitmap, 0, 0, bitmap.width, bitmap.height, matrix, true)
|
|
||||||
if (rotated !== bitmap) {
|
|
||||||
bitmap.recycle()
|
|
||||||
}
|
|
||||||
return rotated
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun parseFacing(paramsJson: String?): String? =
|
private fun parseFacing(paramsJson: String?): String? =
|
||||||
when {
|
when {
|
||||||
paramsJson?.contains("\"front\"") == true -> "front"
|
paramsJson?.contains("\"front\"") == true -> "front"
|
||||||
@@ -282,8 +254,7 @@ private suspend fun Context.cameraProvider(): ProcessCameraProvider =
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Returns (jpegBytes, exifOrientation) so caller can rotate the decoded bitmap. */
|
private suspend fun ImageCapture.takeJpegBytes(executor: Executor): ByteArray =
|
||||||
private suspend fun ImageCapture.takeJpegWithExif(executor: Executor): Pair<ByteArray, Int> =
|
|
||||||
suspendCancellableCoroutine { cont ->
|
suspendCancellableCoroutine { cont ->
|
||||||
val file = File.createTempFile("clawdbot-snap-", ".jpg")
|
val file = File.createTempFile("clawdbot-snap-", ".jpg")
|
||||||
val options = ImageCapture.OutputFileOptions.Builder(file).build()
|
val options = ImageCapture.OutputFileOptions.Builder(file).build()
|
||||||
@@ -292,19 +263,13 @@ private suspend fun ImageCapture.takeJpegWithExif(executor: Executor): Pair<Byte
|
|||||||
executor,
|
executor,
|
||||||
object : ImageCapture.OnImageSavedCallback {
|
object : ImageCapture.OnImageSavedCallback {
|
||||||
override fun onError(exception: ImageCaptureException) {
|
override fun onError(exception: ImageCaptureException) {
|
||||||
file.delete()
|
|
||||||
cont.resumeWithException(exception)
|
cont.resumeWithException(exception)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onImageSaved(outputFileResults: ImageCapture.OutputFileResults) {
|
override fun onImageSaved(outputFileResults: ImageCapture.OutputFileResults) {
|
||||||
try {
|
try {
|
||||||
val exif = ExifInterface(file.absolutePath)
|
|
||||||
val orientation = exif.getAttributeInt(
|
|
||||||
ExifInterface.TAG_ORIENTATION,
|
|
||||||
ExifInterface.ORIENTATION_NORMAL,
|
|
||||||
)
|
|
||||||
val bytes = file.readBytes()
|
val bytes = file.readBytes()
|
||||||
cont.resume(Pair(bytes, orientation))
|
cont.resume(bytes)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
cont.resumeWithException(e)
|
cont.resumeWithException(e)
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import android.content.Context
|
|||||||
import android.hardware.display.DisplayManager
|
import android.hardware.display.DisplayManager
|
||||||
import android.media.MediaRecorder
|
import android.media.MediaRecorder
|
||||||
import android.media.projection.MediaProjectionManager
|
import android.media.projection.MediaProjectionManager
|
||||||
import android.os.Build
|
|
||||||
import android.util.Base64
|
import android.util.Base64
|
||||||
import com.clawdbot.android.ScreenCaptureRequester
|
import com.clawdbot.android.ScreenCaptureRequester
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
@@ -66,7 +65,7 @@ class ScreenRecordManager(private val context: Context) {
|
|||||||
val file = File.createTempFile("clawdbot-screen-", ".mp4")
|
val file = File.createTempFile("clawdbot-screen-", ".mp4")
|
||||||
if (includeAudio) ensureMicPermission()
|
if (includeAudio) ensureMicPermission()
|
||||||
|
|
||||||
val recorder = createMediaRecorder()
|
val recorder = MediaRecorder()
|
||||||
var virtualDisplay: android.hardware.display.VirtualDisplay? = null
|
var virtualDisplay: android.hardware.display.VirtualDisplay? = null
|
||||||
try {
|
try {
|
||||||
if (includeAudio) {
|
if (includeAudio) {
|
||||||
@@ -122,8 +121,6 @@ class ScreenRecordManager(private val context: Context) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun createMediaRecorder(): MediaRecorder = MediaRecorder(context)
|
|
||||||
|
|
||||||
private suspend fun ensureMicPermission() {
|
private suspend fun ensureMicPermission() {
|
||||||
val granted =
|
val granted =
|
||||||
androidx.core.content.ContextCompat.checkSelfPermission(
|
androidx.core.content.ContextCompat.checkSelfPermission(
|
||||||
|
|||||||
@@ -39,7 +39,6 @@ import androidx.compose.material3.MaterialTheme
|
|||||||
import androidx.compose.material3.ModalBottomSheet
|
import androidx.compose.material3.ModalBottomSheet
|
||||||
import androidx.compose.material3.rememberModalBottomSheetState
|
import androidx.compose.material3.rememberModalBottomSheetState
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.automirrored.filled.ScreenShare
|
|
||||||
import androidx.compose.material.icons.filled.ChatBubble
|
import androidx.compose.material.icons.filled.ChatBubble
|
||||||
import androidx.compose.material.icons.filled.CheckCircle
|
import androidx.compose.material.icons.filled.CheckCircle
|
||||||
import androidx.compose.material.icons.filled.Error
|
import androidx.compose.material.icons.filled.Error
|
||||||
@@ -48,6 +47,7 @@ import androidx.compose.material.icons.filled.PhotoCamera
|
|||||||
import androidx.compose.material.icons.filled.RecordVoiceOver
|
import androidx.compose.material.icons.filled.RecordVoiceOver
|
||||||
import androidx.compose.material.icons.filled.Refresh
|
import androidx.compose.material.icons.filled.Refresh
|
||||||
import androidx.compose.material.icons.filled.Report
|
import androidx.compose.material.icons.filled.Report
|
||||||
|
import androidx.compose.material.icons.filled.ScreenShare
|
||||||
import androidx.compose.material.icons.filled.Settings
|
import androidx.compose.material.icons.filled.Settings
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
@@ -123,7 +123,7 @@ fun RootScreen(viewModel: MainViewModel) {
|
|||||||
if (screenRecordActive) {
|
if (screenRecordActive) {
|
||||||
return@remember StatusActivity(
|
return@remember StatusActivity(
|
||||||
title = "Recording screen…",
|
title = "Recording screen…",
|
||||||
icon = Icons.AutoMirrored.Filled.ScreenShare,
|
icon = Icons.Default.ScreenShare,
|
||||||
contentDescription = "Recording screen",
|
contentDescription = "Recording screen",
|
||||||
tint = androidx.compose.ui.graphics.Color.Red,
|
tint = androidx.compose.ui.graphics.Color.Red,
|
||||||
)
|
)
|
||||||
@@ -327,10 +327,11 @@ private fun CanvasView(viewModel: MainViewModel, modifier: Modifier = Modifier)
|
|||||||
// Some embedded web UIs (incl. the "background website") use localStorage/sessionStorage.
|
// Some embedded web UIs (incl. the "background website") use localStorage/sessionStorage.
|
||||||
settings.domStorageEnabled = true
|
settings.domStorageEnabled = true
|
||||||
settings.mixedContentMode = WebSettings.MIXED_CONTENT_COMPATIBILITY_MODE
|
settings.mixedContentMode = WebSettings.MIXED_CONTENT_COMPATIBILITY_MODE
|
||||||
|
if (WebViewFeature.isFeatureSupported(WebViewFeature.FORCE_DARK)) {
|
||||||
|
WebSettingsCompat.setForceDark(settings, WebSettingsCompat.FORCE_DARK_OFF)
|
||||||
|
}
|
||||||
if (WebViewFeature.isFeatureSupported(WebViewFeature.ALGORITHMIC_DARKENING)) {
|
if (WebViewFeature.isFeatureSupported(WebViewFeature.ALGORITHMIC_DARKENING)) {
|
||||||
WebSettingsCompat.setAlgorithmicDarkeningAllowed(settings, false)
|
WebSettingsCompat.setAlgorithmicDarkeningAllowed(settings, false)
|
||||||
} else {
|
|
||||||
disableForceDarkIfSupported(settings)
|
|
||||||
}
|
}
|
||||||
if (isDebuggable) {
|
if (isDebuggable) {
|
||||||
Log.d("ClawdbotWebView", "userAgent: ${settings.userAgentString}")
|
Log.d("ClawdbotWebView", "userAgent: ${settings.userAgentString}")
|
||||||
@@ -413,12 +414,6 @@ private fun CanvasView(viewModel: MainViewModel, modifier: Modifier = Modifier)
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun disableForceDarkIfSupported(settings: WebSettings) {
|
|
||||||
if (!WebViewFeature.isFeatureSupported(WebViewFeature.FORCE_DARK)) return
|
|
||||||
@Suppress("DEPRECATION")
|
|
||||||
WebSettingsCompat.setForceDark(settings, WebSettingsCompat.FORCE_DARK_OFF)
|
|
||||||
}
|
|
||||||
|
|
||||||
private class CanvasA2UIActionBridge(private val onMessage: (String) -> Unit) {
|
private class CanvasA2UIActionBridge(private val onMessage: (String) -> Unit) {
|
||||||
@JavascriptInterface
|
@JavascriptInterface
|
||||||
fun postMessage(payload: String?) {
|
fun postMessage(payload: String?) {
|
||||||
|
|||||||
@@ -44,7 +44,6 @@ import com.clawdbot.android.chat.ChatSessionEntry
|
|||||||
fun ChatComposer(
|
fun ChatComposer(
|
||||||
sessionKey: String,
|
sessionKey: String,
|
||||||
sessions: List<ChatSessionEntry>,
|
sessions: List<ChatSessionEntry>,
|
||||||
mainSessionKey: String,
|
|
||||||
healthOk: Boolean,
|
healthOk: Boolean,
|
||||||
thinkingLevel: String,
|
thinkingLevel: String,
|
||||||
pendingRunCount: Int,
|
pendingRunCount: Int,
|
||||||
@@ -62,7 +61,7 @@ fun ChatComposer(
|
|||||||
var showThinkingMenu by remember { mutableStateOf(false) }
|
var showThinkingMenu by remember { mutableStateOf(false) }
|
||||||
var showSessionMenu by remember { mutableStateOf(false) }
|
var showSessionMenu by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
val sessionOptions = resolveSessionChoices(sessionKey, sessions, mainSessionKey = mainSessionKey)
|
val sessionOptions = resolveSessionChoices(sessionKey, sessions)
|
||||||
val currentSessionLabel =
|
val currentSessionLabel =
|
||||||
sessionOptions.firstOrNull { it.key == sessionKey }?.displayName ?: sessionKey
|
sessionOptions.firstOrNull { it.key == sessionKey }?.displayName ?: sessionKey
|
||||||
|
|
||||||
|
|||||||
@@ -33,14 +33,13 @@ fun ChatSheetContent(viewModel: MainViewModel) {
|
|||||||
val pendingRunCount by viewModel.pendingRunCount.collectAsState()
|
val pendingRunCount by viewModel.pendingRunCount.collectAsState()
|
||||||
val healthOk by viewModel.chatHealthOk.collectAsState()
|
val healthOk by viewModel.chatHealthOk.collectAsState()
|
||||||
val sessionKey by viewModel.chatSessionKey.collectAsState()
|
val sessionKey by viewModel.chatSessionKey.collectAsState()
|
||||||
val mainSessionKey by viewModel.mainSessionKey.collectAsState()
|
|
||||||
val thinkingLevel by viewModel.chatThinkingLevel.collectAsState()
|
val thinkingLevel by viewModel.chatThinkingLevel.collectAsState()
|
||||||
val streamingAssistantText by viewModel.chatStreamingAssistantText.collectAsState()
|
val streamingAssistantText by viewModel.chatStreamingAssistantText.collectAsState()
|
||||||
val pendingToolCalls by viewModel.chatPendingToolCalls.collectAsState()
|
val pendingToolCalls by viewModel.chatPendingToolCalls.collectAsState()
|
||||||
val sessions by viewModel.chatSessions.collectAsState()
|
val sessions by viewModel.chatSessions.collectAsState()
|
||||||
|
|
||||||
LaunchedEffect(mainSessionKey) {
|
LaunchedEffect(Unit) {
|
||||||
viewModel.loadChat(mainSessionKey)
|
viewModel.loadChat("main")
|
||||||
viewModel.refreshChatSessions(limit = 200)
|
viewModel.refreshChatSessions(limit = 200)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -86,7 +85,6 @@ fun ChatSheetContent(viewModel: MainViewModel) {
|
|||||||
ChatComposer(
|
ChatComposer(
|
||||||
sessionKey = sessionKey,
|
sessionKey = sessionKey,
|
||||||
sessions = sessions,
|
sessions = sessions,
|
||||||
mainSessionKey = mainSessionKey,
|
|
||||||
healthOk = healthOk,
|
healthOk = healthOk,
|
||||||
thinkingLevel = thinkingLevel,
|
thinkingLevel = thinkingLevel,
|
||||||
pendingRunCount = pendingRunCount,
|
pendingRunCount = pendingRunCount,
|
||||||
|
|||||||
@@ -2,23 +2,20 @@ package com.clawdbot.android.ui.chat
|
|||||||
|
|
||||||
import com.clawdbot.android.chat.ChatSessionEntry
|
import com.clawdbot.android.chat.ChatSessionEntry
|
||||||
|
|
||||||
|
private const val MAIN_SESSION_KEY = "main"
|
||||||
private const val RECENT_WINDOW_MS = 24 * 60 * 60 * 1000L
|
private const val RECENT_WINDOW_MS = 24 * 60 * 60 * 1000L
|
||||||
|
|
||||||
fun resolveSessionChoices(
|
fun resolveSessionChoices(
|
||||||
currentSessionKey: String,
|
currentSessionKey: String,
|
||||||
sessions: List<ChatSessionEntry>,
|
sessions: List<ChatSessionEntry>,
|
||||||
mainSessionKey: String,
|
|
||||||
nowMs: Long = System.currentTimeMillis(),
|
nowMs: Long = System.currentTimeMillis(),
|
||||||
): List<ChatSessionEntry> {
|
): List<ChatSessionEntry> {
|
||||||
val mainKey = mainSessionKey.trim().ifEmpty { "main" }
|
val current = currentSessionKey.trim()
|
||||||
val current = currentSessionKey.trim().let { if (it == "main" && mainKey != "main") mainKey else it }
|
|
||||||
val aliasKey = if (mainKey == "main") null else "main"
|
|
||||||
val cutoff = nowMs - RECENT_WINDOW_MS
|
val cutoff = nowMs - RECENT_WINDOW_MS
|
||||||
val sorted = sessions.sortedByDescending { it.updatedAtMs ?: 0L }
|
val sorted = sessions.sortedByDescending { it.updatedAtMs ?: 0L }
|
||||||
val recent = mutableListOf<ChatSessionEntry>()
|
val recent = mutableListOf<ChatSessionEntry>()
|
||||||
val seen = mutableSetOf<String>()
|
val seen = mutableSetOf<String>()
|
||||||
for (entry in sorted) {
|
for (entry in sorted) {
|
||||||
if (aliasKey != null && entry.key == aliasKey) continue
|
|
||||||
if (!seen.add(entry.key)) continue
|
if (!seen.add(entry.key)) continue
|
||||||
if ((entry.updatedAtMs ?: 0L) < cutoff) continue
|
if ((entry.updatedAtMs ?: 0L) < cutoff) continue
|
||||||
recent.add(entry)
|
recent.add(entry)
|
||||||
@@ -26,13 +23,13 @@ fun resolveSessionChoices(
|
|||||||
|
|
||||||
val result = mutableListOf<ChatSessionEntry>()
|
val result = mutableListOf<ChatSessionEntry>()
|
||||||
val included = mutableSetOf<String>()
|
val included = mutableSetOf<String>()
|
||||||
val mainEntry = sorted.firstOrNull { it.key == mainKey }
|
val mainEntry = sorted.firstOrNull { it.key == MAIN_SESSION_KEY }
|
||||||
if (mainEntry != null) {
|
if (mainEntry != null) {
|
||||||
result.add(mainEntry)
|
result.add(mainEntry)
|
||||||
included.add(mainKey)
|
included.add(MAIN_SESSION_KEY)
|
||||||
} else if (current == mainKey) {
|
} else if (current == MAIN_SESSION_KEY) {
|
||||||
result.add(ChatSessionEntry(key = mainKey, updatedAtMs = null))
|
result.add(ChatSessionEntry(key = MAIN_SESSION_KEY, updatedAtMs = null))
|
||||||
included.add(mainKey)
|
included.add(MAIN_SESSION_KEY)
|
||||||
}
|
}
|
||||||
|
|
||||||
for (entry in recent) {
|
for (entry in recent) {
|
||||||
|
|||||||
@@ -21,8 +21,6 @@ import android.speech.tts.UtteranceProgressListener
|
|||||||
import android.util.Log
|
import android.util.Log
|
||||||
import androidx.core.content.ContextCompat
|
import androidx.core.content.ContextCompat
|
||||||
import com.clawdbot.android.bridge.BridgeSession
|
import com.clawdbot.android.bridge.BridgeSession
|
||||||
import com.clawdbot.android.isCanonicalMainSessionKey
|
|
||||||
import com.clawdbot.android.normalizeMainKey
|
|
||||||
import java.net.HttpURLConnection
|
import java.net.HttpURLConnection
|
||||||
import java.net.URL
|
import java.net.URL
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
@@ -117,13 +115,6 @@ class TalkModeManager(
|
|||||||
chatSubscribedSessionKey = null
|
chatSubscribedSessionKey = null
|
||||||
}
|
}
|
||||||
|
|
||||||
fun setMainSessionKey(sessionKey: String?) {
|
|
||||||
val trimmed = sessionKey?.trim().orEmpty()
|
|
||||||
if (trimmed.isEmpty()) return
|
|
||||||
if (isCanonicalMainSessionKey(mainSessionKey)) return
|
|
||||||
mainSessionKey = trimmed
|
|
||||||
}
|
|
||||||
|
|
||||||
fun setEnabled(enabled: Boolean) {
|
fun setEnabled(enabled: Boolean) {
|
||||||
if (_isEnabled.value == enabled) return
|
if (_isEnabled.value == enabled) return
|
||||||
_isEnabled.value = enabled
|
_isEnabled.value = enabled
|
||||||
@@ -723,7 +714,6 @@ class TalkModeManager(
|
|||||||
systemTtsPendingId = null
|
systemTtsPendingId = null
|
||||||
}
|
}
|
||||||
|
|
||||||
@Suppress("OVERRIDE_DEPRECATION")
|
|
||||||
@Deprecated("Deprecated in Java")
|
@Deprecated("Deprecated in Java")
|
||||||
override fun onError(utteranceId: String?) {
|
override fun onError(utteranceId: String?) {
|
||||||
if (utteranceId == null) return
|
if (utteranceId == null) return
|
||||||
@@ -823,7 +813,7 @@ class TalkModeManager(
|
|||||||
val config = root?.get("config").asObjectOrNull()
|
val config = root?.get("config").asObjectOrNull()
|
||||||
val talk = config?.get("talk").asObjectOrNull()
|
val talk = config?.get("talk").asObjectOrNull()
|
||||||
val sessionCfg = config?.get("session").asObjectOrNull()
|
val sessionCfg = config?.get("session").asObjectOrNull()
|
||||||
val mainKey = normalizeMainKey(sessionCfg?.get("mainKey").asStringOrNull())
|
val mainKey = sessionCfg?.get("mainKey").asStringOrNull()?.trim()?.takeIf { it.isNotEmpty() } ?: "main"
|
||||||
val voice = talk?.get("voiceId")?.asStringOrNull()?.trim()?.takeIf { it.isNotEmpty() }
|
val voice = talk?.get("voiceId")?.asStringOrNull()?.trim()?.takeIf { it.isNotEmpty() }
|
||||||
val aliases =
|
val aliases =
|
||||||
talk?.get("voiceAliases").asObjectOrNull()?.entries?.mapNotNull { (key, value) ->
|
talk?.get("voiceAliases").asObjectOrNull()?.entries?.mapNotNull { (key, value) ->
|
||||||
@@ -835,9 +825,7 @@ class TalkModeManager(
|
|||||||
val key = talk?.get("apiKey")?.asStringOrNull()?.trim()?.takeIf { it.isNotEmpty() }
|
val key = talk?.get("apiKey")?.asStringOrNull()?.trim()?.takeIf { it.isNotEmpty() }
|
||||||
val interrupt = talk?.get("interruptOnSpeech")?.asBooleanOrNull()
|
val interrupt = talk?.get("interruptOnSpeech")?.asBooleanOrNull()
|
||||||
|
|
||||||
if (!isCanonicalMainSessionKey(mainSessionKey)) {
|
mainSessionKey = mainKey
|
||||||
mainSessionKey = mainKey
|
|
||||||
}
|
|
||||||
defaultVoiceId = voice ?: envVoice?.takeIf { it.isNotEmpty() } ?: sagVoice?.takeIf { it.isNotEmpty() }
|
defaultVoiceId = voice ?: envVoice?.takeIf { it.isNotEmpty() } ?: sagVoice?.takeIf { it.isNotEmpty() }
|
||||||
voiceAliases = aliases
|
voiceAliases = aliases
|
||||||
if (!voiceOverrideActive) currentVoiceId = defaultVoiceId
|
if (!voiceOverrideActive) currentVoiceId = defaultVoiceId
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 9.4 KiB |
|
Before Width: | Height: | Size: 44 KiB After Width: | Height: | Size: 39 KiB |
|
Before Width: | Height: | Size: 5.5 KiB After Width: | Height: | Size: 4.6 KiB |
|
Before Width: | Height: | Size: 21 KiB After Width: | Height: | Size: 19 KiB |
|
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 73 KiB After Width: | Height: | Size: 67 KiB |
|
Before Width: | Height: | Size: 36 KiB After Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 155 KiB After Width: | Height: | Size: 148 KiB |
|
Before Width: | Height: | Size: 60 KiB After Width: | Height: | Size: 54 KiB |
|
Before Width: | Height: | Size: 270 KiB After Width: | Height: | Size: 267 KiB |
@@ -30,7 +30,7 @@ class BridgeSessionTest {
|
|||||||
val session =
|
val session =
|
||||||
BridgeSession(
|
BridgeSession(
|
||||||
scope = scope,
|
scope = scope,
|
||||||
onConnected = { _, _, _ -> connected.complete(Unit) },
|
onConnected = { _, _ -> connected.complete(Unit) },
|
||||||
onDisconnected = { /* ignore */ },
|
onDisconnected = { /* ignore */ },
|
||||||
onEvent = { _, _ -> /* ignore */ },
|
onEvent = { _, _ -> /* ignore */ },
|
||||||
onInvoke = { BridgeSession.InvokeResult.ok(null) },
|
onInvoke = { BridgeSession.InvokeResult.ok(null) },
|
||||||
@@ -97,7 +97,7 @@ class BridgeSessionTest {
|
|||||||
val session =
|
val session =
|
||||||
BridgeSession(
|
BridgeSession(
|
||||||
scope = scope,
|
scope = scope,
|
||||||
onConnected = { _, _, _ -> connected.complete(Unit) },
|
onConnected = { _, _ -> connected.complete(Unit) },
|
||||||
onDisconnected = { /* ignore */ },
|
onDisconnected = { /* ignore */ },
|
||||||
onEvent = { _, _ -> /* ignore */ },
|
onEvent = { _, _ -> /* ignore */ },
|
||||||
onInvoke = { BridgeSession.InvokeResult.ok(null) },
|
onInvoke = { BridgeSession.InvokeResult.ok(null) },
|
||||||
@@ -167,7 +167,7 @@ class BridgeSessionTest {
|
|||||||
val session =
|
val session =
|
||||||
BridgeSession(
|
BridgeSession(
|
||||||
scope = scope,
|
scope = scope,
|
||||||
onConnected = { _, _, _ -> connected.complete(Unit) },
|
onConnected = { _, _ -> connected.complete(Unit) },
|
||||||
onDisconnected = { /* ignore */ },
|
onDisconnected = { /* ignore */ },
|
||||||
onEvent = { _, _ -> /* ignore */ },
|
onEvent = { _, _ -> /* ignore */ },
|
||||||
onInvoke = { throw IllegalStateException("FOO_BAR: boom") },
|
onInvoke = { throw IllegalStateException("FOO_BAR: boom") },
|
||||||
@@ -239,7 +239,7 @@ class BridgeSessionTest {
|
|||||||
val session =
|
val session =
|
||||||
BridgeSession(
|
BridgeSession(
|
||||||
scope = scope,
|
scope = scope,
|
||||||
onConnected = { _, _, _ -> connected.countDown() },
|
onConnected = { _, _ -> connected.countDown() },
|
||||||
onDisconnected = { /* ignore */ },
|
onDisconnected = { /* ignore */ },
|
||||||
onEvent = { _, _ -> /* ignore */ },
|
onEvent = { _, _ -> /* ignore */ },
|
||||||
onInvoke = { BridgeSession.InvokeResult.ok(null) },
|
onInvoke = { BridgeSession.InvokeResult.ok(null) },
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ class SessionFiltersTest {
|
|||||||
ChatSessionEntry(key = "recent-2", updatedAtMs = recent2),
|
ChatSessionEntry(key = "recent-2", updatedAtMs = recent2),
|
||||||
)
|
)
|
||||||
|
|
||||||
val result = resolveSessionChoices("main", sessions, mainSessionKey = "main", nowMs = now).map { it.key }
|
val result = resolveSessionChoices("main", sessions, nowMs = now).map { it.key }
|
||||||
assertEquals(listOf("main", "recent-1", "recent-2"), result)
|
assertEquals(listOf("main", "recent-1", "recent-2"), result)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -29,7 +29,7 @@ class SessionFiltersTest {
|
|||||||
val recent = now - 10 * 60 * 1000L
|
val recent = now - 10 * 60 * 1000L
|
||||||
val sessions = listOf(ChatSessionEntry(key = "main", updatedAtMs = recent))
|
val sessions = listOf(ChatSessionEntry(key = "main", updatedAtMs = recent))
|
||||||
|
|
||||||
val result = resolveSessionChoices("custom", sessions, mainSessionKey = "main", nowMs = now).map { it.key }
|
val result = resolveSessionChoices("custom", sessions, nowMs = now).map { it.key }
|
||||||
assertEquals(listOf("main", "custom"), result)
|
assertEquals(listOf("main", "custom"), result)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Before Width: | Height: | Size: 1.3 MiB After Width: | Height: | Size: 1.4 MiB |
|
Before Width: | Height: | Size: 1.9 KiB After Width: | Height: | Size: 1.0 KiB |
|
Before Width: | Height: | Size: 4.2 KiB After Width: | Height: | Size: 3.3 KiB |
|
Before Width: | Height: | Size: 7.9 KiB After Width: | Height: | Size: 6.5 KiB |
|
Before Width: | Height: | Size: 2.8 KiB After Width: | Height: | Size: 1.9 KiB |
|
Before Width: | Height: | Size: 7.4 KiB After Width: | Height: | Size: 6.2 KiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 4.2 KiB After Width: | Height: | Size: 3.3 KiB |
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 26 KiB After Width: | Height: | Size: 21 KiB |
|
Before Width: | Height: | Size: 26 KiB After Width: | Height: | Size: 21 KiB |
|
Before Width: | Height: | Size: 53 KiB After Width: | Height: | Size: 43 KiB |
|
Before Width: | Height: | Size: 39 KiB After Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 46 KiB After Width: | Height: | Size: 38 KiB |
@@ -10,36 +10,10 @@ actor BridgeClient {
|
|||||||
func pairAndHello(
|
func pairAndHello(
|
||||||
endpoint: NWEndpoint,
|
endpoint: NWEndpoint,
|
||||||
hello: BridgeHello,
|
hello: BridgeHello,
|
||||||
tls: BridgeTLSParams? = nil,
|
|
||||||
onStatus: (@Sendable (String) -> Void)? = nil) async throws -> String
|
|
||||||
{
|
|
||||||
do {
|
|
||||||
return try await self.pairAndHelloOnce(
|
|
||||||
endpoint: endpoint,
|
|
||||||
hello: hello,
|
|
||||||
tls: tls,
|
|
||||||
onStatus: onStatus)
|
|
||||||
} catch {
|
|
||||||
if let tls, !tls.required {
|
|
||||||
return try await self.pairAndHelloOnce(
|
|
||||||
endpoint: endpoint,
|
|
||||||
hello: hello,
|
|
||||||
tls: nil,
|
|
||||||
onStatus: onStatus)
|
|
||||||
}
|
|
||||||
throw error
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func pairAndHelloOnce(
|
|
||||||
endpoint: NWEndpoint,
|
|
||||||
hello: BridgeHello,
|
|
||||||
tls: BridgeTLSParams?,
|
|
||||||
onStatus: (@Sendable (String) -> Void)? = nil) async throws -> String
|
onStatus: (@Sendable (String) -> Void)? = nil) async throws -> String
|
||||||
{
|
{
|
||||||
self.lineBuffer = Data()
|
self.lineBuffer = Data()
|
||||||
let params = self.makeParameters(tls: tls)
|
let connection = NWConnection(to: endpoint, using: .tcp)
|
||||||
let connection = NWConnection(to: endpoint, using: params)
|
|
||||||
let queue = DispatchQueue(label: "com.clawdbot.ios.bridge-client")
|
let queue = DispatchQueue(label: "com.clawdbot.ios.bridge-client")
|
||||||
defer { connection.cancel() }
|
defer { connection.cancel() }
|
||||||
try await self.withTimeout(seconds: 8, purpose: "connect") {
|
try await self.withTimeout(seconds: 8, purpose: "connect") {
|
||||||
@@ -168,18 +142,6 @@ actor BridgeClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func makeParameters(tls: BridgeTLSParams?) -> NWParameters {
|
|
||||||
if let tlsOptions = makeBridgeTLSOptions(tls) {
|
|
||||||
let tcpOptions = NWProtocolTCP.Options()
|
|
||||||
let params = NWParameters(tls: tlsOptions, tcp: tcpOptions)
|
|
||||||
params.includePeerToPeer = true
|
|
||||||
return params
|
|
||||||
}
|
|
||||||
let params = NWParameters.tcp
|
|
||||||
params.includePeerToPeer = true
|
|
||||||
return params
|
|
||||||
}
|
|
||||||
|
|
||||||
private struct TimeoutError: LocalizedError, Sendable {
|
private struct TimeoutError: LocalizedError, Sendable {
|
||||||
var purpose: String
|
var purpose: String
|
||||||
var seconds: Int
|
var seconds: Int
|
||||||
@@ -199,10 +161,18 @@ actor BridgeClient {
|
|||||||
purpose: String,
|
purpose: String,
|
||||||
_ op: @escaping @Sendable () async throws -> T) async throws -> T
|
_ op: @escaping @Sendable () async throws -> T) async throws -> T
|
||||||
{
|
{
|
||||||
try await AsyncTimeout.withTimeout(
|
try await withThrowingTaskGroup(of: T.self) { group in
|
||||||
seconds: Double(seconds),
|
group.addTask {
|
||||||
onTimeout: { TimeoutError(purpose: purpose, seconds: seconds) },
|
try await op()
|
||||||
operation: op)
|
}
|
||||||
|
group.addTask {
|
||||||
|
try await Task.sleep(nanoseconds: UInt64(seconds) * 1_000_000_000)
|
||||||
|
throw TimeoutError(purpose: purpose, seconds: seconds)
|
||||||
|
}
|
||||||
|
let result = try await group.next()!
|
||||||
|
group.cancelAll()
|
||||||
|
return result
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func startAndWaitForReady(_ connection: NWConnection, queue: DispatchQueue) async throws {
|
private func startAndWaitForReady(_ connection: NWConnection, queue: DispatchQueue) async throws {
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ protocol BridgePairingClient: Sendable {
|
|||||||
func pairAndHello(
|
func pairAndHello(
|
||||||
endpoint: NWEndpoint,
|
endpoint: NWEndpoint,
|
||||||
hello: BridgeHello,
|
hello: BridgeHello,
|
||||||
tls: BridgeTLSParams?,
|
|
||||||
onStatus: (@Sendable (String) -> Void)?) async throws -> String
|
onStatus: (@Sendable (String) -> Void)?) async throws -> String
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -116,14 +115,7 @@ final class BridgeConnectionController {
|
|||||||
|
|
||||||
self.didAutoConnect = true
|
self.didAutoConnect = true
|
||||||
let endpoint = NWEndpoint.hostPort(host: NWEndpoint.Host(manualHost), port: port)
|
let endpoint = NWEndpoint.hostPort(host: NWEndpoint.Host(manualHost), port: port)
|
||||||
let stableID = BridgeEndpointID.stableID(endpoint)
|
self.startAutoConnect(endpoint: endpoint, token: token, instanceId: instanceId)
|
||||||
let tlsParams = self.resolveManualTLSParams(stableID: stableID)
|
|
||||||
self.startAutoConnect(
|
|
||||||
endpoint: endpoint,
|
|
||||||
bridgeStableID: stableID,
|
|
||||||
tls: tlsParams,
|
|
||||||
token: token,
|
|
||||||
instanceId: instanceId)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -139,14 +131,8 @@ final class BridgeConnectionController {
|
|||||||
|
|
||||||
guard let target = self.bridges.first(where: { $0.stableID == targetStableID }) else { return }
|
guard let target = self.bridges.first(where: { $0.stableID == targetStableID }) else { return }
|
||||||
|
|
||||||
let tlsParams = self.resolveDiscoveredTLSParams(bridge: target)
|
|
||||||
self.didAutoConnect = true
|
self.didAutoConnect = true
|
||||||
self.startAutoConnect(
|
self.startAutoConnect(endpoint: target.endpoint, token: token, instanceId: instanceId)
|
||||||
endpoint: target.endpoint,
|
|
||||||
bridgeStableID: target.stableID,
|
|
||||||
tls: tlsParams,
|
|
||||||
token: token,
|
|
||||||
instanceId: instanceId)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func updateLastDiscoveredBridge(from bridges: [BridgeDiscoveryModel.DiscoveredBridge]) {
|
private func updateLastDiscoveredBridge(from bridges: [BridgeDiscoveryModel.DiscoveredBridge]) {
|
||||||
@@ -185,13 +171,7 @@ final class BridgeConnectionController {
|
|||||||
"bridge-token.\(instanceId)"
|
"bridge-token.\(instanceId)"
|
||||||
}
|
}
|
||||||
|
|
||||||
private func startAutoConnect(
|
private func startAutoConnect(endpoint: NWEndpoint, token: String, instanceId: String) {
|
||||||
endpoint: NWEndpoint,
|
|
||||||
bridgeStableID: String,
|
|
||||||
tls: BridgeTLSParams?,
|
|
||||||
token: String,
|
|
||||||
instanceId: String)
|
|
||||||
{
|
|
||||||
guard let appModel else { return }
|
guard let appModel else { return }
|
||||||
Task { [weak self] in
|
Task { [weak self] in
|
||||||
guard let self else { return }
|
guard let self else { return }
|
||||||
@@ -200,7 +180,6 @@ final class BridgeConnectionController {
|
|||||||
let refreshed = try await self.bridgeClientFactory().pairAndHello(
|
let refreshed = try await self.bridgeClientFactory().pairAndHello(
|
||||||
endpoint: endpoint,
|
endpoint: endpoint,
|
||||||
hello: hello,
|
hello: hello,
|
||||||
tls: tls,
|
|
||||||
onStatus: { status in
|
onStatus: { status in
|
||||||
Task { @MainActor in
|
Task { @MainActor in
|
||||||
appModel.bridgeStatusText = status
|
appModel.bridgeStatusText = status
|
||||||
@@ -213,11 +192,7 @@ final class BridgeConnectionController {
|
|||||||
service: "com.clawdbot.bridge",
|
service: "com.clawdbot.bridge",
|
||||||
account: self.keychainAccount(instanceId: instanceId))
|
account: self.keychainAccount(instanceId: instanceId))
|
||||||
}
|
}
|
||||||
appModel.connectToBridge(
|
appModel.connectToBridge(endpoint: endpoint, hello: self.makeHello(token: resolvedToken))
|
||||||
endpoint: endpoint,
|
|
||||||
bridgeStableID: bridgeStableID,
|
|
||||||
tls: tls,
|
|
||||||
hello: self.makeHello(token: resolvedToken))
|
|
||||||
} catch {
|
} catch {
|
||||||
await MainActor.run {
|
await MainActor.run {
|
||||||
appModel.bridgeStatusText = "Bridge error: \(error.localizedDescription)"
|
appModel.bridgeStatusText = "Bridge error: \(error.localizedDescription)"
|
||||||
@@ -226,47 +201,6 @@ final class BridgeConnectionController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func resolveDiscoveredTLSParams(
|
|
||||||
bridge: BridgeDiscoveryModel.DiscoveredBridge) -> BridgeTLSParams?
|
|
||||||
{
|
|
||||||
let stableID = bridge.stableID
|
|
||||||
let stored = BridgeTLSStore.loadFingerprint(stableID: stableID)
|
|
||||||
|
|
||||||
if bridge.tlsEnabled || bridge.tlsFingerprintSha256 != nil {
|
|
||||||
return BridgeTLSParams(
|
|
||||||
required: true,
|
|
||||||
expectedFingerprint: bridge.tlsFingerprintSha256 ?? stored,
|
|
||||||
allowTOFU: stored == nil,
|
|
||||||
storeKey: stableID)
|
|
||||||
}
|
|
||||||
|
|
||||||
if let stored {
|
|
||||||
return BridgeTLSParams(
|
|
||||||
required: true,
|
|
||||||
expectedFingerprint: stored,
|
|
||||||
allowTOFU: false,
|
|
||||||
storeKey: stableID)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
private func resolveManualTLSParams(stableID: String) -> BridgeTLSParams? {
|
|
||||||
if let stored = BridgeTLSStore.loadFingerprint(stableID: stableID) {
|
|
||||||
return BridgeTLSParams(
|
|
||||||
required: true,
|
|
||||||
expectedFingerprint: stored,
|
|
||||||
allowTOFU: false,
|
|
||||||
storeKey: stableID)
|
|
||||||
}
|
|
||||||
|
|
||||||
return BridgeTLSParams(
|
|
||||||
required: false,
|
|
||||||
expectedFingerprint: nil,
|
|
||||||
allowTOFU: true,
|
|
||||||
storeKey: stableID)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func resolvedDisplayName(defaults: UserDefaults) -> String {
|
private func resolvedDisplayName(defaults: UserDefaults) -> String {
|
||||||
let key = "node.displayName"
|
let key = "node.displayName"
|
||||||
let existing = defaults.string(forKey: key)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
let existing = defaults.string(forKey: key)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||||
|
|||||||
@@ -23,8 +23,6 @@ final class BridgeDiscoveryModel {
|
|||||||
var gatewayPort: Int?
|
var gatewayPort: Int?
|
||||||
var bridgePort: Int?
|
var bridgePort: Int?
|
||||||
var canvasPort: Int?
|
var canvasPort: Int?
|
||||||
var tlsEnabled: Bool
|
|
||||||
var tlsFingerprintSha256: String?
|
|
||||||
var cliPath: String?
|
var cliPath: String?
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -92,8 +90,6 @@ final class BridgeDiscoveryModel {
|
|||||||
gatewayPort: Self.txtIntValue(txt, key: "gatewayPort"),
|
gatewayPort: Self.txtIntValue(txt, key: "gatewayPort"),
|
||||||
bridgePort: Self.txtIntValue(txt, key: "bridgePort"),
|
bridgePort: Self.txtIntValue(txt, key: "bridgePort"),
|
||||||
canvasPort: Self.txtIntValue(txt, key: "canvasPort"),
|
canvasPort: Self.txtIntValue(txt, key: "canvasPort"),
|
||||||
tlsEnabled: Self.txtBoolValue(txt, key: "bridgeTls"),
|
|
||||||
tlsFingerprintSha256: Self.txtValue(txt, key: "bridgeTlsSha256"),
|
|
||||||
cliPath: Self.txtValue(txt, key: "cliPath"))
|
cliPath: Self.txtValue(txt, key: "cliPath"))
|
||||||
default:
|
default:
|
||||||
return nil
|
return nil
|
||||||
@@ -218,9 +214,4 @@ final class BridgeDiscoveryModel {
|
|||||||
guard let raw = self.txtValue(dict, key: key) else { return nil }
|
guard let raw = self.txtValue(dict, key: key) else { return nil }
|
||||||
return Int(raw)
|
return Int(raw)
|
||||||
}
|
}
|
||||||
|
|
||||||
private static func txtBoolValue(_ dict: [String: String], key: String) -> Bool {
|
|
||||||
guard let raw = self.txtValue(dict, key: key)?.lowercased() else { return false }
|
|
||||||
return raw == "1" || raw == "true" || raw == "yes"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -26,7 +26,6 @@ actor BridgeSession {
|
|||||||
|
|
||||||
private(set) var state: State = .idle
|
private(set) var state: State = .idle
|
||||||
private var canvasHostUrl: String?
|
private var canvasHostUrl: String?
|
||||||
private var mainSessionKey: String?
|
|
||||||
|
|
||||||
func currentCanvasHostUrl() -> String? {
|
func currentCanvasHostUrl() -> String? {
|
||||||
self.canvasHostUrl
|
self.canvasHostUrl
|
||||||
@@ -69,42 +68,15 @@ actor BridgeSession {
|
|||||||
func connect(
|
func connect(
|
||||||
endpoint: NWEndpoint,
|
endpoint: NWEndpoint,
|
||||||
hello: BridgeHello,
|
hello: BridgeHello,
|
||||||
tls: BridgeTLSParams? = nil,
|
onConnected: (@Sendable (String) async -> Void)? = nil,
|
||||||
onConnected: (@Sendable (String, String?) async -> Void)? = nil,
|
|
||||||
onInvoke: @escaping @Sendable (BridgeInvokeRequest) async -> BridgeInvokeResponse)
|
onInvoke: @escaping @Sendable (BridgeInvokeRequest) async -> BridgeInvokeResponse)
|
||||||
async throws
|
async throws
|
||||||
{
|
{
|
||||||
await self.disconnect()
|
await self.disconnect()
|
||||||
self.state = .connecting
|
self.state = .connecting
|
||||||
do {
|
|
||||||
try await self.connectOnce(
|
|
||||||
endpoint: endpoint,
|
|
||||||
hello: hello,
|
|
||||||
tls: tls,
|
|
||||||
onConnected: onConnected,
|
|
||||||
onInvoke: onInvoke)
|
|
||||||
} catch {
|
|
||||||
if let tls, !tls.required {
|
|
||||||
try await self.connectOnce(
|
|
||||||
endpoint: endpoint,
|
|
||||||
hello: hello,
|
|
||||||
tls: nil,
|
|
||||||
onConnected: onConnected,
|
|
||||||
onInvoke: onInvoke)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
throw error
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func connectOnce(
|
let params = NWParameters.tcp
|
||||||
endpoint: NWEndpoint,
|
params.includePeerToPeer = true
|
||||||
hello: BridgeHello,
|
|
||||||
tls: BridgeTLSParams?,
|
|
||||||
onConnected: (@Sendable (String, String?) async -> Void)?,
|
|
||||||
onInvoke: @escaping @Sendable (BridgeInvokeRequest) async -> BridgeInvokeResponse) async throws
|
|
||||||
{
|
|
||||||
let params = self.makeParameters(tls: tls)
|
|
||||||
let connection = NWConnection(to: endpoint, using: params)
|
let connection = NWConnection(to: endpoint, using: params)
|
||||||
let queue = DispatchQueue(label: "com.clawdbot.ios.bridge-session")
|
let queue = DispatchQueue(label: "com.clawdbot.ios.bridge-session")
|
||||||
self.connection = connection
|
self.connection = connection
|
||||||
@@ -135,9 +107,7 @@ actor BridgeSession {
|
|||||||
let ok = try self.decoder.decode(BridgeHelloOk.self, from: data)
|
let ok = try self.decoder.decode(BridgeHelloOk.self, from: data)
|
||||||
self.state = .connected(serverName: ok.serverName)
|
self.state = .connected(serverName: ok.serverName)
|
||||||
self.canvasHostUrl = ok.canvasHostUrl?.trimmingCharacters(in: .whitespacesAndNewlines)
|
self.canvasHostUrl = ok.canvasHostUrl?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
let mainKey = ok.mainSessionKey?.trimmingCharacters(in: .whitespacesAndNewlines)
|
await onConnected?(ok.serverName)
|
||||||
self.mainSessionKey = (mainKey?.isEmpty == false) ? mainKey : nil
|
|
||||||
await onConnected?(ok.serverName, self.mainSessionKey)
|
|
||||||
} else if base.type == "error" {
|
} else if base.type == "error" {
|
||||||
let err = try self.decoder.decode(BridgeErrorFrame.self, from: data)
|
let err = try self.decoder.decode(BridgeErrorFrame.self, from: data)
|
||||||
self.state = .failed(message: "\(err.code): \(err.message)")
|
self.state = .failed(message: "\(err.code): \(err.message)")
|
||||||
@@ -247,7 +217,6 @@ actor BridgeSession {
|
|||||||
self.queue = nil
|
self.queue = nil
|
||||||
self.buffer = Data()
|
self.buffer = Data()
|
||||||
self.canvasHostUrl = nil
|
self.canvasHostUrl = nil
|
||||||
self.mainSessionKey = nil
|
|
||||||
|
|
||||||
let pending = self.pendingRPC.values
|
let pending = self.pendingRPC.values
|
||||||
self.pendingRPC.removeAll()
|
self.pendingRPC.removeAll()
|
||||||
@@ -265,10 +234,6 @@ actor BridgeSession {
|
|||||||
self.state = .idle
|
self.state = .idle
|
||||||
}
|
}
|
||||||
|
|
||||||
func currentMainSessionKey() -> String? {
|
|
||||||
self.mainSessionKey
|
|
||||||
}
|
|
||||||
|
|
||||||
private func beginRPC(
|
private func beginRPC(
|
||||||
id: String,
|
id: String,
|
||||||
request: BridgeRPCRequest,
|
request: BridgeRPCRequest,
|
||||||
@@ -282,18 +247,6 @@ actor BridgeSession {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func makeParameters(tls: BridgeTLSParams?) -> NWParameters {
|
|
||||||
if let tlsOptions = makeBridgeTLSOptions(tls) {
|
|
||||||
let tcpOptions = NWProtocolTCP.Options()
|
|
||||||
let params = NWParameters(tls: tlsOptions, tcp: tcpOptions)
|
|
||||||
params.includePeerToPeer = true
|
|
||||||
return params
|
|
||||||
}
|
|
||||||
let params = NWParameters.tcp
|
|
||||||
params.includePeerToPeer = true
|
|
||||||
return params
|
|
||||||
}
|
|
||||||
|
|
||||||
private func timeoutRPC(id: String) async {
|
private func timeoutRPC(id: String) async {
|
||||||
guard let cont = self.pendingRPC.removeValue(forKey: id) else { return }
|
guard let cont = self.pendingRPC.removeValue(forKey: id) else { return }
|
||||||
cont.resume(throwing: NSError(domain: "Bridge", code: 15, userInfo: [
|
cont.resume(throwing: NSError(domain: "Bridge", code: 15, userInfo: [
|
||||||
@@ -368,10 +321,20 @@ actor BridgeSession {
|
|||||||
seconds: Double,
|
seconds: Double,
|
||||||
operation: @escaping @Sendable () async throws -> T) async throws -> T
|
operation: @escaping @Sendable () async throws -> T) async throws -> T
|
||||||
{
|
{
|
||||||
try await AsyncTimeout.withTimeout(
|
try await withThrowingTaskGroup(of: T.self) { group in
|
||||||
seconds: seconds,
|
group.addTask { try await operation() }
|
||||||
onTimeout: { TimeoutError(message: "UNAVAILABLE: connection timeout") },
|
group.addTask {
|
||||||
operation: operation)
|
try await Task.sleep(nanoseconds: UInt64(seconds * 1_000_000_000))
|
||||||
|
throw TimeoutError(message: "UNAVAILABLE: connection timeout")
|
||||||
|
}
|
||||||
|
|
||||||
|
guard let first = try await group.next() else {
|
||||||
|
throw TimeoutError(message: "UNAVAILABLE: connection timeout")
|
||||||
|
}
|
||||||
|
|
||||||
|
group.cancelAll()
|
||||||
|
return first
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static func makeStateStream(for connection: NWConnection) -> AsyncStream<NWConnection.State> {
|
private static func makeStateStream(for connection: NWConnection) -> AsyncStream<NWConnection.State> {
|
||||||
|
|||||||
@@ -1,66 +0,0 @@
|
|||||||
import CryptoKit
|
|
||||||
import Foundation
|
|
||||||
import Network
|
|
||||||
import Security
|
|
||||||
|
|
||||||
struct BridgeTLSParams: Sendable {
|
|
||||||
let required: Bool
|
|
||||||
let expectedFingerprint: String?
|
|
||||||
let allowTOFU: Bool
|
|
||||||
let storeKey: String?
|
|
||||||
}
|
|
||||||
|
|
||||||
enum BridgeTLSStore {
|
|
||||||
private static let service = "com.clawdbot.bridge.tls"
|
|
||||||
|
|
||||||
static func loadFingerprint(stableID: String) -> String? {
|
|
||||||
KeychainStore.loadString(service: service, account: stableID)?.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
||||||
}
|
|
||||||
|
|
||||||
static func saveFingerprint(_ value: String, stableID: String) {
|
|
||||||
_ = KeychainStore.saveString(value, service: service, account: stableID)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func makeBridgeTLSOptions(_ params: BridgeTLSParams?) -> NWProtocolTLS.Options? {
|
|
||||||
guard let params else { return nil }
|
|
||||||
let options = NWProtocolTLS.Options()
|
|
||||||
let expected = params.expectedFingerprint.map(normalizeBridgeFingerprint)
|
|
||||||
let allowTOFU = params.allowTOFU
|
|
||||||
let storeKey = params.storeKey
|
|
||||||
|
|
||||||
sec_protocol_options_set_verify_block(
|
|
||||||
options.securityProtocolOptions,
|
|
||||||
{ _, trust, complete in
|
|
||||||
let trustRef = sec_trust_copy_ref(trust).takeRetainedValue()
|
|
||||||
if let chain = SecTrustCopyCertificateChain(trustRef) as? [SecCertificate],
|
|
||||||
let cert = chain.first
|
|
||||||
{
|
|
||||||
let data = SecCertificateCopyData(cert) as Data
|
|
||||||
let fingerprint = sha256Hex(data)
|
|
||||||
if let expected {
|
|
||||||
complete(fingerprint == expected)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if allowTOFU {
|
|
||||||
if let storeKey { BridgeTLSStore.saveFingerprint(fingerprint, stableID: storeKey) }
|
|
||||||
complete(true)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
let ok = SecTrustEvaluateWithError(trustRef, nil)
|
|
||||||
complete(ok)
|
|
||||||
},
|
|
||||||
DispatchQueue(label: "com.clawdbot.bridge.tls.verify"))
|
|
||||||
|
|
||||||
return options
|
|
||||||
}
|
|
||||||
|
|
||||||
private func sha256Hex(_ data: Data) -> String {
|
|
||||||
let digest = SHA256.hash(data: data)
|
|
||||||
return digest.map { String(format: "%02x", $0) }.joined()
|
|
||||||
}
|
|
||||||
|
|
||||||
private func normalizeBridgeFingerprint(_ raw: String) -> String {
|
|
||||||
raw.lowercased().filter { $0.isHexDigit }
|
|
||||||
}
|
|
||||||
@@ -190,7 +190,14 @@ actor CameraController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func listDevices() -> [CameraDeviceInfo] {
|
func listDevices() -> [CameraDeviceInfo] {
|
||||||
return Self.discoverVideoDevices().map { device in
|
let types: [AVCaptureDevice.DeviceType] = [
|
||||||
|
.builtInWideAngleCamera,
|
||||||
|
]
|
||||||
|
let session = AVCaptureDevice.DiscoverySession(
|
||||||
|
deviceTypes: types,
|
||||||
|
mediaType: .video,
|
||||||
|
position: .unspecified)
|
||||||
|
return session.devices.map { device in
|
||||||
CameraDeviceInfo(
|
CameraDeviceInfo(
|
||||||
id: device.uniqueID,
|
id: device.uniqueID,
|
||||||
name: device.localizedName,
|
name: device.localizedName,
|
||||||
@@ -225,7 +232,7 @@ actor CameraController {
|
|||||||
deviceId: String?) -> AVCaptureDevice?
|
deviceId: String?) -> AVCaptureDevice?
|
||||||
{
|
{
|
||||||
if let deviceId, !deviceId.isEmpty {
|
if let deviceId, !deviceId.isEmpty {
|
||||||
if let match = Self.discoverVideoDevices().first(where: { $0.uniqueID == deviceId }) {
|
if let match = AVCaptureDevice.devices(for: .video).first(where: { $0.uniqueID == deviceId }) {
|
||||||
return match
|
return match
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -245,24 +252,6 @@ actor CameraController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private nonisolated static func discoverVideoDevices() -> [AVCaptureDevice] {
|
|
||||||
let types: [AVCaptureDevice.DeviceType] = [
|
|
||||||
.builtInWideAngleCamera,
|
|
||||||
.builtInUltraWideCamera,
|
|
||||||
.builtInTelephotoCamera,
|
|
||||||
.builtInDualCamera,
|
|
||||||
.builtInDualWideCamera,
|
|
||||||
.builtInTripleCamera,
|
|
||||||
.builtInTrueDepthCamera,
|
|
||||||
.builtInLiDARDepthCamera,
|
|
||||||
]
|
|
||||||
let session = AVCaptureDevice.DiscoverySession(
|
|
||||||
deviceTypes: types,
|
|
||||||
mediaType: .video,
|
|
||||||
position: .unspecified)
|
|
||||||
return session.devices
|
|
||||||
}
|
|
||||||
|
|
||||||
nonisolated static func clampQuality(_ quality: Double?) -> Double {
|
nonisolated static func clampQuality(_ quality: Double?) -> Double {
|
||||||
let q = quality ?? 0.9
|
let q = quality ?? 0.9
|
||||||
return min(1.0, max(0.05, q))
|
return min(1.0, max(0.05, q))
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ struct ChatSheet: View {
|
|||||||
@State private var viewModel: ClawdbotChatViewModel
|
@State private var viewModel: ClawdbotChatViewModel
|
||||||
private let userAccent: Color?
|
private let userAccent: Color?
|
||||||
|
|
||||||
init(bridge: BridgeSession, sessionKey: String, userAccent: Color? = nil) {
|
init(bridge: BridgeSession, sessionKey: String = "main", userAccent: Color? = nil) {
|
||||||
let transport = IOSBridgeChatTransport(bridge: bridge)
|
let transport = IOSBridgeChatTransport(bridge: bridge)
|
||||||
self._viewModel = State(
|
self._viewModel = State(
|
||||||
initialValue: ClawdbotChatViewModel(
|
initialValue: ClawdbotChatViewModel(
|
||||||
|
|||||||
@@ -19,9 +19,9 @@
|
|||||||
<key>CFBundlePackageType</key>
|
<key>CFBundlePackageType</key>
|
||||||
<string>APPL</string>
|
<string>APPL</string>
|
||||||
<key>CFBundleShortVersionString</key>
|
<key>CFBundleShortVersionString</key>
|
||||||
<string>2026.1.11-4</string>
|
<string>1.0</string>
|
||||||
<key>CFBundleVersion</key>
|
<key>CFBundleVersion</key>
|
||||||
<string>202601113</string>
|
<string>1</string>
|
||||||
<key>NSAppTransportSecurity</key>
|
<key>NSAppTransportSecurity</key>
|
||||||
<dict>
|
<dict>
|
||||||
<key>NSAllowsArbitraryLoadsInWebContent</key>
|
<key>NSAllowsArbitraryLoadsInWebContent</key>
|
||||||
@@ -35,10 +35,10 @@
|
|||||||
<string>Clawdbot can capture photos or short video clips when requested via the bridge.</string>
|
<string>Clawdbot can capture photos or short video clips when requested via the bridge.</string>
|
||||||
<key>NSLocalNetworkUsageDescription</key>
|
<key>NSLocalNetworkUsageDescription</key>
|
||||||
<string>Clawdbot discovers and connects to your Clawdbot bridge on the local network.</string>
|
<string>Clawdbot discovers and connects to your Clawdbot bridge on the local network.</string>
|
||||||
<key>NSLocationAlwaysAndWhenInUseUsageDescription</key>
|
|
||||||
<string>Clawdbot can share your location in the background when you enable Always.</string>
|
|
||||||
<key>NSLocationWhenInUseUsageDescription</key>
|
<key>NSLocationWhenInUseUsageDescription</key>
|
||||||
<string>Clawdbot uses your location when you allow location sharing.</string>
|
<string>Clawdbot uses your location when you allow location sharing.</string>
|
||||||
|
<key>NSLocationAlwaysAndWhenInUseUsageDescription</key>
|
||||||
|
<string>Clawdbot can share your location in the background when you enable Always.</string>
|
||||||
<key>NSMicrophoneUsageDescription</key>
|
<key>NSMicrophoneUsageDescription</key>
|
||||||
<string>Clawdbot needs microphone access for voice wake.</string>
|
<string>Clawdbot needs microphone access for voice wake.</string>
|
||||||
<key>NSSpeechRecognitionUsageDescription</key>
|
<key>NSSpeechRecognitionUsageDescription</key>
|
||||||
|
|||||||
@@ -86,11 +86,24 @@ final class LocationService: NSObject, CLLocationManagerDelegate {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func withTimeout<T: Sendable>(
|
private func withTimeout<T>(
|
||||||
timeoutMs: Int,
|
timeoutMs: Int,
|
||||||
operation: @escaping @Sendable () async throws -> T) async throws -> T
|
operation: @escaping () async throws -> T) async throws -> T
|
||||||
{
|
{
|
||||||
try await AsyncTimeout.withTimeoutMs(timeoutMs: timeoutMs, onTimeout: { Error.timeout }, operation: operation)
|
if timeoutMs == 0 {
|
||||||
|
return try await operation()
|
||||||
|
}
|
||||||
|
|
||||||
|
return try await withThrowingTaskGroup(of: T.self) { group in
|
||||||
|
group.addTask { try await operation() }
|
||||||
|
group.addTask {
|
||||||
|
try await Task.sleep(nanoseconds: UInt64(timeoutMs) * 1_000_000)
|
||||||
|
throw Error.timeout
|
||||||
|
}
|
||||||
|
let result = try await group.next()!
|
||||||
|
group.cancelAll()
|
||||||
|
return result
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static func accuracyValue(_ accuracy: ClawdbotLocationAccuracy) -> CLLocationAccuracy {
|
private static func accuracyValue(_ accuracy: ClawdbotLocationAccuracy) -> CLLocationAccuracy {
|
||||||
@@ -104,35 +117,26 @@ final class LocationService: NSObject, CLLocationManagerDelegate {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
nonisolated func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) {
|
func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) {
|
||||||
let status = manager.authorizationStatus
|
if let cont = self.authContinuation {
|
||||||
Task { @MainActor in
|
self.authContinuation = nil
|
||||||
if let cont = self.authContinuation {
|
cont.resume(returning: manager.authorizationStatus)
|
||||||
self.authContinuation = nil
|
|
||||||
cont.resume(returning: status)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
nonisolated func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) {
|
func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) {
|
||||||
let locs = locations
|
guard let cont = self.locationContinuation else { return }
|
||||||
Task { @MainActor in
|
self.locationContinuation = nil
|
||||||
guard let cont = self.locationContinuation else { return }
|
if let latest = locations.last {
|
||||||
self.locationContinuation = nil
|
cont.resume(returning: latest)
|
||||||
if let latest = locs.last {
|
} else {
|
||||||
cont.resume(returning: latest)
|
cont.resume(throwing: Error.unavailable)
|
||||||
} else {
|
|
||||||
cont.resume(throwing: Error.unavailable)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
nonisolated func locationManager(_ manager: CLLocationManager, didFailWithError error: Swift.Error) {
|
func locationManager(_ manager: CLLocationManager, didFailWithError error: Swift.Error) {
|
||||||
let err = error
|
guard let cont = self.locationContinuation else { return }
|
||||||
Task { @MainActor in
|
self.locationContinuation = nil
|
||||||
guard let cont = self.locationContinuation else { return }
|
cont.resume(throwing: error)
|
||||||
self.locationContinuation = nil
|
|
||||||
cont.resume(throwing: err)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -109,7 +109,7 @@ final class NodeAppModel {
|
|||||||
let host = UserDefaults.standard.string(forKey: "node.displayName") ?? UIDevice.current.name
|
let host = UserDefaults.standard.string(forKey: "node.displayName") ?? UIDevice.current.name
|
||||||
let instanceId = (UserDefaults.standard.string(forKey: "node.instanceId") ?? "ios-node").lowercased()
|
let instanceId = (UserDefaults.standard.string(forKey: "node.instanceId") ?? "ios-node").lowercased()
|
||||||
let contextJSON = ClawdbotCanvasA2UIAction.compactJSON(userAction["context"])
|
let contextJSON = ClawdbotCanvasA2UIAction.compactJSON(userAction["context"])
|
||||||
let sessionKey = self.mainSessionKey
|
let sessionKey = "main"
|
||||||
|
|
||||||
let messageContext = ClawdbotCanvasA2UIAction.AgentMessageContext(
|
let messageContext = ClawdbotCanvasA2UIAction.AgentMessageContext(
|
||||||
actionName: name,
|
actionName: name,
|
||||||
@@ -204,15 +204,12 @@ final class NodeAppModel {
|
|||||||
|
|
||||||
func connectToBridge(
|
func connectToBridge(
|
||||||
endpoint: NWEndpoint,
|
endpoint: NWEndpoint,
|
||||||
bridgeStableID: String,
|
|
||||||
tls: BridgeTLSParams?,
|
|
||||||
hello: BridgeHello)
|
hello: BridgeHello)
|
||||||
{
|
{
|
||||||
self.bridgeTask?.cancel()
|
self.bridgeTask?.cancel()
|
||||||
self.bridgeServerName = nil
|
self.bridgeServerName = nil
|
||||||
self.bridgeRemoteAddress = nil
|
self.bridgeRemoteAddress = nil
|
||||||
let id = bridgeStableID.trimmingCharacters(in: .whitespacesAndNewlines)
|
self.connectedBridgeID = BridgeEndpointID.stableID(endpoint)
|
||||||
self.connectedBridgeID = id.isEmpty ? BridgeEndpointID.stableID(endpoint) : id
|
|
||||||
self.voiceWakeSyncTask?.cancel()
|
self.voiceWakeSyncTask?.cancel()
|
||||||
self.voiceWakeSyncTask = nil
|
self.voiceWakeSyncTask = nil
|
||||||
|
|
||||||
@@ -233,16 +230,12 @@ final class NodeAppModel {
|
|||||||
try await self.bridge.connect(
|
try await self.bridge.connect(
|
||||||
endpoint: endpoint,
|
endpoint: endpoint,
|
||||||
hello: hello,
|
hello: hello,
|
||||||
tls: tls,
|
onConnected: { [weak self] serverName in
|
||||||
onConnected: { [weak self] serverName, mainSessionKey in
|
|
||||||
guard let self else { return }
|
guard let self else { return }
|
||||||
await MainActor.run {
|
await MainActor.run {
|
||||||
self.bridgeStatusText = "Connected"
|
self.bridgeStatusText = "Connected"
|
||||||
self.bridgeServerName = serverName
|
self.bridgeServerName = serverName
|
||||||
}
|
}
|
||||||
await MainActor.run {
|
|
||||||
self.applyMainSessionKey(mainSessionKey)
|
|
||||||
}
|
|
||||||
if let addr = await self.bridge.currentRemoteAddress() {
|
if let addr = await self.bridge.currentRemoteAddress() {
|
||||||
await MainActor.run {
|
await MainActor.run {
|
||||||
self.bridgeRemoteAddress = addr
|
self.bridgeRemoteAddress = addr
|
||||||
@@ -291,10 +284,7 @@ final class NodeAppModel {
|
|||||||
self.bridgeRemoteAddress = nil
|
self.bridgeRemoteAddress = nil
|
||||||
self.connectedBridgeID = nil
|
self.connectedBridgeID = nil
|
||||||
self.seamColorHex = nil
|
self.seamColorHex = nil
|
||||||
if !SessionKey.isCanonicalMainSessionKey(self.mainSessionKey) {
|
self.mainSessionKey = "main"
|
||||||
self.mainSessionKey = "main"
|
|
||||||
self.talkMode.updateMainSessionKey(self.mainSessionKey)
|
|
||||||
}
|
|
||||||
self.showLocalCanvasOnDisconnect()
|
self.showLocalCanvasOnDisconnect()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -311,23 +301,10 @@ final class NodeAppModel {
|
|||||||
self.bridgeRemoteAddress = nil
|
self.bridgeRemoteAddress = nil
|
||||||
self.connectedBridgeID = nil
|
self.connectedBridgeID = nil
|
||||||
self.seamColorHex = nil
|
self.seamColorHex = nil
|
||||||
if !SessionKey.isCanonicalMainSessionKey(self.mainSessionKey) {
|
self.mainSessionKey = "main"
|
||||||
self.mainSessionKey = "main"
|
|
||||||
self.talkMode.updateMainSessionKey(self.mainSessionKey)
|
|
||||||
}
|
|
||||||
self.showLocalCanvasOnDisconnect()
|
self.showLocalCanvasOnDisconnect()
|
||||||
}
|
}
|
||||||
|
|
||||||
private func applyMainSessionKey(_ key: String?) {
|
|
||||||
let trimmed = (key ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
|
|
||||||
guard !trimmed.isEmpty else { return }
|
|
||||||
let current = self.mainSessionKey.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
||||||
if SessionKey.isCanonicalMainSessionKey(current) { return }
|
|
||||||
if trimmed == current { return }
|
|
||||||
self.mainSessionKey = trimmed
|
|
||||||
self.talkMode.updateMainSessionKey(trimmed)
|
|
||||||
}
|
|
||||||
|
|
||||||
var seamColor: Color {
|
var seamColor: Color {
|
||||||
Self.color(fromHex: self.seamColorHex) ?? Self.defaultSeamColor
|
Self.color(fromHex: self.seamColorHex) ?? Self.defaultSeamColor
|
||||||
}
|
}
|
||||||
@@ -353,13 +330,11 @@ final class NodeAppModel {
|
|||||||
let ui = config["ui"] as? [String: Any]
|
let ui = config["ui"] as? [String: Any]
|
||||||
let raw = (ui?["seamColor"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
let raw = (ui?["seamColor"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||||
let session = config["session"] as? [String: Any]
|
let session = config["session"] as? [String: Any]
|
||||||
let mainKey = SessionKey.normalizeMainKey(session?["mainKey"] as? String)
|
let rawMainKey = (session?["mainKey"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||||
|
let mainKey = rawMainKey.isEmpty ? "main" : rawMainKey
|
||||||
await MainActor.run {
|
await MainActor.run {
|
||||||
self.seamColorHex = raw.isEmpty ? nil : raw
|
self.seamColorHex = raw.isEmpty ? nil : raw
|
||||||
if !SessionKey.isCanonicalMainSessionKey(self.mainSessionKey) {
|
self.mainSessionKey = mainKey
|
||||||
self.mainSessionKey = mainKey
|
|
||||||
self.talkMode.updateMainSessionKey(mainKey)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// ignore
|
// ignore
|
||||||
|
|||||||
@@ -137,11 +137,9 @@ final class ScreenRecordService: @unchecked Sendable {
|
|||||||
recordQueue: DispatchQueue) -> @Sendable (CMSampleBuffer, RPSampleBufferType, Error?) -> Void
|
recordQueue: DispatchQueue) -> @Sendable (CMSampleBuffer, RPSampleBufferType, Error?) -> Void
|
||||||
{
|
{
|
||||||
{ sample, type, error in
|
{ sample, type, error in
|
||||||
let sampleBox = UncheckedSendableBox(value: sample)
|
|
||||||
// ReplayKit can call the capture handler on a background queue.
|
// ReplayKit can call the capture handler on a background queue.
|
||||||
// Serialize writes to avoid queue asserts.
|
// Serialize writes to avoid queue asserts.
|
||||||
recordQueue.async {
|
recordQueue.async {
|
||||||
let sample = sampleBox.value
|
|
||||||
if let error {
|
if let error {
|
||||||
state.withLock { state in
|
state.withLock { state in
|
||||||
if state.handlerError == nil { state.handlerError = error }
|
if state.handlerError == nil { state.handlerError = error }
|
||||||
|
|||||||
@@ -1,15 +0,0 @@
|
|||||||
import Foundation
|
|
||||||
|
|
||||||
enum SessionKey {
|
|
||||||
static func normalizeMainKey(_ raw: String?) -> String {
|
|
||||||
let trimmed = (raw ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
|
|
||||||
return trimmed.isEmpty ? "main" : trimmed
|
|
||||||
}
|
|
||||||
|
|
||||||
static func isCanonicalMainSessionKey(_ value: String?) -> Bool {
|
|
||||||
let trimmed = (value ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
|
|
||||||
if trimmed.isEmpty { return false }
|
|
||||||
if trimmed == "global" { return true }
|
|
||||||
return trimmed.hasPrefix("agent:")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -407,11 +407,9 @@ struct SettingsTab: View {
|
|||||||
modelIdentifier: self.modelIdentifier(),
|
modelIdentifier: self.modelIdentifier(),
|
||||||
caps: self.currentCaps(),
|
caps: self.currentCaps(),
|
||||||
commands: self.currentCommands())
|
commands: self.currentCommands())
|
||||||
let tlsParams = self.resolveDiscoveredTLSParams(bridge: bridge)
|
|
||||||
let token = try await BridgeClient().pairAndHello(
|
let token = try await BridgeClient().pairAndHello(
|
||||||
endpoint: bridge.endpoint,
|
endpoint: bridge.endpoint,
|
||||||
hello: hello,
|
hello: hello,
|
||||||
tls: tlsParams,
|
|
||||||
onStatus: { status in
|
onStatus: { status in
|
||||||
Task { @MainActor in
|
Task { @MainActor in
|
||||||
statusStore.text = status
|
statusStore.text = status
|
||||||
@@ -427,8 +425,6 @@ struct SettingsTab: View {
|
|||||||
|
|
||||||
self.appModel.connectToBridge(
|
self.appModel.connectToBridge(
|
||||||
endpoint: bridge.endpoint,
|
endpoint: bridge.endpoint,
|
||||||
bridgeStableID: bridge.stableID,
|
|
||||||
tls: tlsParams,
|
|
||||||
hello: BridgeHello(
|
hello: BridgeHello(
|
||||||
nodeId: self.instanceId,
|
nodeId: self.instanceId,
|
||||||
displayName: self.displayName,
|
displayName: self.displayName,
|
||||||
@@ -465,8 +461,6 @@ struct SettingsTab: View {
|
|||||||
defer { self.connectingBridgeID = nil }
|
defer { self.connectingBridgeID = nil }
|
||||||
|
|
||||||
let endpoint: NWEndpoint = .hostPort(host: NWEndpoint.Host(host), port: port)
|
let endpoint: NWEndpoint = .hostPort(host: NWEndpoint.Host(host), port: port)
|
||||||
let stableID = BridgeEndpointID.stableID(endpoint)
|
|
||||||
let tlsParams = self.resolveManualTLSParams(stableID: stableID)
|
|
||||||
|
|
||||||
do {
|
do {
|
||||||
let statusStore = self.connectStatus
|
let statusStore = self.connectStatus
|
||||||
@@ -490,7 +484,6 @@ struct SettingsTab: View {
|
|||||||
let token = try await BridgeClient().pairAndHello(
|
let token = try await BridgeClient().pairAndHello(
|
||||||
endpoint: endpoint,
|
endpoint: endpoint,
|
||||||
hello: hello,
|
hello: hello,
|
||||||
tls: tlsParams,
|
|
||||||
onStatus: { status in
|
onStatus: { status in
|
||||||
Task { @MainActor in
|
Task { @MainActor in
|
||||||
statusStore.text = status
|
statusStore.text = status
|
||||||
@@ -506,8 +499,6 @@ struct SettingsTab: View {
|
|||||||
|
|
||||||
self.appModel.connectToBridge(
|
self.appModel.connectToBridge(
|
||||||
endpoint: endpoint,
|
endpoint: endpoint,
|
||||||
bridgeStableID: stableID,
|
|
||||||
tls: tlsParams,
|
|
||||||
hello: BridgeHello(
|
hello: BridgeHello(
|
||||||
nodeId: self.instanceId,
|
nodeId: self.instanceId,
|
||||||
displayName: self.displayName,
|
displayName: self.displayName,
|
||||||
@@ -524,47 +515,6 @@ struct SettingsTab: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func resolveDiscoveredTLSParams(
|
|
||||||
bridge: BridgeDiscoveryModel.DiscoveredBridge) -> BridgeTLSParams?
|
|
||||||
{
|
|
||||||
let stableID = bridge.stableID
|
|
||||||
let stored = BridgeTLSStore.loadFingerprint(stableID: stableID)
|
|
||||||
|
|
||||||
if bridge.tlsEnabled || bridge.tlsFingerprintSha256 != nil {
|
|
||||||
return BridgeTLSParams(
|
|
||||||
required: true,
|
|
||||||
expectedFingerprint: bridge.tlsFingerprintSha256 ?? stored,
|
|
||||||
allowTOFU: stored == nil,
|
|
||||||
storeKey: stableID)
|
|
||||||
}
|
|
||||||
|
|
||||||
if let stored {
|
|
||||||
return BridgeTLSParams(
|
|
||||||
required: true,
|
|
||||||
expectedFingerprint: stored,
|
|
||||||
allowTOFU: false,
|
|
||||||
storeKey: stableID)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
private func resolveManualTLSParams(stableID: String) -> BridgeTLSParams? {
|
|
||||||
if let stored = BridgeTLSStore.loadFingerprint(stableID: stableID) {
|
|
||||||
return BridgeTLSParams(
|
|
||||||
required: true,
|
|
||||||
expectedFingerprint: stored,
|
|
||||||
allowTOFU: false,
|
|
||||||
storeKey: stableID)
|
|
||||||
}
|
|
||||||
|
|
||||||
return BridgeTLSParams(
|
|
||||||
required: false,
|
|
||||||
expectedFingerprint: nil,
|
|
||||||
allowTOFU: true,
|
|
||||||
storeKey: stableID)
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func primaryIPv4Address() -> String? {
|
private static func primaryIPv4Address() -> String? {
|
||||||
var addrList: UnsafeMutablePointer<ifaddrs>?
|
var addrList: UnsafeMutablePointer<ifaddrs>?
|
||||||
guard getifaddrs(&addrList) == 0, let first = addrList else { return nil }
|
guard getifaddrs(&addrList) == 0, let first = addrList else { return nil }
|
||||||
|
|||||||
@@ -53,13 +53,6 @@ final class TalkModeManager: NSObject {
|
|||||||
self.bridge = bridge
|
self.bridge = bridge
|
||||||
}
|
}
|
||||||
|
|
||||||
func updateMainSessionKey(_ sessionKey: String?) {
|
|
||||||
let trimmed = (sessionKey ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
|
|
||||||
guard !trimmed.isEmpty else { return }
|
|
||||||
if SessionKey.isCanonicalMainSessionKey(self.mainSessionKey) { return }
|
|
||||||
self.mainSessionKey = trimmed
|
|
||||||
}
|
|
||||||
|
|
||||||
func setEnabled(_ enabled: Bool) {
|
func setEnabled(_ enabled: Bool) {
|
||||||
self.isEnabled = enabled
|
self.isEnabled = enabled
|
||||||
if enabled {
|
if enabled {
|
||||||
@@ -295,8 +288,9 @@ final class TalkModeManager: NSObject {
|
|||||||
self.chatSubscribedSessionKeys.insert(key)
|
self.chatSubscribedSessionKeys.insert(key)
|
||||||
self.logger.info("chat.subscribe ok sessionKey=\(key, privacy: .public)")
|
self.logger.info("chat.subscribe ok sessionKey=\(key, privacy: .public)")
|
||||||
} catch {
|
} catch {
|
||||||
let err = error.localizedDescription
|
self.logger.warning(
|
||||||
self.logger.warning("chat.subscribe failed key=\(key, privacy: .public) err=\(err, privacy: .public)")
|
"chat.subscribe failed sessionKey=\(key, privacy: .public) " +
|
||||||
|
"err=\(error.localizedDescription, privacy: .public)")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -534,8 +528,9 @@ final class TalkModeManager: NSObject {
|
|||||||
self.lastPlaybackWasPCM = false
|
self.lastPlaybackWasPCM = false
|
||||||
result = await self.mp3Player.play(stream: stream)
|
result = await self.mp3Player.play(stream: stream)
|
||||||
}
|
}
|
||||||
let duration = Date().timeIntervalSince(started)
|
self.logger.info(
|
||||||
self.logger.info("elevenlabs stream finished=\(result.finished, privacy: .public) dur=\(duration, privacy: .public)s")
|
"elevenlabs stream finished=\(result.finished, privacy: .public) " +
|
||||||
|
"dur=\(Date().timeIntervalSince(started), privacy: .public)s")
|
||||||
if !result.finished, let interruptedAt = result.interruptedAt {
|
if !result.finished, let interruptedAt = result.interruptedAt {
|
||||||
self.lastInterruptedAtSeconds = interruptedAt
|
self.lastInterruptedAtSeconds = interruptedAt
|
||||||
}
|
}
|
||||||
@@ -656,10 +651,8 @@ final class TalkModeManager: NSObject {
|
|||||||
guard let config = json["config"] as? [String: Any] else { return }
|
guard let config = json["config"] as? [String: Any] else { return }
|
||||||
let talk = config["talk"] as? [String: Any]
|
let talk = config["talk"] as? [String: Any]
|
||||||
let session = config["session"] as? [String: Any]
|
let session = config["session"] as? [String: Any]
|
||||||
let mainKey = SessionKey.normalizeMainKey(session?["mainKey"] as? String)
|
let rawMainKey = (session?["mainKey"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||||
if !SessionKey.isCanonicalMainSessionKey(self.mainSessionKey) {
|
self.mainSessionKey = rawMainKey.isEmpty ? "main" : rawMainKey
|
||||||
self.mainSessionKey = mainKey
|
|
||||||
}
|
|
||||||
self.defaultVoiceId = (talk?["voiceId"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines)
|
self.defaultVoiceId = (talk?["voiceId"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
if let aliases = talk?["voiceAliases"] as? [String: Any] {
|
if let aliases = talk?["voiceAliases"] as? [String: Any] {
|
||||||
var resolved: [String: String] = [:]
|
var resolved: [String: String] = [:]
|
||||||
|
|||||||
@@ -26,8 +26,7 @@ Sources/Voice/VoiceTab.swift
|
|||||||
Sources/Voice/VoiceWakeManager.swift
|
Sources/Voice/VoiceWakeManager.swift
|
||||||
Sources/Voice/VoiceWakePreferences.swift
|
Sources/Voice/VoiceWakePreferences.swift
|
||||||
../shared/ClawdbotKit/Sources/ClawdbotChatUI/ChatComposer.swift
|
../shared/ClawdbotKit/Sources/ClawdbotChatUI/ChatComposer.swift
|
||||||
../shared/ClawdbotKit/Sources/ClawdbotChatUI/ChatMarkdownRenderer.swift
|
../shared/ClawdbotKit/Sources/ClawdbotChatUI/ChatMarkdownSplitter.swift
|
||||||
../shared/ClawdbotKit/Sources/ClawdbotChatUI/ChatMarkdownPreprocessor.swift
|
|
||||||
../shared/ClawdbotKit/Sources/ClawdbotChatUI/ChatMessageViews.swift
|
../shared/ClawdbotKit/Sources/ClawdbotChatUI/ChatMessageViews.swift
|
||||||
../shared/ClawdbotKit/Sources/ClawdbotChatUI/ChatModels.swift
|
../shared/ClawdbotKit/Sources/ClawdbotChatUI/ChatModels.swift
|
||||||
../shared/ClawdbotKit/Sources/ClawdbotChatUI/ChatPayloadDecoding.swift
|
../shared/ClawdbotKit/Sources/ClawdbotChatUI/ChatPayloadDecoding.swift
|
||||||
|
|||||||
@@ -27,7 +27,6 @@ private actor MockBridgePairingClient: BridgePairingClient {
|
|||||||
func pairAndHello(
|
func pairAndHello(
|
||||||
endpoint: NWEndpoint,
|
endpoint: NWEndpoint,
|
||||||
hello: BridgeHello,
|
hello: BridgeHello,
|
||||||
tls: BridgeTLSParams?,
|
|
||||||
onStatus: (@Sendable (String) -> Void)?) async throws -> String
|
onStatus: (@Sendable (String) -> Void)?) async throws -> String
|
||||||
{
|
{
|
||||||
self.lastToken = hello.token
|
self.lastToken = hello.token
|
||||||
@@ -176,6 +175,7 @@ private func withKeychainValues<T>(
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test @MainActor func makeHelloBuildsCapsAndCommands() {
|
@Test @MainActor func makeHelloBuildsCapsAndCommands() {
|
||||||
|
let defaults = UserDefaults.standard
|
||||||
let voiceWakeKey = VoiceWakePreferences.enabledKey
|
let voiceWakeKey = VoiceWakePreferences.enabledKey
|
||||||
|
|
||||||
withKeychainValues([instanceIdEntry: nil, preferredBridgeEntry: nil, lastBridgeEntry: nil]) {
|
withKeychainValues([instanceIdEntry: nil, preferredBridgeEntry: nil, lastBridgeEntry: nil]) {
|
||||||
@@ -245,8 +245,6 @@ private func withKeychainValues<T>(
|
|||||||
gatewayPort: 18789,
|
gatewayPort: 18789,
|
||||||
bridgePort: 18790,
|
bridgePort: 18790,
|
||||||
canvasPort: 18793,
|
canvasPort: 18793,
|
||||||
tlsEnabled: false,
|
|
||||||
tlsFingerprintSha256: nil,
|
|
||||||
cliPath: nil)
|
cliPath: nil)
|
||||||
let mock = MockBridgePairingClient(resultToken: "new-token")
|
let mock = MockBridgePairingClient(resultToken: "new-token")
|
||||||
let account = "bridge-token.ios-test"
|
let account = "bridge-token.ios-test"
|
||||||
@@ -295,8 +293,6 @@ private func withKeychainValues<T>(
|
|||||||
gatewayPort: 18789,
|
gatewayPort: 18789,
|
||||||
bridgePort: 18790,
|
bridgePort: 18790,
|
||||||
canvasPort: 18793,
|
canvasPort: 18793,
|
||||||
tlsEnabled: false,
|
|
||||||
tlsFingerprintSha256: nil,
|
|
||||||
cliPath: nil)
|
cliPath: nil)
|
||||||
let bridgeB = BridgeDiscoveryModel.DiscoveredBridge(
|
let bridgeB = BridgeDiscoveryModel.DiscoveredBridge(
|
||||||
name: "Gateway B",
|
name: "Gateway B",
|
||||||
@@ -308,8 +304,6 @@ private func withKeychainValues<T>(
|
|||||||
gatewayPort: 28789,
|
gatewayPort: 28789,
|
||||||
bridgePort: 28790,
|
bridgePort: 28790,
|
||||||
canvasPort: 28793,
|
canvasPort: 28793,
|
||||||
tlsEnabled: false,
|
|
||||||
tlsFingerprintSha256: nil,
|
|
||||||
cliPath: nil)
|
cliPath: nil)
|
||||||
|
|
||||||
let mock = MockBridgePairingClient(resultToken: "token-ok")
|
let mock = MockBridgePairingClient(resultToken: "token-ok")
|
||||||
|
|||||||
@@ -5,8 +5,7 @@ import Testing
|
|||||||
@Test func errorDescriptionsAreStable() {
|
@Test func errorDescriptionsAreStable() {
|
||||||
#expect(CameraController.CameraError.cameraUnavailable.errorDescription == "Camera unavailable")
|
#expect(CameraController.CameraError.cameraUnavailable.errorDescription == "Camera unavailable")
|
||||||
#expect(CameraController.CameraError.microphoneUnavailable.errorDescription == "Microphone unavailable")
|
#expect(CameraController.CameraError.microphoneUnavailable.errorDescription == "Microphone unavailable")
|
||||||
#expect(CameraController.CameraError.permissionDenied(kind: "Camera")
|
#expect(CameraController.CameraError.permissionDenied(kind: "Camera").errorDescription == "Camera permission denied")
|
||||||
.errorDescription == "Camera permission denied")
|
|
||||||
#expect(CameraController.CameraError.invalidParams("bad").errorDescription == "bad")
|
#expect(CameraController.CameraError.invalidParams("bad").errorDescription == "bad")
|
||||||
#expect(CameraController.CameraError.captureFailed("nope").errorDescription == "nope")
|
#expect(CameraController.CameraError.captureFailed("nope").errorDescription == "nope")
|
||||||
#expect(CameraController.CameraError.exportFailed("export").errorDescription == "export")
|
#expect(CameraController.CameraError.exportFailed("export").errorDescription == "export")
|
||||||
|
|||||||
@@ -17,8 +17,8 @@
|
|||||||
<key>CFBundlePackageType</key>
|
<key>CFBundlePackageType</key>
|
||||||
<string>BNDL</string>
|
<string>BNDL</string>
|
||||||
<key>CFBundleShortVersionString</key>
|
<key>CFBundleShortVersionString</key>
|
||||||
<string>2026.1.11-4</string>
|
<string>1.0</string>
|
||||||
<key>CFBundleVersion</key>
|
<key>CFBundleVersion</key>
|
||||||
<string>202601113</string>
|
<string>1</string>
|
||||||
</dict>
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
@@ -20,3 +20,4 @@ import Testing
|
|||||||
#expect(triggers == nil)
|
#expect(triggers == nil)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||