Merge remote-tracking branch 'upstream/main' into claude/swiftui-github-mobile-app-WjaD7
This commit is contained in:
commit
e98a2f0097
BIN
.agent/.DS_Store
vendored
BIN
.agent/.DS_Store
vendored
Binary file not shown.
@ -7,6 +7,10 @@
|
|||||||
[exclude-files]
|
[exclude-files]
|
||||||
# pnpm lockfiles contain lots of high-entropy package integrity blobs.
|
# pnpm lockfiles contain lots of high-entropy package integrity blobs.
|
||||||
pattern = (^|/)pnpm-lock\.yaml$
|
pattern = (^|/)pnpm-lock\.yaml$
|
||||||
|
# Generated output and vendored assets.
|
||||||
|
pattern = (^|/)(dist|vendor)/
|
||||||
|
# Local config file with allowlist patterns.
|
||||||
|
pattern = (^|/)\.detect-secrets\.cfg$
|
||||||
|
|
||||||
[exclude-lines]
|
[exclude-lines]
|
||||||
# Fastlane checks for private key marker; not a real key.
|
# Fastlane checks for private key marker; not a real key.
|
||||||
|
|||||||
1
.github/FUNDING.yml
vendored
Normal file
1
.github/FUNDING.yml
vendored
Normal file
@ -0,0 +1 @@
|
|||||||
|
custom: ['https://github.com/sponsors/steipete']
|
||||||
17
.github/actionlint.yaml
vendored
Normal file
17
.github/actionlint.yaml
vendored
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
# actionlint configuration
|
||||||
|
# https://github.com/rhysd/actionlint/blob/main/docs/config.md
|
||||||
|
|
||||||
|
self-hosted-runner:
|
||||||
|
labels:
|
||||||
|
# Blacksmith CI runners
|
||||||
|
- blacksmith-4vcpu-ubuntu-2404
|
||||||
|
- blacksmith-4vcpu-windows-2025
|
||||||
|
|
||||||
|
# Ignore patterns for known issues
|
||||||
|
paths:
|
||||||
|
.github/workflows/**/*.yml:
|
||||||
|
ignore:
|
||||||
|
# Ignore shellcheck warnings (we run shellcheck separately)
|
||||||
|
- 'shellcheck reported issue.+'
|
||||||
|
# Ignore intentional if: false for disabled jobs
|
||||||
|
- 'constant expression "false" in condition'
|
||||||
113
.github/dependabot.yml
vendored
Normal file
113
.github/dependabot.yml
vendored
Normal file
@ -0,0 +1,113 @@
|
|||||||
|
# Dependabot configuration
|
||||||
|
# https://docs.github.com/en/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file
|
||||||
|
|
||||||
|
version: 2
|
||||||
|
|
||||||
|
registries:
|
||||||
|
npm-npmjs:
|
||||||
|
type: npm-registry
|
||||||
|
url: https://registry.npmjs.org
|
||||||
|
replaces-base: true
|
||||||
|
|
||||||
|
updates:
|
||||||
|
# npm dependencies (root)
|
||||||
|
- package-ecosystem: npm
|
||||||
|
directory: /
|
||||||
|
schedule:
|
||||||
|
interval: weekly
|
||||||
|
cooldown:
|
||||||
|
default-days: 7
|
||||||
|
groups:
|
||||||
|
production:
|
||||||
|
dependency-type: production
|
||||||
|
update-types:
|
||||||
|
- minor
|
||||||
|
- patch
|
||||||
|
development:
|
||||||
|
dependency-type: development
|
||||||
|
update-types:
|
||||||
|
- minor
|
||||||
|
- patch
|
||||||
|
open-pull-requests-limit: 10
|
||||||
|
registries:
|
||||||
|
- npm-npmjs
|
||||||
|
|
||||||
|
# GitHub Actions
|
||||||
|
- package-ecosystem: github-actions
|
||||||
|
directory: /
|
||||||
|
schedule:
|
||||||
|
interval: weekly
|
||||||
|
cooldown:
|
||||||
|
default-days: 7
|
||||||
|
groups:
|
||||||
|
actions:
|
||||||
|
patterns:
|
||||||
|
- "*"
|
||||||
|
update-types:
|
||||||
|
- minor
|
||||||
|
- patch
|
||||||
|
open-pull-requests-limit: 5
|
||||||
|
|
||||||
|
# Swift Package Manager - macOS app
|
||||||
|
- package-ecosystem: swift
|
||||||
|
directory: /apps/macos
|
||||||
|
schedule:
|
||||||
|
interval: weekly
|
||||||
|
cooldown:
|
||||||
|
default-days: 7
|
||||||
|
groups:
|
||||||
|
swift-deps:
|
||||||
|
patterns:
|
||||||
|
- "*"
|
||||||
|
update-types:
|
||||||
|
- minor
|
||||||
|
- patch
|
||||||
|
open-pull-requests-limit: 5
|
||||||
|
|
||||||
|
# Swift Package Manager - shared ClawdbotKit
|
||||||
|
- package-ecosystem: swift
|
||||||
|
directory: /apps/shared/ClawdbotKit
|
||||||
|
schedule:
|
||||||
|
interval: weekly
|
||||||
|
cooldown:
|
||||||
|
default-days: 7
|
||||||
|
groups:
|
||||||
|
swift-deps:
|
||||||
|
patterns:
|
||||||
|
- "*"
|
||||||
|
update-types:
|
||||||
|
- minor
|
||||||
|
- patch
|
||||||
|
open-pull-requests-limit: 5
|
||||||
|
|
||||||
|
# Swift Package Manager - Swabble
|
||||||
|
- package-ecosystem: swift
|
||||||
|
directory: /Swabble
|
||||||
|
schedule:
|
||||||
|
interval: weekly
|
||||||
|
cooldown:
|
||||||
|
default-days: 7
|
||||||
|
groups:
|
||||||
|
swift-deps:
|
||||||
|
patterns:
|
||||||
|
- "*"
|
||||||
|
update-types:
|
||||||
|
- minor
|
||||||
|
- patch
|
||||||
|
open-pull-requests-limit: 5
|
||||||
|
|
||||||
|
# Gradle - Android app
|
||||||
|
- package-ecosystem: gradle
|
||||||
|
directory: /apps/android
|
||||||
|
schedule:
|
||||||
|
interval: weekly
|
||||||
|
cooldown:
|
||||||
|
default-days: 7
|
||||||
|
groups:
|
||||||
|
android-deps:
|
||||||
|
patterns:
|
||||||
|
- "*"
|
||||||
|
update-types:
|
||||||
|
- minor
|
||||||
|
- patch
|
||||||
|
open-pull-requests-limit: 5
|
||||||
222
.github/labeler.yml
vendored
Normal file
222
.github/labeler.yml
vendored
Normal file
@ -0,0 +1,222 @@
|
|||||||
|
"channel: bluebubbles":
|
||||||
|
- changed-files:
|
||||||
|
- any-glob-to-any-file:
|
||||||
|
- "extensions/bluebubbles/**"
|
||||||
|
- "docs/channels/bluebubbles.md"
|
||||||
|
"channel: discord":
|
||||||
|
- changed-files:
|
||||||
|
- any-glob-to-any-file:
|
||||||
|
- "src/discord/**"
|
||||||
|
- "extensions/discord/**"
|
||||||
|
- "docs/channels/discord.md"
|
||||||
|
"channel: googlechat":
|
||||||
|
- changed-files:
|
||||||
|
- any-glob-to-any-file:
|
||||||
|
- "extensions/googlechat/**"
|
||||||
|
- "docs/channels/googlechat.md"
|
||||||
|
"channel: imessage":
|
||||||
|
- changed-files:
|
||||||
|
- any-glob-to-any-file:
|
||||||
|
- "src/imessage/**"
|
||||||
|
- "extensions/imessage/**"
|
||||||
|
- "docs/channels/imessage.md"
|
||||||
|
"channel: line":
|
||||||
|
- changed-files:
|
||||||
|
- any-glob-to-any-file:
|
||||||
|
- "extensions/line/**"
|
||||||
|
- "docs/channels/line.md"
|
||||||
|
"channel: matrix":
|
||||||
|
- changed-files:
|
||||||
|
- any-glob-to-any-file:
|
||||||
|
- "extensions/matrix/**"
|
||||||
|
- "docs/channels/matrix.md"
|
||||||
|
"channel: mattermost":
|
||||||
|
- changed-files:
|
||||||
|
- any-glob-to-any-file:
|
||||||
|
- "extensions/mattermost/**"
|
||||||
|
- "docs/channels/mattermost.md"
|
||||||
|
"channel: msteams":
|
||||||
|
- changed-files:
|
||||||
|
- any-glob-to-any-file:
|
||||||
|
- "extensions/msteams/**"
|
||||||
|
- "docs/channels/msteams.md"
|
||||||
|
"channel: nextcloud-talk":
|
||||||
|
- changed-files:
|
||||||
|
- any-glob-to-any-file:
|
||||||
|
- "extensions/nextcloud-talk/**"
|
||||||
|
- "docs/channels/nextcloud-talk.md"
|
||||||
|
"channel: nostr":
|
||||||
|
- changed-files:
|
||||||
|
- any-glob-to-any-file:
|
||||||
|
- "extensions/nostr/**"
|
||||||
|
- "docs/channels/nostr.md"
|
||||||
|
"channel: signal":
|
||||||
|
- changed-files:
|
||||||
|
- any-glob-to-any-file:
|
||||||
|
- "src/signal/**"
|
||||||
|
- "extensions/signal/**"
|
||||||
|
- "docs/channels/signal.md"
|
||||||
|
"channel: slack":
|
||||||
|
- changed-files:
|
||||||
|
- any-glob-to-any-file:
|
||||||
|
- "src/slack/**"
|
||||||
|
- "extensions/slack/**"
|
||||||
|
- "docs/channels/slack.md"
|
||||||
|
"channel: telegram":
|
||||||
|
- changed-files:
|
||||||
|
- any-glob-to-any-file:
|
||||||
|
- "src/telegram/**"
|
||||||
|
- "extensions/telegram/**"
|
||||||
|
- "docs/channels/telegram.md"
|
||||||
|
"channel: tlon":
|
||||||
|
- changed-files:
|
||||||
|
- any-glob-to-any-file:
|
||||||
|
- "extensions/tlon/**"
|
||||||
|
- "docs/channels/tlon.md"
|
||||||
|
"channel: voice-call":
|
||||||
|
- changed-files:
|
||||||
|
- any-glob-to-any-file:
|
||||||
|
- "extensions/voice-call/**"
|
||||||
|
"channel: whatsapp-web":
|
||||||
|
- changed-files:
|
||||||
|
- any-glob-to-any-file:
|
||||||
|
- "src/web/**"
|
||||||
|
- "extensions/whatsapp/**"
|
||||||
|
- "docs/channels/whatsapp.md"
|
||||||
|
"channel: zalo":
|
||||||
|
- changed-files:
|
||||||
|
- any-glob-to-any-file:
|
||||||
|
- "extensions/zalo/**"
|
||||||
|
- "docs/channels/zalo.md"
|
||||||
|
"channel: zalouser":
|
||||||
|
- changed-files:
|
||||||
|
- any-glob-to-any-file:
|
||||||
|
- "extensions/zalouser/**"
|
||||||
|
- "docs/channels/zalouser.md"
|
||||||
|
|
||||||
|
"app: android":
|
||||||
|
- changed-files:
|
||||||
|
- any-glob-to-any-file:
|
||||||
|
- "apps/android/**"
|
||||||
|
- "docs/platforms/android.md"
|
||||||
|
"app: ios":
|
||||||
|
- changed-files:
|
||||||
|
- any-glob-to-any-file:
|
||||||
|
- "apps/ios/**"
|
||||||
|
- "docs/platforms/ios.md"
|
||||||
|
"app: macos":
|
||||||
|
- changed-files:
|
||||||
|
- any-glob-to-any-file:
|
||||||
|
- "apps/macos/**"
|
||||||
|
- "docs/platforms/macos.md"
|
||||||
|
- "docs/platforms/mac/**"
|
||||||
|
"app: web-ui":
|
||||||
|
- changed-files:
|
||||||
|
- any-glob-to-any-file:
|
||||||
|
- "ui/**"
|
||||||
|
- "src/gateway/control-ui.ts"
|
||||||
|
- "src/gateway/control-ui-shared.ts"
|
||||||
|
- "src/gateway/protocol/**"
|
||||||
|
- "src/gateway/server-methods/chat.ts"
|
||||||
|
- "src/infra/control-ui-assets.ts"
|
||||||
|
|
||||||
|
"gateway":
|
||||||
|
- changed-files:
|
||||||
|
- any-glob-to-any-file:
|
||||||
|
- "src/gateway/**"
|
||||||
|
- "src/daemon/**"
|
||||||
|
- "docs/gateway/**"
|
||||||
|
|
||||||
|
"docs":
|
||||||
|
- changed-files:
|
||||||
|
- any-glob-to-any-file:
|
||||||
|
- "docs/**"
|
||||||
|
- "docs.acp.md"
|
||||||
|
|
||||||
|
"cli":
|
||||||
|
- changed-files:
|
||||||
|
- any-glob-to-any-file:
|
||||||
|
- "src/cli/**"
|
||||||
|
|
||||||
|
"commands":
|
||||||
|
- changed-files:
|
||||||
|
- any-glob-to-any-file:
|
||||||
|
- "src/commands/**"
|
||||||
|
|
||||||
|
"scripts":
|
||||||
|
- changed-files:
|
||||||
|
- any-glob-to-any-file:
|
||||||
|
- "scripts/**"
|
||||||
|
|
||||||
|
"docker":
|
||||||
|
- changed-files:
|
||||||
|
- any-glob-to-any-file:
|
||||||
|
- "Dockerfile"
|
||||||
|
- "Dockerfile.*"
|
||||||
|
- "docker-compose.yml"
|
||||||
|
- "docker-setup.sh"
|
||||||
|
- ".dockerignore"
|
||||||
|
- "scripts/**/*docker*"
|
||||||
|
- "scripts/**/Dockerfile*"
|
||||||
|
- "scripts/sandbox-*.sh"
|
||||||
|
- "src/agents/sandbox*.ts"
|
||||||
|
- "src/commands/sandbox*.ts"
|
||||||
|
- "src/cli/sandbox-cli.ts"
|
||||||
|
- "src/docker-setup.test.ts"
|
||||||
|
- "src/config/**/*sandbox*"
|
||||||
|
- "docs/cli/sandbox.md"
|
||||||
|
- "docs/gateway/sandbox*.md"
|
||||||
|
- "docs/install/docker.md"
|
||||||
|
- "docs/multi-agent-sandbox-tools.md"
|
||||||
|
|
||||||
|
"agents":
|
||||||
|
- changed-files:
|
||||||
|
- any-glob-to-any-file:
|
||||||
|
- "src/agents/**"
|
||||||
|
|
||||||
|
"security":
|
||||||
|
- changed-files:
|
||||||
|
- any-glob-to-any-file:
|
||||||
|
- "docs/cli/security.md"
|
||||||
|
- "docs/gateway/security.md"
|
||||||
|
|
||||||
|
"extensions: copilot-proxy":
|
||||||
|
- changed-files:
|
||||||
|
- any-glob-to-any-file:
|
||||||
|
- "extensions/copilot-proxy/**"
|
||||||
|
"extensions: diagnostics-otel":
|
||||||
|
- changed-files:
|
||||||
|
- any-glob-to-any-file:
|
||||||
|
- "extensions/diagnostics-otel/**"
|
||||||
|
"extensions: google-antigravity-auth":
|
||||||
|
- changed-files:
|
||||||
|
- any-glob-to-any-file:
|
||||||
|
- "extensions/google-antigravity-auth/**"
|
||||||
|
"extensions: google-gemini-cli-auth":
|
||||||
|
- changed-files:
|
||||||
|
- any-glob-to-any-file:
|
||||||
|
- "extensions/google-gemini-cli-auth/**"
|
||||||
|
"extensions: llm-task":
|
||||||
|
- changed-files:
|
||||||
|
- any-glob-to-any-file:
|
||||||
|
- "extensions/llm-task/**"
|
||||||
|
"extensions: lobster":
|
||||||
|
- changed-files:
|
||||||
|
- any-glob-to-any-file:
|
||||||
|
- "extensions/lobster/**"
|
||||||
|
"extensions: memory-core":
|
||||||
|
- changed-files:
|
||||||
|
- any-glob-to-any-file:
|
||||||
|
- "extensions/memory-core/**"
|
||||||
|
"extensions: memory-lancedb":
|
||||||
|
- changed-files:
|
||||||
|
- any-glob-to-any-file:
|
||||||
|
- "extensions/memory-lancedb/**"
|
||||||
|
"extensions: open-prose":
|
||||||
|
- changed-files:
|
||||||
|
- any-glob-to-any-file:
|
||||||
|
- "extensions/open-prose/**"
|
||||||
|
"extensions: qwen-portal-auth":
|
||||||
|
- changed-files:
|
||||||
|
- any-glob-to-any-file:
|
||||||
|
- "extensions/qwen-portal-auth/**"
|
||||||
65
.github/workflows/auto-response.yml
vendored
Normal file
65
.github/workflows/auto-response.yml
vendored
Normal file
@ -0,0 +1,65 @@
|
|||||||
|
name: Auto response
|
||||||
|
|
||||||
|
on:
|
||||||
|
issues:
|
||||||
|
types: [labeled]
|
||||||
|
pull_request_target:
|
||||||
|
types: [labeled]
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
issues: write
|
||||||
|
pull-requests: write
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
auto-response:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/create-github-app-token@v1
|
||||||
|
id: app-token
|
||||||
|
with:
|
||||||
|
app-id: "2729701"
|
||||||
|
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
|
||||||
|
- name: Handle labeled items
|
||||||
|
uses: actions/github-script@v7
|
||||||
|
with:
|
||||||
|
github-token: ${{ steps.app-token.outputs.token }}
|
||||||
|
script: |
|
||||||
|
const rules = [
|
||||||
|
{
|
||||||
|
label: "skill-clawdhub",
|
||||||
|
close: true,
|
||||||
|
message:
|
||||||
|
"Thanks for the contribution! New skills should be published to Clawdhub for everyone to use. We’re keeping the core lean on skills, so I’m closing this out.",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const labelName = context.payload.label?.name;
|
||||||
|
if (!labelName) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rule = rules.find((item) => item.label === labelName);
|
||||||
|
if (!rule) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const issueNumber = context.payload.issue?.number ?? context.payload.pull_request?.number;
|
||||||
|
if (!issueNumber) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await github.rest.issues.createComment({
|
||||||
|
owner: context.repo.owner,
|
||||||
|
repo: context.repo.repo,
|
||||||
|
issue_number: issueNumber,
|
||||||
|
body: rule.message,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (rule.close) {
|
||||||
|
await github.rest.issues.update({
|
||||||
|
owner: context.repo.owner,
|
||||||
|
repo: context.repo.repo,
|
||||||
|
issue_number: issueNumber,
|
||||||
|
state: "closed",
|
||||||
|
});
|
||||||
|
}
|
||||||
90
.github/workflows/ci.yml
vendored
90
.github/workflows/ci.yml
vendored
@ -32,20 +32,29 @@ jobs:
|
|||||||
node-version: 22.x
|
node-version: 22.x
|
||||||
check-latest: true
|
check-latest: true
|
||||||
|
|
||||||
|
- name: Setup pnpm (corepack retry)
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
corepack enable
|
||||||
|
for attempt in 1 2 3; do
|
||||||
|
if corepack prepare pnpm@10.23.0 --activate; then
|
||||||
|
pnpm -v
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
echo "corepack prepare failed (attempt $attempt/3). Retrying..."
|
||||||
|
sleep $((attempt * 10))
|
||||||
|
done
|
||||||
|
exit 1
|
||||||
|
|
||||||
- name: Runtime versions
|
- name: Runtime versions
|
||||||
run: |
|
run: |
|
||||||
node -v
|
node -v
|
||||||
npm -v
|
npm -v
|
||||||
|
pnpm -v
|
||||||
|
|
||||||
- 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"
|
||||||
|
|
||||||
- name: Enable corepack and pin pnpm
|
|
||||||
run: |
|
|
||||||
corepack enable
|
|
||||||
corepack prepare pnpm@10.23.0 --activate
|
|
||||||
pnpm -v
|
|
||||||
|
|
||||||
- name: Install dependencies (frozen)
|
- name: Install dependencies (frozen)
|
||||||
env:
|
env:
|
||||||
CI: true
|
CI: true
|
||||||
@ -108,6 +117,20 @@ jobs:
|
|||||||
node-version: 22.x
|
node-version: 22.x
|
||||||
check-latest: true
|
check-latest: true
|
||||||
|
|
||||||
|
- name: Setup pnpm (corepack retry)
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
corepack enable
|
||||||
|
for attempt in 1 2 3; do
|
||||||
|
if corepack prepare pnpm@10.23.0 --activate; then
|
||||||
|
pnpm -v
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
echo "corepack prepare failed (attempt $attempt/3). Retrying..."
|
||||||
|
sleep $((attempt * 10))
|
||||||
|
done
|
||||||
|
exit 1
|
||||||
|
|
||||||
- name: Setup Bun
|
- name: Setup Bun
|
||||||
uses: oven-sh/setup-bun@v2
|
uses: oven-sh/setup-bun@v2
|
||||||
with:
|
with:
|
||||||
@ -118,16 +141,11 @@ jobs:
|
|||||||
node -v
|
node -v
|
||||||
npm -v
|
npm -v
|
||||||
bun -v
|
bun -v
|
||||||
|
pnpm -v
|
||||||
|
|
||||||
- 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"
|
||||||
|
|
||||||
- name: Enable corepack and pin pnpm
|
|
||||||
run: |
|
|
||||||
corepack enable
|
|
||||||
corepack prepare pnpm@10.23.0 --activate
|
|
||||||
pnpm -v
|
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
env:
|
env:
|
||||||
CI: true
|
CI: true
|
||||||
@ -168,6 +186,8 @@ jobs:
|
|||||||
|
|
||||||
checks-windows:
|
checks-windows:
|
||||||
runs-on: blacksmith-4vcpu-windows-2025
|
runs-on: blacksmith-4vcpu-windows-2025
|
||||||
|
env:
|
||||||
|
NODE_OPTIONS: --max-old-space-size=4096
|
||||||
defaults:
|
defaults:
|
||||||
run:
|
run:
|
||||||
shell: bash
|
shell: bash
|
||||||
@ -212,6 +232,20 @@ jobs:
|
|||||||
node-version: 22.x
|
node-version: 22.x
|
||||||
check-latest: true
|
check-latest: true
|
||||||
|
|
||||||
|
- name: Setup pnpm (corepack retry)
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
corepack enable
|
||||||
|
for attempt in 1 2 3; do
|
||||||
|
if corepack prepare pnpm@10.23.0 --activate; then
|
||||||
|
pnpm -v
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
echo "corepack prepare failed (attempt $attempt/3). Retrying..."
|
||||||
|
sleep $((attempt * 10))
|
||||||
|
done
|
||||||
|
exit 1
|
||||||
|
|
||||||
- name: Setup Bun
|
- name: Setup Bun
|
||||||
uses: oven-sh/setup-bun@v2
|
uses: oven-sh/setup-bun@v2
|
||||||
with:
|
with:
|
||||||
@ -222,16 +256,11 @@ jobs:
|
|||||||
node -v
|
node -v
|
||||||
npm -v
|
npm -v
|
||||||
bun -v
|
bun -v
|
||||||
|
pnpm -v
|
||||||
|
|
||||||
- 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"
|
||||||
|
|
||||||
- name: Enable corepack and pin pnpm
|
|
||||||
run: |
|
|
||||||
corepack enable
|
|
||||||
corepack prepare pnpm@10.23.0 --activate
|
|
||||||
pnpm -v
|
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
env:
|
env:
|
||||||
CI: true
|
CI: true
|
||||||
@ -279,20 +308,29 @@ jobs:
|
|||||||
node-version: 22.x
|
node-version: 22.x
|
||||||
check-latest: true
|
check-latest: true
|
||||||
|
|
||||||
|
- name: Setup pnpm (corepack retry)
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
corepack enable
|
||||||
|
for attempt in 1 2 3; do
|
||||||
|
if corepack prepare pnpm@10.23.0 --activate; then
|
||||||
|
pnpm -v
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
echo "corepack prepare failed (attempt $attempt/3). Retrying..."
|
||||||
|
sleep $((attempt * 10))
|
||||||
|
done
|
||||||
|
exit 1
|
||||||
|
|
||||||
- name: Runtime versions
|
- name: Runtime versions
|
||||||
run: |
|
run: |
|
||||||
node -v
|
node -v
|
||||||
npm -v
|
npm -v
|
||||||
|
pnpm -v
|
||||||
|
|
||||||
- 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"
|
||||||
|
|
||||||
- name: Enable corepack and pin pnpm
|
|
||||||
run: |
|
|
||||||
corepack enable
|
|
||||||
corepack prepare pnpm@10.23.0 --activate
|
|
||||||
pnpm -v
|
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
env:
|
env:
|
||||||
CI: true
|
CI: true
|
||||||
@ -304,6 +342,8 @@ jobs:
|
|||||||
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 --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 }}
|
- name: Run ${{ matrix.task }}
|
||||||
|
env:
|
||||||
|
NODE_OPTIONS: --max-old-space-size=4096
|
||||||
run: ${{ matrix.command }}
|
run: ${{ matrix.command }}
|
||||||
|
|
||||||
macos-app:
|
macos-app:
|
||||||
@ -590,6 +630,8 @@ jobs:
|
|||||||
|
|
||||||
- name: Setup Gradle
|
- name: Setup Gradle
|
||||||
uses: gradle/actions/setup-gradle@v4
|
uses: gradle/actions/setup-gradle@v4
|
||||||
|
with:
|
||||||
|
gradle-version: 8.11.1
|
||||||
|
|
||||||
- name: Install Android SDK packages
|
- name: Install Android SDK packages
|
||||||
run: |
|
run: |
|
||||||
|
|||||||
143
.github/workflows/docker-release.yml
vendored
Normal file
143
.github/workflows/docker-release.yml
vendored
Normal file
@ -0,0 +1,143 @@
|
|||||||
|
name: Docker Release
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
tags:
|
||||||
|
- "v*"
|
||||||
|
|
||||||
|
env:
|
||||||
|
REGISTRY: ghcr.io
|
||||||
|
IMAGE_NAME: ${{ github.repository }}
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
# Build amd64 image
|
||||||
|
build-amd64:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
packages: write
|
||||||
|
contents: read
|
||||||
|
outputs:
|
||||||
|
image-digest: ${{ steps.build.outputs.digest }}
|
||||||
|
image-metadata: ${{ steps.meta.outputs.json }}
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
|
- name: Login to GitHub Container Registry
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: ${{ env.REGISTRY }}
|
||||||
|
username: ${{ github.repository_owner }}
|
||||||
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
|
- name: Extract metadata
|
||||||
|
id: meta
|
||||||
|
uses: docker/metadata-action@v5
|
||||||
|
with:
|
||||||
|
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||||
|
tags: |
|
||||||
|
type=ref,event=branch
|
||||||
|
type=semver,pattern={{version}}
|
||||||
|
type=semver,pattern={{version}},suffix=-amd64
|
||||||
|
type=semver,pattern={{version}},suffix=-arm64
|
||||||
|
type=ref,event=branch,suffix=-amd64
|
||||||
|
type=ref,event=branch,suffix=-arm64
|
||||||
|
|
||||||
|
- name: Build and push amd64 image
|
||||||
|
id: build
|
||||||
|
uses: docker/build-push-action@v6
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
platforms: linux/amd64
|
||||||
|
labels: ${{ steps.meta.outputs.labels }}
|
||||||
|
tags: ${{ steps.meta.outputs.tags }}
|
||||||
|
cache-from: type=gha
|
||||||
|
cache-to: type=gha,mode=max
|
||||||
|
provenance: false
|
||||||
|
push: true
|
||||||
|
|
||||||
|
# Build arm64 image
|
||||||
|
build-arm64:
|
||||||
|
runs-on: ubuntu-24.04-arm
|
||||||
|
permissions:
|
||||||
|
packages: write
|
||||||
|
contents: read
|
||||||
|
outputs:
|
||||||
|
image-digest: ${{ steps.build.outputs.digest }}
|
||||||
|
image-metadata: ${{ steps.meta.outputs.json }}
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
|
- name: Login to GitHub Container Registry
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: ${{ env.REGISTRY }}
|
||||||
|
username: ${{ github.repository_owner }}
|
||||||
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
|
- name: Extract metadata
|
||||||
|
id: meta
|
||||||
|
uses: docker/metadata-action@v5
|
||||||
|
with:
|
||||||
|
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||||
|
tags: |
|
||||||
|
type=ref,event=branch
|
||||||
|
type=semver,pattern={{version}}
|
||||||
|
type=semver,pattern={{version}},suffix=-amd64
|
||||||
|
type=semver,pattern={{version}},suffix=-arm64
|
||||||
|
type=ref,event=branch,suffix=-amd64
|
||||||
|
type=ref,event=branch,suffix=-arm64
|
||||||
|
|
||||||
|
- name: Build and push arm64 image
|
||||||
|
id: build
|
||||||
|
uses: docker/build-push-action@v6
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
platforms: linux/arm64
|
||||||
|
labels: ${{ steps.meta.outputs.labels }}
|
||||||
|
tags: ${{ steps.meta.outputs.tags }}
|
||||||
|
cache-from: type=gha
|
||||||
|
cache-to: type=gha,mode=max
|
||||||
|
provenance: false
|
||||||
|
push: true
|
||||||
|
|
||||||
|
# Create multi-platform manifest
|
||||||
|
create-manifest:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
packages: write
|
||||||
|
contents: read
|
||||||
|
needs: [build-amd64, build-arm64]
|
||||||
|
steps:
|
||||||
|
- name: Login to GitHub Container Registry
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: ${{ env.REGISTRY }}
|
||||||
|
username: ${{ github.repository_owner }}
|
||||||
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
|
- name: Extract metadata for manifest
|
||||||
|
id: meta
|
||||||
|
uses: docker/metadata-action@v5
|
||||||
|
with:
|
||||||
|
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||||
|
tags: |
|
||||||
|
type=ref,event=branch
|
||||||
|
type=semver,pattern={{version}}
|
||||||
|
|
||||||
|
- name: Create and push manifest
|
||||||
|
run: |
|
||||||
|
docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
|
||||||
|
${{ needs.build-amd64.outputs.image-digest }} \
|
||||||
|
${{ needs.build-arm64.outputs.image-digest }}
|
||||||
|
env:
|
||||||
|
DOCKER_METADATA_OUTPUT_JSON: ${{ steps.meta.outputs.json }}
|
||||||
20
.github/workflows/install-smoke.yml
vendored
20
.github/workflows/install-smoke.yml
vendored
@ -13,12 +13,19 @@ jobs:
|
|||||||
- name: Checkout CLI
|
- name: Checkout CLI
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Setup pnpm
|
- name: Setup pnpm (corepack retry)
|
||||||
uses: pnpm/action-setup@v3
|
run: |
|
||||||
with:
|
set -euo pipefail
|
||||||
version: 10
|
corepack enable
|
||||||
- name: Enable Corepack
|
for attempt in 1 2 3; do
|
||||||
run: corepack enable
|
if corepack prepare pnpm@10.23.0 --activate; then
|
||||||
|
pnpm -v
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
echo "corepack prepare failed (attempt $attempt/3). Retrying..."
|
||||||
|
sleep $((attempt * 10))
|
||||||
|
done
|
||||||
|
exit 1
|
||||||
|
|
||||||
- name: Install pnpm deps (minimal)
|
- name: Install pnpm deps (minimal)
|
||||||
run: pnpm install --ignore-scripts --frozen-lockfile
|
run: pnpm install --ignore-scripts --frozen-lockfile
|
||||||
@ -29,5 +36,6 @@ jobs:
|
|||||||
CLAWDBOT_INSTALL_CLI_URL: https://clawd.bot/install-cli.sh
|
CLAWDBOT_INSTALL_CLI_URL: https://clawd.bot/install-cli.sh
|
||||||
CLAWDBOT_NO_ONBOARD: "1"
|
CLAWDBOT_NO_ONBOARD: "1"
|
||||||
CLAWDBOT_INSTALL_SMOKE_SKIP_CLI: "1"
|
CLAWDBOT_INSTALL_SMOKE_SKIP_CLI: "1"
|
||||||
|
CLAWDBOT_INSTALL_SMOKE_SKIP_NONROOT: ${{ github.event_name == 'pull_request' && '1' || '0' }}
|
||||||
CLAWDBOT_INSTALL_SMOKE_PREVIOUS: "2026.1.11-4"
|
CLAWDBOT_INSTALL_SMOKE_PREVIOUS: "2026.1.11-4"
|
||||||
run: pnpm test:install:smoke
|
run: pnpm test:install:smoke
|
||||||
|
|||||||
23
.github/workflows/labeler.yml
vendored
Normal file
23
.github/workflows/labeler.yml
vendored
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
name: Labeler
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request_target:
|
||||||
|
types: [opened, synchronize, reopened]
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
pull-requests: write
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
label:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/create-github-app-token@v1
|
||||||
|
id: app-token
|
||||||
|
with:
|
||||||
|
app-id: "2729701"
|
||||||
|
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
|
||||||
|
- uses: actions/labeler@v5
|
||||||
|
with:
|
||||||
|
configuration-path: .github/labeler.yml
|
||||||
|
repo-token: ${{ steps.app-token.outputs.token }}
|
||||||
105
.pre-commit-config.yaml
Normal file
105
.pre-commit-config.yaml
Normal file
@ -0,0 +1,105 @@
|
|||||||
|
# Pre-commit hooks for clawdbot
|
||||||
|
# Install: prek install
|
||||||
|
# Run manually: prek run --all-files
|
||||||
|
#
|
||||||
|
# See https://pre-commit.com for more information
|
||||||
|
|
||||||
|
repos:
|
||||||
|
# Basic file hygiene
|
||||||
|
- repo: https://github.com/pre-commit/pre-commit-hooks
|
||||||
|
rev: v6.0.0
|
||||||
|
hooks:
|
||||||
|
- id: trailing-whitespace
|
||||||
|
exclude: '^(docs/|dist/|vendor/|.*\.snap$)'
|
||||||
|
- id: end-of-file-fixer
|
||||||
|
exclude: '^(docs/|dist/|vendor/|.*\.snap$)'
|
||||||
|
- id: check-yaml
|
||||||
|
args: [--allow-multiple-documents]
|
||||||
|
- id: check-added-large-files
|
||||||
|
args: [--maxkb=500]
|
||||||
|
- id: check-merge-conflict
|
||||||
|
|
||||||
|
# Secret detection (same as CI)
|
||||||
|
- repo: https://github.com/Yelp/detect-secrets
|
||||||
|
rev: v1.5.0
|
||||||
|
hooks:
|
||||||
|
- id: detect-secrets
|
||||||
|
args:
|
||||||
|
- --baseline
|
||||||
|
- .secrets.baseline
|
||||||
|
- --exclude-files
|
||||||
|
- '(^|/)(dist/|vendor/|pnpm-lock\.yaml$|\.detect-secrets\.cfg$)'
|
||||||
|
- --exclude-lines
|
||||||
|
- 'key_content\.include\?\("BEGIN PRIVATE KEY"\)'
|
||||||
|
- --exclude-lines
|
||||||
|
- 'case \.apiKeyEnv: "API key \(env var\)"'
|
||||||
|
- --exclude-lines
|
||||||
|
- 'case apikey = "apiKey"'
|
||||||
|
- --exclude-lines
|
||||||
|
- '"gateway\.remote\.password"'
|
||||||
|
- --exclude-lines
|
||||||
|
- '"gateway\.auth\.password"'
|
||||||
|
- --exclude-lines
|
||||||
|
- '"talk\.apiKey"'
|
||||||
|
- --exclude-lines
|
||||||
|
- '=== "string"'
|
||||||
|
- --exclude-lines
|
||||||
|
- 'typeof remote\?\.password === "string"'
|
||||||
|
|
||||||
|
# Shell script linting
|
||||||
|
- repo: https://github.com/koalaman/shellcheck-precommit
|
||||||
|
rev: v0.11.0
|
||||||
|
hooks:
|
||||||
|
- id: shellcheck
|
||||||
|
args: [--severity=error] # Only fail on errors, not warnings/info
|
||||||
|
# Exclude vendor and scripts with embedded code or known issues
|
||||||
|
exclude: '^(vendor/|scripts/e2e/)'
|
||||||
|
|
||||||
|
# GitHub Actions linting
|
||||||
|
- repo: https://github.com/rhysd/actionlint
|
||||||
|
rev: v1.7.10
|
||||||
|
hooks:
|
||||||
|
- id: actionlint
|
||||||
|
|
||||||
|
# GitHub Actions security audit
|
||||||
|
- repo: https://github.com/zizmorcore/zizmor-pre-commit
|
||||||
|
rev: v1.22.0
|
||||||
|
hooks:
|
||||||
|
- id: zizmor
|
||||||
|
args: [--persona=regular, --min-severity=medium, --min-confidence=medium]
|
||||||
|
exclude: '^(vendor/|Swabble/)'
|
||||||
|
|
||||||
|
# Project checks (same commands as CI)
|
||||||
|
- repo: local
|
||||||
|
hooks:
|
||||||
|
# oxlint --type-aware src test
|
||||||
|
- id: oxlint
|
||||||
|
name: oxlint
|
||||||
|
entry: scripts/pre-commit/run-node-tool.sh oxlint --type-aware src test
|
||||||
|
language: system
|
||||||
|
pass_filenames: false
|
||||||
|
types_or: [javascript, jsx, ts, tsx]
|
||||||
|
|
||||||
|
# oxfmt --check src test
|
||||||
|
- id: oxfmt
|
||||||
|
name: oxfmt
|
||||||
|
entry: scripts/pre-commit/run-node-tool.sh oxfmt --check src test
|
||||||
|
language: system
|
||||||
|
pass_filenames: false
|
||||||
|
types_or: [javascript, jsx, ts, tsx]
|
||||||
|
|
||||||
|
# swiftlint (same as CI)
|
||||||
|
- id: swiftlint
|
||||||
|
name: swiftlint
|
||||||
|
entry: swiftlint --config .swiftlint.yml
|
||||||
|
language: system
|
||||||
|
pass_filenames: false
|
||||||
|
types: [swift]
|
||||||
|
|
||||||
|
# swiftformat --lint (same as CI)
|
||||||
|
- id: swiftformat
|
||||||
|
name: swiftformat
|
||||||
|
entry: swiftformat --lint apps/macos/Sources --config .swiftformat
|
||||||
|
language: system
|
||||||
|
pass_filenames: false
|
||||||
|
types: [swift]
|
||||||
1923
.secrets.baseline
1923
.secrets.baseline
File diff suppressed because it is too large
Load Diff
25
.shellcheckrc
Normal file
25
.shellcheckrc
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
# ShellCheck configuration
|
||||||
|
# https://www.shellcheck.net/wiki/
|
||||||
|
|
||||||
|
# Disable common false positives and style suggestions
|
||||||
|
|
||||||
|
# SC2034: Variable appears unused (often exported or used indirectly)
|
||||||
|
disable=SC2034
|
||||||
|
|
||||||
|
# SC2155: Declare and assign separately (common idiom, rarely causes issues)
|
||||||
|
disable=SC2155
|
||||||
|
|
||||||
|
# SC2295: Expansions inside ${..} need quoting (info-level, rarely causes issues)
|
||||||
|
disable=SC2295
|
||||||
|
|
||||||
|
# SC1012: \r is literal (tr -d '\r' works as intended on most systems)
|
||||||
|
disable=SC1012
|
||||||
|
|
||||||
|
# SC2026: Word outside quotes (info-level, often intentional)
|
||||||
|
disable=SC2026
|
||||||
|
|
||||||
|
# SC2016: Expressions don't expand in single quotes (often intentional in sed/awk)
|
||||||
|
disable=SC2016
|
||||||
|
|
||||||
|
# SC2129: Consider using { cmd1; cmd2; } >> file (style preference)
|
||||||
|
disable=SC2129
|
||||||
20
AGENTS.md
20
AGENTS.md
@ -7,24 +7,38 @@
|
|||||||
- 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.
|
- 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.
|
||||||
|
- Plugins: install runs `npm install --omit=dev` in plugin dir; runtime deps must live in `dependencies`. Avoid `workspace:*` in `dependencies` (npm install breaks); put `clawdbot` in `devDependencies` or `peerDependencies` instead (runtime resolves `clawdbot/plugin-sdk` via jiti alias).
|
||||||
- Installers served from `https://clawd.bot/*`: live in the sibling repo `../clawd.bot` (`public/install.sh`, `public/install-cli.sh`, `public/install.ps1`).
|
- 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).
|
- 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 docs: `docs/channels/`
|
||||||
- Core channel code: `src/telegram`, `src/discord`, `src/slack`, `src/signal`, `src/imessage`, `src/web` (WhatsApp web), `src/channels`, `src/routing`
|
- 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`)
|
- Extensions (channel plugins): `extensions/*` (e.g. `extensions/msteams`, `extensions/matrix`, `extensions/zalo`, `extensions/zalouser`, `extensions/voice-call`)
|
||||||
|
- When adding channels/extensions/apps/docs, review `.github/labeler.yml` for label coverage.
|
||||||
|
|
||||||
## Docs Linking (Mintlify)
|
## Docs Linking (Mintlify)
|
||||||
- Docs are hosted on Mintlify (docs.clawd.bot).
|
- Docs are hosted on Mintlify (docs.clawd.bot).
|
||||||
- Internal doc links in `docs/**/*.md`: root-relative, no `.md`/`.mdx` (example: `[Config](/configuration)`).
|
- 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)`).
|
- Section cross-references: use anchors on root-relative paths (example: `[Hooks](/configuration#hooks)`).
|
||||||
|
- Doc headings and anchors: avoid em dashes and apostrophes in headings because they break Mintlify anchor links.
|
||||||
- When Peter asks for links, reply with full `https://docs.clawd.bot/...` URLs (not root-relative).
|
- 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.
|
- 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.
|
- 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”.
|
- Docs content must be generic: no personal device names/hostnames/paths; use placeholders like `user@gateway-host` and “gateway host”.
|
||||||
|
|
||||||
|
## exe.dev VM ops (general)
|
||||||
|
- Access: stable path is `ssh exe.dev` then `ssh vm-name` (assume SSH key already set).
|
||||||
|
- SSH flaky: use exe.dev web terminal or Shelley (web agent); keep a tmux session for long ops.
|
||||||
|
- Update: `sudo npm i -g clawdbot@latest` (global install needs root on `/usr/lib/node_modules`).
|
||||||
|
- Config: use `clawdbot config set ...`; ensure `gateway.mode=local` is set.
|
||||||
|
- Discord: store raw token only (no `DISCORD_BOT_TOKEN=` prefix).
|
||||||
|
- Restart: stop old gateway and run:
|
||||||
|
`pkill -9 -f clawdbot-gateway || true; nohup clawdbot gateway run --bind loopback --port 18789 --force > /tmp/clawdbot-gateway.log 2>&1 &`
|
||||||
|
- Verify: `clawdbot channels status --probe`, `ss -ltnp | rg 18789`, `tail -n 120 /tmp/clawdbot-gateway.log`.
|
||||||
|
|
||||||
## Build, Test, and Development Commands
|
## Build, Test, and Development Commands
|
||||||
- Runtime baseline: Node **22+** (keep Node + Bun paths working).
|
- Runtime baseline: Node **22+** (keep Node + Bun paths working).
|
||||||
- Install deps: `pnpm install`
|
- Install deps: `pnpm install`
|
||||||
|
- Pre-commit hooks: `prek install` (runs same checks as CI)
|
||||||
- Also supported: `bun install` (keep `pnpm-lock.yaml` + Bun patching in sync when touching deps/patches).
|
- Also supported: `bun install` (keep `pnpm-lock.yaml` + Bun patching in sync when touching deps/patches).
|
||||||
- Prefer Bun for TypeScript execution (scripts, dev, tests): `bun <file.ts>` / `bunx <tool>`.
|
- Prefer Bun for TypeScript execution (scripts, dev, tests): `bun <file.ts>` / `bunx <tool>`.
|
||||||
- Run CLI in dev: `pnpm clawdbot ...` (bun) or `pnpm dev`.
|
- Run CLI in dev: `pnpm clawdbot ...` (bun) or `pnpm dev`.
|
||||||
@ -66,6 +80,7 @@
|
|||||||
- PR review flow: when given a PR link, review via `gh pr view`/`gh pr diff` and do **not** change branches.
|
- PR review flow: when given a PR link, review via `gh pr view`/`gh pr diff` and do **not** change branches.
|
||||||
- PR review calls: prefer a single `gh pr view --json ...` to batch metadata/comments; run `gh pr diff` only when needed.
|
- PR review calls: prefer a single `gh pr view --json ...` to batch metadata/comments; run `gh pr diff` only when needed.
|
||||||
- Before starting a review when a GH Issue/PR is pasted: run `git pull`; if there are local changes or unpushed commits, stop and alert the user before reviewing.
|
- Before starting a review when a GH Issue/PR is pasted: run `git pull`; if there are local changes or unpushed commits, stop and alert the user before reviewing.
|
||||||
|
- Goal: merge PRs. Prefer **rebase** when commits are clean; **squash** when history is messy.
|
||||||
- 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`.
|
- 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.
|
- 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 a PR: add a changelog entry with the PR number and thank the contributor.
|
||||||
@ -94,6 +109,7 @@
|
|||||||
## Agent-Specific Notes
|
## Agent-Specific Notes
|
||||||
- Vocabulary: "makeup" = "mac app".
|
- Vocabulary: "makeup" = "mac app".
|
||||||
- Never edit `node_modules` (global/Homebrew/npm/git installs too). Updates overwrite. Skill notes go in `tools.md` or `AGENTS.md`.
|
- Never edit `node_modules` (global/Homebrew/npm/git installs too). Updates overwrite. Skill notes go in `tools.md` or `AGENTS.md`.
|
||||||
|
- Signal: "update fly" => `fly ssh console -a flawd-bot -C "bash -lc 'cd /data/clawd/clawdbot && git pull --rebase origin main'"` then `fly machines restart e825232f34d058 -a flawd-bot`.
|
||||||
- When working on a GitHub Issue or PR, print the full URL at the end of the task.
|
- When working on a GitHub Issue or PR, print the full URL at the end of the task.
|
||||||
- When answering questions, respond with high-confidence answers only: verify in code; do not guess.
|
- When answering questions, respond with high-confidence answers only: verify in code; do not guess.
|
||||||
- Never update the Carbon dependency.
|
- Never update the Carbon dependency.
|
||||||
@ -119,6 +135,10 @@
|
|||||||
- **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 explicitly requested.
|
||||||
- **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.
|
||||||
|
- Lint/format churn:
|
||||||
|
- If staged+unstaged diffs are formatting-only, auto-resolve without asking.
|
||||||
|
- If commit/push already requested, auto-stage and include formatting-only follow-ups in the same commit (or a tiny follow-up commit if needed), no extra confirmation.
|
||||||
|
- Only ask when changes are semantic (logic/data/behavior).
|
||||||
- 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.
|
- 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.
|
||||||
- **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.
|
- **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.
|
||||||
- Bug investigations: read source code of relevant npm dependencies and all related local code before concluding; aim for high-confidence root cause.
|
- Bug investigations: read source code of relevant npm dependencies and all related local code before concluding; aim for high-confidence root cause.
|
||||||
|
|||||||
213
CHANGELOG.md
213
CHANGELOG.md
@ -2,6 +2,218 @@
|
|||||||
|
|
||||||
Docs: https://docs.clawd.bot
|
Docs: https://docs.clawd.bot
|
||||||
|
|
||||||
|
## 2026.1.25
|
||||||
|
Status: unreleased.
|
||||||
|
|
||||||
|
### Changes
|
||||||
|
- Agents: honor tools.exec.safeBins in exec allowlist checks. (#2281)
|
||||||
|
- Docs: tighten Fly private deployment steps. (#2289) Thanks @dguido.
|
||||||
|
- Docs: add migration guide for moving to a new machine. (#2381)
|
||||||
|
- Gateway: warn on hook tokens via query params; document header auth preference. (#2200) Thanks @YuriNachos.
|
||||||
|
- Gateway: add dangerous Control UI device auth bypass flag + audit warnings. (#2248)
|
||||||
|
- Doctor: warn on gateway exposure without auth. (#2016) Thanks @Alex-Alaniz.
|
||||||
|
- Discord: add configurable privileged gateway intents for presences/members. (#2266) Thanks @kentaro.
|
||||||
|
- Docs: add Vercel AI Gateway to providers sidebar. (#1901) Thanks @jerilynzheng.
|
||||||
|
- Agents: expand cron tool description with full schema docs. (#1988) Thanks @tomascupr.
|
||||||
|
- Skills: add missing dependency metadata for GitHub, Notion, Slack, Discord. (#1995) Thanks @jackheuberger.
|
||||||
|
- Docs: add Render deployment guide. (#1975) Thanks @anurag.
|
||||||
|
- Docs: add Claude Max API Proxy guide. (#1875) Thanks @atalovesyou.
|
||||||
|
- Docs: add DigitalOcean deployment guide. (#1870) Thanks @0xJonHoldsCrypto.
|
||||||
|
- Docs: add Oracle Cloud (OCI) platform guide + cross-links. (#2333) Thanks @hirefrank.
|
||||||
|
- Docs: add Raspberry Pi install guide. (#1871) Thanks @0xJonHoldsCrypto.
|
||||||
|
- Docs: add GCP Compute Engine deployment guide. (#1848) Thanks @hougangdev.
|
||||||
|
- Docs: add LINE channel guide. Thanks @thewilloftheshadow.
|
||||||
|
- Docs: credit both contributors for Control UI refresh. (#1852) Thanks @EnzeD.
|
||||||
|
- Onboarding: add Venice API key to non-interactive flow. (#1893) Thanks @jonisjongithub.
|
||||||
|
- Onboarding: strengthen security warning copy for beta + access control expectations.
|
||||||
|
- Tlon: format thread reply IDs as @ud. (#1837) Thanks @wca4a.
|
||||||
|
- Gateway: prefer newest session metadata when combining stores. (#1823) Thanks @emanuelst.
|
||||||
|
- Web UI: keep sub-agent announce replies visible in WebChat. (#1977) Thanks @andrescardonas7.
|
||||||
|
- CI: increase Node heap size for macOS checks. (#1890) Thanks @realZachi.
|
||||||
|
- macOS: avoid crash when rendering code blocks by bumping Textual to 0.3.1. (#2033) Thanks @garricn.
|
||||||
|
- Browser: fall back to URL matching for extension relay target resolution. (#1999) Thanks @jonit-dev.
|
||||||
|
- Update: ignore dist/control-ui for dirty checks and restore after ui builds. (#1976) Thanks @Glucksberg.
|
||||||
|
- Telegram: allow caption param for media sends. (#1888) Thanks @mguellsegarra.
|
||||||
|
- Telegram: support plugin sendPayload channelData (media/buttons) and validate plugin commands. (#1917) Thanks @JoshuaLelon.
|
||||||
|
- Telegram: avoid block replies when streaming is disabled. (#1885) Thanks @ivancasco.
|
||||||
|
- Security: use Windows ACLs for permission audits and fixes on Windows. (#1957)
|
||||||
|
- Auth: show copyable Google auth URL after ASCII prompt. (#1787) Thanks @robbyczgw-cla.
|
||||||
|
- Routing: precompile session key regexes. (#1697) Thanks @Ray0907.
|
||||||
|
- TUI: avoid width overflow when rendering selection lists. (#1686) Thanks @mossein.
|
||||||
|
- Telegram: keep topic IDs in restart sentinel notifications. (#1807) Thanks @hsrvc.
|
||||||
|
- Telegram: add optional silent send flag (disable notifications). (#2382) Thanks @Suksham-sharma.
|
||||||
|
- Config: apply config.env before ${VAR} substitution. (#1813) Thanks @spanishflu-est1918.
|
||||||
|
- Slack: clear ack reaction after streamed replies. (#2044) Thanks @fancyboi999.
|
||||||
|
- macOS: keep custom SSH usernames in remote target. (#2046) Thanks @algal.
|
||||||
|
|
||||||
|
### Breaking
|
||||||
|
- **BREAKING:** Gateway auth mode "none" is removed; gateway now requires token/password (Tailscale Serve identity still allowed).
|
||||||
|
|
||||||
|
### Fixes
|
||||||
|
- BlueBubbles: coalesce inbound URL link preview messages. (#1981) Thanks @tyler6204.
|
||||||
|
- Agents: include memory.md when bootstrapping memory context. (#2318) Thanks @czekaj.
|
||||||
|
- Telegram: wrap reasoning italics per line to avoid raw underscores. (#2181) Thanks @YuriNachos.
|
||||||
|
- Voice Call: enforce Twilio webhook signature verification for ngrok URLs; disable ngrok free tier bypass by default.
|
||||||
|
- Security: harden Tailscale Serve auth by validating identity via local tailscaled before trusting headers.
|
||||||
|
- Build: align memory-core peer dependency with lockfile.
|
||||||
|
- Security: add mDNS discovery mode with minimal default to reduce information disclosure. (#1882) Thanks @orlyjamie.
|
||||||
|
- Security: harden URL fetches with DNS pinning to reduce rebinding risk. Thanks Chris Zheng.
|
||||||
|
- Web UI: improve WebChat image paste previews and allow image-only sends. (#1925) Thanks @smartprogrammer93.
|
||||||
|
- Security: wrap external hook content by default with a per-hook opt-out. (#1827) Thanks @mertcicekci0.
|
||||||
|
- Gateway: default auth now fail-closed (token/password required; Tailscale Serve identity remains allowed).
|
||||||
|
- Gateway: treat loopback + non-local Host connections as remote unless trusted proxy headers are present.
|
||||||
|
- Onboarding: remove unsupported gateway auth "off" choice from onboarding/configure flows and CLI flags.
|
||||||
|
|
||||||
|
## 2026.1.24-3
|
||||||
|
|
||||||
|
### Fixes
|
||||||
|
- Slack: fix image downloads failing due to missing Authorization header on cross-origin redirects. (#1936) Thanks @sanderhelgesen.
|
||||||
|
- Gateway: harden reverse proxy handling for local-client detection and unauthenticated proxied connects. (#1795) Thanks @orlyjamie.
|
||||||
|
- Security audit: flag loopback Control UI with auth disabled as critical. (#1795) Thanks @orlyjamie.
|
||||||
|
- CLI: resume claude-cli sessions and stream CLI replies to TUI clients. (#1921) Thanks @rmorse.
|
||||||
|
|
||||||
|
## 2026.1.24-2
|
||||||
|
|
||||||
|
### Fixes
|
||||||
|
- Packaging: include dist/link-understanding output in npm tarball (fixes missing apply.js import on install).
|
||||||
|
|
||||||
|
## 2026.1.24-1
|
||||||
|
|
||||||
|
### Fixes
|
||||||
|
- Packaging: include dist/shared output in npm tarball (fixes missing reasoning-tags import on install).
|
||||||
|
|
||||||
|
## 2026.1.24
|
||||||
|
|
||||||
|
### Highlights
|
||||||
|
- Providers: Ollama discovery + docs; Venice guide upgrades + cross-links. (#1606) Thanks @abhaymundhara. https://docs.clawd.bot/providers/ollama https://docs.clawd.bot/providers/venice
|
||||||
|
- Channels: LINE plugin (Messaging API) with rich replies + quick replies. (#1630) Thanks @plum-dawg.
|
||||||
|
- TTS: Edge fallback (keyless) + `/tts` auto modes. (#1668, #1667) Thanks @steipete, @sebslight. https://docs.clawd.bot/tts
|
||||||
|
- Exec approvals: approve in-chat via `/approve` across all channels (including plugins). (#1621) Thanks @czekaj. https://docs.clawd.bot/tools/exec-approvals https://docs.clawd.bot/tools/slash-commands
|
||||||
|
- Telegram: DM topics as separate sessions + outbound link preview toggle. (#1597, #1700) Thanks @rohannagpal, @zerone0x. https://docs.clawd.bot/channels/telegram
|
||||||
|
|
||||||
|
### Changes
|
||||||
|
- Channels: add LINE plugin (Messaging API) with rich replies, quick replies, and plugin HTTP registry. (#1630) Thanks @plum-dawg.
|
||||||
|
- TTS: add Edge TTS provider fallback, defaulting to keyless Edge with MP3 retry on format failures. (#1668) Thanks @steipete. https://docs.clawd.bot/tts
|
||||||
|
- TTS: add auto mode enum (off/always/inbound/tagged) with per-session `/tts` override. (#1667) Thanks @sebslight. https://docs.clawd.bot/tts
|
||||||
|
- Telegram: treat DM topics as separate sessions and keep DM history limits stable with thread suffixes. (#1597) Thanks @rohannagpal.
|
||||||
|
- Telegram: add `channels.telegram.linkPreview` to toggle outbound link previews. (#1700) Thanks @zerone0x. https://docs.clawd.bot/channels/telegram
|
||||||
|
- Web search: add Brave freshness filter parameter for time-scoped results. (#1688) Thanks @JonUleis. https://docs.clawd.bot/tools/web
|
||||||
|
- UI: refresh Control UI dashboard design system (colors, icons, typography). (#1745, #1786) Thanks @EnzeD, @mousberg.
|
||||||
|
- Exec approvals: forward approval prompts to chat with `/approve` for all channels (including plugins). (#1621) Thanks @czekaj. https://docs.clawd.bot/tools/exec-approvals https://docs.clawd.bot/tools/slash-commands
|
||||||
|
- Gateway: expose config.patch in the gateway tool with safe partial updates + restart sentinel. (#1653) Thanks @Glucksberg.
|
||||||
|
- Diagnostics: add diagnostic flags for targeted debug logs (config + env override). https://docs.clawd.bot/diagnostics/flags
|
||||||
|
- Docs: expand FAQ (migration, scheduling, concurrency, model recommendations, OpenAI subscription auth, Pi sizing, hackable install, docs SSL workaround).
|
||||||
|
- Docs: add verbose installer troubleshooting guidance.
|
||||||
|
- Docs: add macOS VM guide with local/hosted options + VPS/nodes guidance. (#1693) Thanks @f-trycua.
|
||||||
|
- Docs: add Bedrock EC2 instance role setup + IAM steps. (#1625) Thanks @sergical. https://docs.clawd.bot/bedrock
|
||||||
|
- Docs: update Fly.io guide notes.
|
||||||
|
- Dev: add prek pre-commit hooks + dependabot config for weekly updates. (#1720) Thanks @dguido.
|
||||||
|
|
||||||
|
### Fixes
|
||||||
|
- Web UI: fix config/debug layout overflow, scrolling, and code block sizing. (#1715) Thanks @saipreetham589.
|
||||||
|
- Web UI: show Stop button during active runs, swap back to New session when idle. (#1664) Thanks @ndbroadbent.
|
||||||
|
- Web UI: clear stale disconnect banners on reconnect; allow form saves with unsupported schema paths but block missing schema. (#1707) Thanks @Glucksberg.
|
||||||
|
- Web UI: hide internal `message_id` hints in chat bubbles.
|
||||||
|
- Gateway: allow Control UI token-only auth to skip device pairing even when device identity is present (`gateway.controlUi.allowInsecureAuth`). (#1679) Thanks @steipete.
|
||||||
|
- Matrix: decrypt E2EE media attachments with preflight size guard. (#1744) Thanks @araa47.
|
||||||
|
- BlueBubbles: route phone-number targets to DMs, avoid leaking routing IDs, and auto-create missing DMs (Private API required). (#1751) Thanks @tyler6204. https://docs.clawd.bot/channels/bluebubbles
|
||||||
|
- BlueBubbles: keep part-index GUIDs in reply tags when short IDs are missing.
|
||||||
|
- iMessage: normalize chat_id/chat_guid/chat_identifier prefixes case-insensitively and keep service-prefixed handles stable. (#1708) Thanks @aaronn.
|
||||||
|
- Signal: repair reaction sends (group/UUID targets + CLI author flags). (#1651) Thanks @vilkasdev.
|
||||||
|
- Signal: add configurable signal-cli startup timeout + external daemon mode docs. (#1677) https://docs.clawd.bot/channels/signal
|
||||||
|
- Telegram: set fetch duplex="half" for uploads on Node 22 to avoid sendPhoto failures. (#1684) Thanks @commdata2338.
|
||||||
|
- Telegram: use wrapped fetch for long-polling on Node to normalize AbortSignal handling. (#1639)
|
||||||
|
- Telegram: honor per-account proxy for outbound API calls. (#1774) Thanks @radek-paclt.
|
||||||
|
- Telegram: fall back to text when voice notes are blocked by privacy settings. (#1725) Thanks @foeken.
|
||||||
|
- Voice Call: return stream TwiML for outbound conversation calls on initial Twilio webhook. (#1634)
|
||||||
|
- Voice Call: serialize Twilio TTS playback and cancel on barge-in to prevent overlap. (#1713) Thanks @dguido.
|
||||||
|
- Google Chat: tighten email allowlist matching, typing cleanup, media caps, and onboarding/docs/tests. (#1635) Thanks @iHildy.
|
||||||
|
- Google Chat: normalize space targets without double `spaces/` prefix.
|
||||||
|
- Agents: auto-compact on context overflow prompt errors before failing. (#1627) Thanks @rodrigouroz.
|
||||||
|
- Agents: use the active auth profile for auto-compaction recovery.
|
||||||
|
- Media understanding: skip image understanding when the primary model already supports vision. (#1747) Thanks @tyler6204.
|
||||||
|
- Models: default missing custom provider fields so minimal configs are accepted.
|
||||||
|
- Messaging: keep newline chunking safe for fenced markdown blocks across channels.
|
||||||
|
- Messaging: treat newline chunking as paragraph-aware (blank-line splits) to keep lists and headings together. (#1726) Thanks @tyler6204.
|
||||||
|
- TUI: reload history after gateway reconnect to restore session state. (#1663)
|
||||||
|
- Heartbeat: normalize target identifiers for consistent routing.
|
||||||
|
- Exec: keep approvals for elevated ask unless full mode. (#1616) Thanks @ivancasco.
|
||||||
|
- Exec: treat Windows platform labels as Windows for node shell selection. (#1760) Thanks @ymat19.
|
||||||
|
- Gateway: include inline config env vars in service install environments. (#1735) Thanks @Seredeep.
|
||||||
|
- Gateway: skip Tailscale DNS probing when tailscale.mode is off. (#1671)
|
||||||
|
- Gateway: reduce log noise for late invokes + remote node probes; debounce skills refresh. (#1607) Thanks @petter-b.
|
||||||
|
- Gateway: clarify Control UI/WebChat auth error hints for missing tokens. (#1690)
|
||||||
|
- Gateway: listen on IPv6 loopback when bound to 127.0.0.1 so localhost webhooks work.
|
||||||
|
- Gateway: store lock files in the temp directory to avoid stale locks on persistent volumes. (#1676)
|
||||||
|
- macOS: default direct-transport `ws://` URLs to port 18789; document `gateway.remote.transport`. (#1603) Thanks @ngutman.
|
||||||
|
- Tests: cap Vitest workers on CI macOS to reduce timeouts. (#1597) Thanks @rohannagpal.
|
||||||
|
- Tests: avoid fake-timer dependency in embedded runner stream mock to reduce CI flakes. (#1597) Thanks @rohannagpal.
|
||||||
|
- Tests: increase embedded runner ordering test timeout to reduce CI flakes. (#1597) Thanks @rohannagpal.
|
||||||
|
|
||||||
|
## 2026.1.23-1
|
||||||
|
|
||||||
|
### Fixes
|
||||||
|
- Packaging: include dist/tts output in npm tarball (fixes missing dist/tts/tts.js).
|
||||||
|
|
||||||
|
## 2026.1.23
|
||||||
|
|
||||||
|
### Highlights
|
||||||
|
- TTS: move Telegram TTS into core + enable model-driven TTS tags by default for expressive audio replies. (#1559) Thanks @Glucksberg. https://docs.clawd.bot/tts
|
||||||
|
- Gateway: add `/tools/invoke` HTTP endpoint for direct tool calls (auth + tool policy enforced). (#1575) Thanks @vignesh07. https://docs.clawd.bot/gateway/tools-invoke-http-api
|
||||||
|
- Heartbeat: per-channel visibility controls (OK/alerts/indicator). (#1452) Thanks @dlauer. https://docs.clawd.bot/gateway/heartbeat
|
||||||
|
- Deploy: add Fly.io deployment support + guide. (#1570) https://docs.clawd.bot/platforms/fly
|
||||||
|
- Channels: add Tlon/Urbit channel plugin (DMs, group mentions, thread replies). (#1544) Thanks @wca4a. https://docs.clawd.bot/channels/tlon
|
||||||
|
|
||||||
|
### Changes
|
||||||
|
- Channels: allow per-group tool allow/deny policies across built-in + plugin channels. (#1546) Thanks @adam91holt. https://docs.clawd.bot/multi-agent-sandbox-tools
|
||||||
|
- Agents: add Bedrock auto-discovery defaults + config overrides. (#1553) Thanks @fal3. https://docs.clawd.bot/bedrock
|
||||||
|
- CLI: add `clawdbot system` for system events + heartbeat controls; remove standalone `wake`. (commit 71203829d) https://docs.clawd.bot/cli/system
|
||||||
|
- CLI: add live auth probes to `clawdbot models status` for per-profile verification. (commit 40181afde) https://docs.clawd.bot/cli/models
|
||||||
|
- CLI: restart the gateway by default after `clawdbot update`; add `--no-restart` to skip it. (commit 2c85b1b40)
|
||||||
|
- Browser: add node-host proxy auto-routing for remote gateways (configurable per gateway/node). (commit c3cb26f7c)
|
||||||
|
- Plugins: add optional `llm-task` JSON-only tool for workflows. (#1498) Thanks @vignesh07. https://docs.clawd.bot/tools/llm-task
|
||||||
|
- Markdown: add per-channel table conversion (bullets for Signal/WhatsApp, code blocks elsewhere). (#1495) Thanks @odysseus0.
|
||||||
|
- Agents: keep system prompt time zone-only and move current time to `session_status` for better cache hits. (commit 66eec295b)
|
||||||
|
- Agents: remove redundant bash tool alias from tool registration/display. (#1571) Thanks @Takhoffman.
|
||||||
|
- Docs: add cron vs heartbeat decision guide (with Lobster workflow notes). (#1533) Thanks @JustYannicc. https://docs.clawd.bot/automation/cron-vs-heartbeat
|
||||||
|
- Docs: clarify HEARTBEAT.md empty file skips heartbeats, missing file still runs. (#1535) Thanks @JustYannicc. https://docs.clawd.bot/gateway/heartbeat
|
||||||
|
|
||||||
|
### Fixes
|
||||||
|
- Sessions: accept non-UUID sessionIds for history/send/status while preserving agent scoping. (#1518)
|
||||||
|
- Heartbeat: accept plugin channel ids for heartbeat target validation + UI hints.
|
||||||
|
- Messaging/Sessions: mirror outbound sends into target session keys (threads + dmScope), create session entries on send, and normalize session key casing. (#1520, commit 4b6cdd1d3)
|
||||||
|
- Sessions: reject array-backed session stores to prevent silent wipes. (#1469)
|
||||||
|
- Gateway: compare Linux process start time to avoid PID recycling lock loops; keep locks unless stale. (#1572) Thanks @steipete.
|
||||||
|
- Gateway: accept null optional fields in exec approval requests. (#1511) Thanks @pvoo.
|
||||||
|
- Exec approvals: persist allowlist entry ids to keep macOS allowlist rows stable. (#1521) Thanks @ngutman.
|
||||||
|
- Exec: honor tools.exec ask/security defaults for elevated approvals (avoid unwanted prompts). (commit 5662a9cdf)
|
||||||
|
- Daemon: use platform PATH delimiters when building minimal service paths. (commit a4e57d3ac)
|
||||||
|
- Linux: include env-configured user bin roots in systemd PATH and align PATH audits. (#1512) Thanks @robbyczgw-cla.
|
||||||
|
- Tailscale: retry serve/funnel with sudo only for permission errors and keep original failure details. (#1551) Thanks @sweepies.
|
||||||
|
- Docker: update gateway command in docker-compose and Hetzner guide. (#1514)
|
||||||
|
- Agents: show tool error fallback when the last assistant turn only invoked tools (prevents silent stops). (commit 8ea8801d0)
|
||||||
|
- Agents: ignore IDENTITY.md template placeholders when parsing identity. (#1556)
|
||||||
|
- Agents: drop orphaned OpenAI Responses reasoning blocks on model switches. (#1562) Thanks @roshanasingh4.
|
||||||
|
- Agents: add CLI log hint to "agent failed before reply" messages. (#1550) Thanks @sweepies.
|
||||||
|
- Agents: warn and ignore tool allowlists that only reference unknown or unloaded plugin tools. (#1566)
|
||||||
|
- Agents: treat plugin-only tool allowlists as opt-ins; keep core tools enabled. (#1467)
|
||||||
|
- Agents: honor enqueue overrides for embedded runs to avoid queue deadlocks in tests. (commit 084002998)
|
||||||
|
- Slack: honor open groupPolicy for unlisted channels in message + slash gating. (#1563) Thanks @itsjaydesu.
|
||||||
|
- Discord: limit autoThread mention bypass to bot-owned threads; keep ack reactions mention-gated. (#1511) Thanks @pvoo.
|
||||||
|
- Discord: retry rate-limited allowlist resolution + command deploy to avoid gateway crashes. (commit f70ac0c7c)
|
||||||
|
- Mentions: ignore mentionPattern matches when another explicit mention is present in group chats (Slack/Discord/Telegram/WhatsApp). (commit d905ca0e0)
|
||||||
|
- Telegram: render markdown in media captions. (#1478)
|
||||||
|
- MS Teams: remove `.default` suffix from Graph scopes and Bot Framework probe scopes. (#1507, #1574) Thanks @Evizero.
|
||||||
|
- Browser: keep extension relay tabs controllable when the extension reuses a session id after switching tabs. (#1160)
|
||||||
|
- Voice wake: auto-save wake words on blur/submit across iOS/Android and align limits with macOS. (commit 69f645c66)
|
||||||
|
- UI: keep the Control UI sidebar visible while scrolling long pages. (#1515) Thanks @pookNast.
|
||||||
|
- UI: cache Control UI markdown rendering + memoize chat text extraction to reduce Safari typing jank. (commit d57cb2e1a)
|
||||||
|
- TUI: forward unknown slash commands, include Gateway commands in autocomplete, and render slash replies as system output. (commit 1af227b61, commit 8195497ce, commit 6fba598ea)
|
||||||
|
- CLI: auth probe output polish (table output, inline errors, reduced noise, and wrap fixes in `clawdbot models status`). (commit da3f2b489, commit 00ae21bed, commit 31e59cd58, commit f7dc27f2d, commit 438e782f8, commit 886752217, commit aabe0bed3, commit 81535d512, commit c63144ab1)
|
||||||
|
- Media: only parse `MEDIA:` tags when they start the line to avoid stripping prose mentions. (#1206)
|
||||||
|
- Media: preserve PNG alpha when possible; fall back to JPEG when still over size cap. (#1491) Thanks @robbyczgw-cla.
|
||||||
|
- Skills: gate bird Homebrew install to macOS. (#1569) Thanks @bradleypriest.
|
||||||
|
|
||||||
## 2026.1.22
|
## 2026.1.22
|
||||||
|
|
||||||
### Changes
|
### Changes
|
||||||
@ -35,6 +247,7 @@ Docs: https://docs.clawd.bot
|
|||||||
- Docs: fix gog auth services example to include docs scope. (#1454) Thanks @zerone0x.
|
- Docs: fix gog auth services example to include docs scope. (#1454) Thanks @zerone0x.
|
||||||
- Slack: reduce WebClient retries to avoid duplicate sends. (#1481)
|
- Slack: reduce WebClient retries to avoid duplicate sends. (#1481)
|
||||||
- Slack: read thread replies for message reads when threadId is provided (replies-only). (#1450) Thanks @rodrigouroz.
|
- Slack: read thread replies for message reads when threadId is provided (replies-only). (#1450) Thanks @rodrigouroz.
|
||||||
|
- Discord: honor accountId across message actions and cron deliveries. (#1492) Thanks @svkozak.
|
||||||
- macOS: prefer linked channels in gateway summary to avoid false “not linked” status.
|
- macOS: prefer linked channels in gateway summary to avoid false “not linked” status.
|
||||||
- macOS/tests: fix gateway summary lookup after guard unwrap; prevent browser opens during tests. (ECID-1483)
|
- macOS/tests: fix gateway summary lookup after guard unwrap; prevent browser opens during tests. (ECID-1483)
|
||||||
|
|
||||||
|
|||||||
@ -40,3 +40,13 @@ Please include in your PR:
|
|||||||
- [ ] Confirm you understand what the code does
|
- [ ] Confirm you understand what the code does
|
||||||
|
|
||||||
AI PRs are first-class citizens here. We just want transparency so reviewers know what to look for.
|
AI PRs are first-class citizens here. We just want transparency so reviewers know what to look for.
|
||||||
|
|
||||||
|
## Current Focus & Roadmap 🗺
|
||||||
|
|
||||||
|
We are currently prioritizing:
|
||||||
|
- **Stability**: Fixing edge cases in channel connections (WhatsApp/Telegram).
|
||||||
|
- **UX**: Improving the onboarding wizard and error messages.
|
||||||
|
- **Skills**: Expanding the library of bundled skills and improving the Skill Creation developer experience.
|
||||||
|
- **Performance**: Optimizing token usage and compaction logic.
|
||||||
|
|
||||||
|
Check the [GitHub Issues](https://github.com/clawdbot/clawdbot/issues) for "good first issue" labels!
|
||||||
|
|||||||
@ -32,4 +32,9 @@ RUN pnpm ui:build
|
|||||||
|
|
||||||
ENV NODE_ENV=production
|
ENV NODE_ENV=production
|
||||||
|
|
||||||
|
# Security hardening: Run as non-root user
|
||||||
|
# The node:22-bookworm image includes a 'node' user (uid 1000)
|
||||||
|
# This reduces the attack surface by preventing container escape via root privileges
|
||||||
|
USER node
|
||||||
|
|
||||||
CMD ["node", "dist/index.js"]
|
CMD ["node", "dist/index.js"]
|
||||||
|
|||||||
44
SECURITY.md
44
SECURITY.md
@ -1,6 +1,6 @@
|
|||||||
# Security Policy
|
# Security Policy
|
||||||
|
|
||||||
If you believe you’ve found a security issue in Clawdbot, please report it privately.
|
If you believe you've found a security issue in Clawdbot, please report it privately.
|
||||||
|
|
||||||
## Reporting
|
## Reporting
|
||||||
|
|
||||||
@ -13,3 +13,45 @@ For threat model + hardening guidance (including `clawdbot security audit --deep
|
|||||||
|
|
||||||
- `https://docs.clawd.bot/gateway/security`
|
- `https://docs.clawd.bot/gateway/security`
|
||||||
|
|
||||||
|
## Runtime Requirements
|
||||||
|
|
||||||
|
### Node.js Version
|
||||||
|
|
||||||
|
Clawdbot requires **Node.js 22.12.0 or later** (LTS). This version includes important security patches:
|
||||||
|
|
||||||
|
- CVE-2025-59466: async_hooks DoS vulnerability
|
||||||
|
- CVE-2026-21636: Permission model bypass vulnerability
|
||||||
|
|
||||||
|
Verify your Node.js version:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
node --version # Should be v22.12.0 or later
|
||||||
|
```
|
||||||
|
|
||||||
|
### Docker Security
|
||||||
|
|
||||||
|
When running Clawdbot in Docker:
|
||||||
|
|
||||||
|
1. The official image runs as a non-root user (`node`) for reduced attack surface
|
||||||
|
2. Use `--read-only` flag when possible for additional filesystem protection
|
||||||
|
3. Limit container capabilities with `--cap-drop=ALL`
|
||||||
|
|
||||||
|
Example secure Docker run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker run --read-only --cap-drop=ALL \
|
||||||
|
-v clawdbot-data:/app/data \
|
||||||
|
clawdbot/clawdbot:latest
|
||||||
|
```
|
||||||
|
|
||||||
|
## Security Scanning
|
||||||
|
|
||||||
|
This project uses `detect-secrets` for automated secret detection in CI/CD.
|
||||||
|
See `.detect-secrets.cfg` for configuration and `.secrets.baseline` for the baseline.
|
||||||
|
|
||||||
|
Run locally:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pip install detect-secrets==1.5.0
|
||||||
|
detect-secrets scan --baseline .secrets.baseline
|
||||||
|
```
|
||||||
|
|||||||
408
appcast.xml
408
appcast.xml
@ -3,284 +3,186 @@
|
|||||||
<channel>
|
<channel>
|
||||||
<title>Clawdbot</title>
|
<title>Clawdbot</title>
|
||||||
<item>
|
<item>
|
||||||
<title>2026.1.21</title>
|
<title>2026.1.24-1</title>
|
||||||
<pubDate>Thu, 22 Jan 2026 12:22:35 +0000</pubDate>
|
<pubDate>Sun, 25 Jan 2026 14:05:25 +0000</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>7374</sparkle:version>
|
<sparkle:version>7952</sparkle:version>
|
||||||
<sparkle:shortVersionString>2026.1.21</sparkle:shortVersionString>
|
<sparkle:shortVersionString>2026.1.24-1</sparkle:shortVersionString>
|
||||||
<sparkle:minimumSystemVersion>15.0</sparkle:minimumSystemVersion>
|
<sparkle:minimumSystemVersion>15.0</sparkle:minimumSystemVersion>
|
||||||
<description><![CDATA[<h2>Clawdbot 2026.1.21</h2>
|
<description><![CDATA[<h2>Clawdbot 2026.1.24-1</h2>
|
||||||
|
<h3>Fixes</h3>
|
||||||
|
<ul>
|
||||||
|
<li>Packaging: include dist/shared output in npm tarball (fixes missing reasoning-tags import on install).</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.24-1/Clawdbot-2026.1.24-1.zip" length="12396699" type="application/octet-stream" sparkle:edSignature="VaEdWIgEJBrZLIp2UmigoQ6vaq4P/jNFXpHYXvXHD5MsATS0CqBl6ugyyxRq+/GbpUqmdgdlht4dTUVbLRw6BA=="/>
|
||||||
|
</item>
|
||||||
|
<item>
|
||||||
|
<title>2026.1.24</title>
|
||||||
|
<pubDate>Sun, 25 Jan 2026 13:31:05 +0000</pubDate>
|
||||||
|
<link>https://raw.githubusercontent.com/clawdbot/clawdbot/main/appcast.xml</link>
|
||||||
|
<sparkle:version>7944</sparkle:version>
|
||||||
|
<sparkle:shortVersionString>2026.1.24</sparkle:shortVersionString>
|
||||||
|
<sparkle:minimumSystemVersion>15.0</sparkle:minimumSystemVersion>
|
||||||
|
<description><![CDATA[<h2>Clawdbot 2026.1.24</h2>
|
||||||
<h3>Highlights</h3>
|
<h3>Highlights</h3>
|
||||||
<ul>
|
<ul>
|
||||||
<li>Lobster optional plugin tool for typed workflows + approval gates. https://docs.clawd.bot/tools/lobster</li>
|
<li>Providers: Ollama discovery + docs; Venice guide upgrades + cross-links. (#1606) Thanks @abhaymundhara. https://docs.clawd.bot/providers/ollama https://docs.clawd.bot/providers/venice</li>
|
||||||
<li>Custom assistant identity + avatars in the Control UI. https://docs.clawd.bot/cli/agents https://docs.clawd.bot/web/control-ui</li>
|
<li>Channels: LINE plugin (Messaging API) with rich replies + quick replies. (#1630) Thanks @plum-dawg.</li>
|
||||||
<li>Cache optimizations: cache-ttl pruning + defaults reduce token spend on cold requests. https://docs.clawd.bot/concepts/session-pruning</li>
|
<li>TTS: Edge fallback (keyless) + <code>/tts</code> auto modes. (#1668, #1667) Thanks @steipete, @sebslight. https://docs.clawd.bot/tts</li>
|
||||||
<li>Exec approvals + elevated ask/full modes. https://docs.clawd.bot/tools/exec-approvals https://docs.clawd.bot/tools/elevated</li>
|
<li>Exec approvals: approve in-chat via <code>/approve</code> across all channels (including plugins). (#1621) Thanks @czekaj. https://docs.clawd.bot/tools/exec-approvals https://docs.clawd.bot/tools/slash-commands</li>
|
||||||
<li>Signal typing/read receipts + MSTeams attachments. https://docs.clawd.bot/channels/signal https://docs.clawd.bot/channels/msteams</li>
|
<li>Telegram: DM topics as separate sessions + outbound link preview toggle. (#1597, #1700) Thanks @rohannagpal, @zerone0x. https://docs.clawd.bot/channels/telegram</li>
|
||||||
<li><code>/models</code> UX refresh + <code>clawdbot update wizard</code>. https://docs.clawd.bot/cli/models https://docs.clawd.bot/cli/update</li>
|
|
||||||
</ul>
|
</ul>
|
||||||
<h3>Changes</h3>
|
<h3>Changes</h3>
|
||||||
<ul>
|
<ul>
|
||||||
<li>Highlight: Lobster optional plugin tool for typed workflows + approval gates. https://docs.clawd.bot/tools/lobster (#1152) Thanks @vignesh07.</li>
|
<li>Channels: add LINE plugin (Messaging API) with rich replies, quick replies, and plugin HTTP registry. (#1630) Thanks @plum-dawg.</li>
|
||||||
<li>Agents/UI: add identity avatar config support and Control UI avatar rendering. (#1329, #1424) Thanks @dlauer. https://docs.clawd.bot/gateway/configuration https://docs.clawd.bot/cli/agents</li>
|
<li>TTS: add Edge TTS provider fallback, defaulting to keyless Edge with MP3 retry on format failures. (#1668) Thanks @steipete. https://docs.clawd.bot/tts</li>
|
||||||
<li>Control UI: add custom assistant identity support and per-session identity display. (#1420) Thanks @robbyczgw-cla. https://docs.clawd.bot/web/control-ui</li>
|
<li>TTS: add auto mode enum (off/always/inbound/tagged) with per-session <code>/tts</code> override. (#1667) Thanks @sebslight. https://docs.clawd.bot/tts</li>
|
||||||
<li>CLI: add <code>clawdbot update wizard</code> with interactive channel selection + restart prompts, plus preflight checks before rebasing. https://docs.clawd.bot/cli/update</li>
|
<li>Telegram: treat DM topics as separate sessions and keep DM history limits stable with thread suffixes. (#1597) Thanks @rohannagpal.</li>
|
||||||
<li>Models/Commands: add <code>/models</code>, improve <code>/model</code> listing UX, and expand <code>clawdbot models</code> paging. (#1398) Thanks @vignesh07. https://docs.clawd.bot/cli/models</li>
|
<li>Telegram: add <code>channels.telegram.linkPreview</code> to toggle outbound link previews. (#1700) Thanks @zerone0x. https://docs.clawd.bot/channels/telegram</li>
|
||||||
<li>CLI: move gateway service commands under <code>clawdbot gateway</code>, flatten node service commands under <code>clawdbot node</code>, and add <code>gateway probe</code> for reachability. https://docs.clawd.bot/cli/gateway https://docs.clawd.bot/cli/node</li>
|
<li>Web search: add Brave freshness filter parameter for time-scoped results. (#1688) Thanks @JonUleis. https://docs.clawd.bot/tools/web</li>
|
||||||
<li>Exec: add elevated ask/full modes, tighten allowlist gating, and render approvals tables on write. https://docs.clawd.bot/tools/elevated https://docs.clawd.bot/tools/exec-approvals</li>
|
<li>UI: refresh Control UI dashboard design system (typography, colors, spacing). (#1786) Thanks @mousberg.</li>
|
||||||
<li>Exec approvals: default to local host, add gateway/node targeting + target details, support wildcard agent allowlists, and tighten allowlist parsing/safe bins. https://docs.clawd.bot/cli/approvals https://docs.clawd.bot/tools/exec-approvals</li>
|
<li>Exec approvals: forward approval prompts to chat with <code>/approve</code> for all channels (including plugins). (#1621) Thanks @czekaj. https://docs.clawd.bot/tools/exec-approvals https://docs.clawd.bot/tools/slash-commands</li>
|
||||||
<li>Heartbeat: allow explicit session keys and active hours. (#1256) Thanks @zknicker. https://docs.clawd.bot/gateway/heartbeat</li>
|
<li>Gateway: expose config.patch in the gateway tool with safe partial updates + restart sentinel. (#1653) Thanks @Glucksberg.</li>
|
||||||
<li>Sessions: add per-channel idle durations via <code>sessions.channelIdleMinutes</code>. (#1353) Thanks @cash-echo-bot.</li>
|
<li>Diagnostics: add diagnostic flags for targeted debug logs (config + env override). https://docs.clawd.bot/diagnostics/flags</li>
|
||||||
<li>Nodes: run exec-style, expose PATH in status/describe, and bootstrap PATH for node-host execution. https://docs.clawd.bot/cli/node</li>
|
<li>Docs: expand FAQ (migration, scheduling, concurrency, model recommendations, OpenAI subscription auth, Pi sizing, hackable install, docs SSL workaround).</li>
|
||||||
<li>Cache: add <code>cache.ttlPrune</code> mode and auth-aware defaults for cache TTL behavior.</li>
|
<li>Docs: add verbose installer troubleshooting guidance.</li>
|
||||||
<li>Queue: add per-channel debounce overrides for auto-reply. https://docs.clawd.bot/concepts/queue</li>
|
<li>Docs: add macOS VM guide with local/hosted options + VPS/nodes guidance. (#1693) Thanks @f-trycua.</li>
|
||||||
<li>Discord: add wildcard channel config support. (#1334) Thanks @pvoo. https://docs.clawd.bot/channels/discord</li>
|
<li>Docs: add Bedrock EC2 instance role setup + IAM steps. (#1625) Thanks @sergical. https://docs.clawd.bot/bedrock</li>
|
||||||
<li>Signal: add typing indicators and DM read receipts via signal-cli. https://docs.clawd.bot/channels/signal</li>
|
<li>Docs: update Fly.io guide notes.</li>
|
||||||
<li>MSTeams: add file uploads, adaptive cards, and attachment handling improvements. (#1410) Thanks @Evizero. https://docs.clawd.bot/channels/msteams</li>
|
<li>Dev: add prek pre-commit hooks + dependabot config for weekly updates. (#1720) Thanks @dguido.</li>
|
||||||
<li>Onboarding: remove the run setup-token auth option (paste setup-token or reuse CLI creds instead).</li>
|
|
||||||
<li>macOS: refresh Settings (location access in Permissions, connection mode in menu, remove CLI install UI).</li>
|
|
||||||
<li>Diagnostics: add cache trace config for debugging. (#1370) Thanks @parubets.</li>
|
|
||||||
<li>Docs: Lobster guides + org URL updates, /model allowlist troubleshooting, Gmail message search examples, gateway.mode troubleshooting, prompt injection guidance, npm prefix/node CLI notes, control UI dev gatewayUrl note, tool_use FAQ, showcase video, and sharp/node-gyp workaround. (#1427, #1220, #1405) Thanks @vignesh07, @mbelinky.</li>
|
|
||||||
</ul>
|
|
||||||
<h3>Breaking</h3>
|
|
||||||
<ul>
|
|
||||||
<li><strong>BREAKING:</strong> Control UI now rejects insecure HTTP without device identity by default. Use HTTPS (Tailscale Serve) or set <code>gateway.controlUi.allowInsecureAuth: true</code> to allow token-only auth. https://docs.clawd.bot/web/control-ui#insecure-http</li>
|
|
||||||
<li><strong>BREAKING:</strong> Envelope and system event timestamps now default to host-local time (was UTC) so agents don’t have to constantly convert.</li>
|
|
||||||
</ul>
|
</ul>
|
||||||
<h3>Fixes</h3>
|
<h3>Fixes</h3>
|
||||||
<ul>
|
<ul>
|
||||||
<li>Streaming/Typing/Media: keep reply tags across streamed chunks, start typing indicators at run start, and accept MEDIA paths with spaces/tilde while preferring the message tool hint for image replies.</li>
|
<li>Web UI: fix config/debug layout overflow, scrolling, and code block sizing. (#1715) Thanks @saipreetham589.</li>
|
||||||
<li>Agents/Providers: drop unsigned thinking blocks for Claude models (Google Antigravity) and enforce alphanumeric tool call ids for strict providers (Mistral/OpenRouter). (#1372) Thanks @zerone0x.</li>
|
<li>Web UI: show Stop button during active runs, swap back to New session when idle. (#1664) Thanks @ndbroadbent.</li>
|
||||||
<li>Exec approvals: treat main as the default agent, align node/gateway allowlist prechecks, validate resolved paths, avoid allowlist resolve races, and avoid null optional params. (#1417, #1414, #1425) Thanks @czekaj.</li>
|
<li>Web UI: clear stale disconnect banners on reconnect; allow form saves with unsupported schema paths but block missing schema. (#1707) Thanks @Glucksberg.</li>
|
||||||
<li>Exec/Windows: resolve Windows exec paths with extensions and handle safe-bin exe names.</li>
|
<li>Web UI: hide internal <code>message_id</code> hints in chat bubbles.</li>
|
||||||
<li>Nodes/macOS: prompt on allowlist miss for node exec approvals, persist allowlist decisions, and flatten node invoke errors. (#1394) Thanks @ngutman.</li>
|
<li>Gateway: allow Control UI token-only auth to skip device pairing even when device identity is present (<code>gateway.controlUi.allowInsecureAuth</code>). (#1679) Thanks @steipete.</li>
|
||||||
<li>Gateway: prevent multiple gateways from sharing the same config/state (singleton lock), keep auto bind loopback-first with explicit tailnet binding, and improve SSH auth handling. (#1380)</li>
|
<li>Matrix: decrypt E2EE media attachments with preflight size guard. (#1744) Thanks @araa47.</li>
|
||||||
<li>Control UI: remove the chat stop button, keep the composer aligned to the bottom edge, stabilize session previews, and refresh the debug panel on route-driven tab changes. (#1373) Thanks @yazinsai.</li>
|
<li>BlueBubbles: route phone-number targets to DMs, avoid leaking routing IDs, and auto-create missing DMs (Private API required). (#1751) Thanks @tyler6204. https://docs.clawd.bot/channels/bluebubbles</li>
|
||||||
<li>UI/config: export <code>SECTION_META</code> for config form modules. (#1418) Thanks @MaudeBot.</li>
|
<li>BlueBubbles: keep part-index GUIDs in reply tags when short IDs are missing.</li>
|
||||||
<li>macOS: keep chat pinned during streaming replies, include Textual resources, respect wildcard exec approvals, allow SSH agent auth, and default distribution builds to universal binaries. (#1279, #1362, #1384, #1396) Thanks @ameno-, @JustYannicc.</li>
|
<li>Signal: repair reaction sends (group/UUID targets + CLI author flags). (#1651) Thanks @vilkasdev.</li>
|
||||||
<li>BlueBubbles: resolve short message IDs safely, expose full IDs in templates, and harden short-id fetch wrappers. (#1369, #1387) Thanks @tyler6204.</li>
|
<li>Signal: add configurable signal-cli startup timeout + external daemon mode docs. (#1677) https://docs.clawd.bot/channels/signal</li>
|
||||||
<li>Models/Configure: inherit session model overrides in threads/topics, map OpenCode Zen models to the correct APIs, narrow Anthropic OAuth allowlist handling, seed allowlist fallbacks, list the full catalog when no allowlist is set, and limit <code>/model</code> list output. (#1376, #1416)</li>
|
<li>Telegram: set fetch duplex="half" for uploads on Node 22 to avoid sendPhoto failures. (#1684) Thanks @commdata2338.</li>
|
||||||
<li>Memory: prevent CLI hangs by deferring vector probes, add sqlite-vec/embedding timeouts, and make session memory indexing async.</li>
|
<li>Telegram: use wrapped fetch for long-polling on Node to normalize AbortSignal handling. (#1639)</li>
|
||||||
<li>Cron: cap reminder context history to 10 messages and honor <code>contextMessages</code>. (#1103) Thanks @mkbehr.</li>
|
<li>Telegram: honor per-account proxy for outbound API calls. (#1774) Thanks @radek-paclt.</li>
|
||||||
<li>Cache: restore the 1h cache TTL option and reset the pruning window.</li>
|
<li>Telegram: fall back to text when voice notes are blocked by privacy settings. (#1725) Thanks @foeken.</li>
|
||||||
<li>Zalo Personal: tolerate ANSI/log-prefixed JSON output from <code>zca</code>. (#1379) Thanks @ptn1411.</li>
|
<li>Voice Call: return stream TwiML for outbound conversation calls on initial Twilio webhook. (#1634)</li>
|
||||||
<li>Browser: suppress Chrome restore prompts for managed profiles. (#1419) Thanks @jamesgroat.</li>
|
<li>Voice Call: serialize Twilio TTS playback and cancel on barge-in to prevent overlap. (#1713) Thanks @dguido.</li>
|
||||||
<li>Infra: preserve fetch helper methods/preconnect when wrapping abort signals and normalize Telegram fetch aborts.</li>
|
<li>Google Chat: tighten email allowlist matching, typing cleanup, media caps, and onboarding/docs/tests. (#1635) Thanks @iHildy.</li>
|
||||||
<li>Config/Doctor: avoid stack traces for invalid configs, log the config path, avoid WhatsApp config resurrection, and warn when <code>gateway.mode</code> is unset. (#900)</li>
|
<li>Google Chat: normalize space targets without double <code>spaces/</code> prefix.</li>
|
||||||
<li>CLI: read Codex CLI account_id for workspace billing. (#1422) Thanks @aj47.</li>
|
<li>Agents: auto-compact on context overflow prompt errors before failing. (#1627) Thanks @rodrigouroz.</li>
|
||||||
<li>Logs/Status: align rolling log filenames with local time and report sandboxed runtime in <code>clawdbot status</code>. (#1343)</li>
|
<li>Agents: use the active auth profile for auto-compaction recovery.</li>
|
||||||
<li>Embedded runner: persist injected history images so attachments aren’t reloaded each turn. (#1374) Thanks @Nicell.</li>
|
<li>Media understanding: skip image understanding when the primary model already supports vision. (#1747) Thanks @tyler6204.</li>
|
||||||
<li>Nodes/Subagents: include agent/node/gateway context in tool failure logs and ensure subagent list uses the command session.</li>
|
<li>Models: default missing custom provider fields so minimal configs are accepted.</li>
|
||||||
|
<li>Messaging: keep newline chunking safe for fenced markdown blocks across channels.</li>
|
||||||
|
<li>TUI: reload history after gateway reconnect to restore session state. (#1663)</li>
|
||||||
|
<li>Heartbeat: normalize target identifiers for consistent routing.</li>
|
||||||
|
<li>Exec: keep approvals for elevated ask unless full mode. (#1616) Thanks @ivancasco.</li>
|
||||||
|
<li>Exec: treat Windows platform labels as Windows for node shell selection. (#1760) Thanks @ymat19.</li>
|
||||||
|
<li>Gateway: include inline config env vars in service install environments. (#1735) Thanks @Seredeep.</li>
|
||||||
|
<li>Gateway: skip Tailscale DNS probing when tailscale.mode is off. (#1671)</li>
|
||||||
|
<li>Gateway: reduce log noise for late invokes + remote node probes; debounce skills refresh. (#1607) Thanks @petter-b.</li>
|
||||||
|
<li>Gateway: clarify Control UI/WebChat auth error hints for missing tokens. (#1690)</li>
|
||||||
|
<li>Gateway: listen on IPv6 loopback when bound to 127.0.0.1 so localhost webhooks work.</li>
|
||||||
|
<li>Gateway: store lock files in the temp directory to avoid stale locks on persistent volumes. (#1676)</li>
|
||||||
|
<li>macOS: default direct-transport <code>ws://</code> URLs to port 18789; document <code>gateway.remote.transport</code>. (#1603) Thanks @ngutman.</li>
|
||||||
|
<li>Tests: cap Vitest workers on CI macOS to reduce timeouts. (#1597) Thanks @rohannagpal.</li>
|
||||||
|
<li>Tests: avoid fake-timer dependency in embedded runner stream mock to reduce CI flakes. (#1597) Thanks @rohannagpal.</li>
|
||||||
|
<li>Tests: increase embedded runner ordering test timeout to reduce CI flakes. (#1597) Thanks @rohannagpal.</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.21/Clawdbot-2026.1.21.zip" length="22284796" type="application/octet-stream" sparkle:edSignature="pXji4NMA/cu35iMxln385d6LnsT4yIZtFtFiR7sIimKeSC2CsyeWzzSD0EhJsN98PdSoy69iEFZt4I2ZtNCECg=="/>
|
<enclosure url="https://github.com/clawdbot/clawdbot/releases/download/v2026.1.24/Clawdbot-2026.1.24.zip" length="12396700" type="application/octet-stream" sparkle:edSignature="u+XzKD3YwV8s79gIr7LK4OtDCcmp/b+cjNC6SHav3/1CVJegh02SsBKatrampox32XGx8P2+8c/+fHV+qpkHCA=="/>
|
||||||
</item>
|
</item>
|
||||||
<item>
|
<item>
|
||||||
<title>2026.1.21</title>
|
<title>2026.1.23</title>
|
||||||
<pubDate>Wed, 21 Jan 2026 08:18:22 +0000</pubDate>
|
<pubDate>Sat, 24 Jan 2026 13:02:18 +0000</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>7116</sparkle:version>
|
<sparkle:version>7750</sparkle:version>
|
||||||
<sparkle:shortVersionString>2026.1.21</sparkle:shortVersionString>
|
<sparkle:shortVersionString>2026.1.23</sparkle:shortVersionString>
|
||||||
<sparkle:minimumSystemVersion>15.0</sparkle:minimumSystemVersion>
|
<sparkle:minimumSystemVersion>15.0</sparkle:minimumSystemVersion>
|
||||||
<description><![CDATA[<h2>Clawdbot 2026.1.21</h2>
|
<description><![CDATA[<h2>Clawdbot 2026.1.23</h2>
|
||||||
|
<h3>Highlights</h3>
|
||||||
|
<ul>
|
||||||
|
<li>TTS: allow model-driven TTS tags by default for expressive audio replies (laughter, singing cues, etc.).</li>
|
||||||
|
</ul>
|
||||||
<h3>Changes</h3>
|
<h3>Changes</h3>
|
||||||
<ul>
|
<ul>
|
||||||
<li>Control UI: add copy-as-markdown with error feedback. (#1345) https://docs.clawd.bot/web/control-ui</li>
|
<li>Gateway: add /tools/invoke HTTP endpoint for direct tool calls and document it. (#1575) Thanks @vignesh07.</li>
|
||||||
<li>Control UI: drop the legacy list view. (#1345) https://docs.clawd.bot/web/control-ui</li>
|
<li>Agents: keep system prompt time zone-only and move current time to <code>session_status</code> for better cache hits.</li>
|
||||||
<li>TUI: add syntax highlighting for code blocks. (#1200) https://docs.clawd.bot/tui</li>
|
<li>Agents: remove redundant bash tool alias from tool registration/display. (#1571) Thanks @Takhoffman.</li>
|
||||||
<li>TUI: session picker shows derived titles, fuzzy search, relative times, and last message preview. (#1271) https://docs.clawd.bot/tui</li>
|
<li>Browser: add node-host proxy auto-routing for remote gateways (configurable per gateway/node).</li>
|
||||||
<li>TUI: add a searchable model picker for quicker model selection. (#1198) https://docs.clawd.bot/tui</li>
|
<li>Heartbeat: add per-channel visibility controls (OK/alerts/indicator). (#1452) Thanks @dlauer.</li>
|
||||||
<li>TUI: add input history (up/down) for submitted messages. (#1348) https://docs.clawd.bot/tui</li>
|
<li>Plugins: add optional llm-task JSON-only tool for workflows. (#1498) Thanks @vignesh07.</li>
|
||||||
<li>ACP: add <code>clawdbot acp</code> for IDE integrations. https://docs.clawd.bot/cli/acp</li>
|
<li>CLI: restart the gateway by default after <code>clawdbot update</code>; add <code>--no-restart</code> to skip it.</li>
|
||||||
<li>ACP: add <code>clawdbot acp client</code> interactive harness for debugging. https://docs.clawd.bot/cli/acp</li>
|
<li>CLI: add live auth probes to <code>clawdbot models status</code> for per-profile verification.</li>
|
||||||
<li>Skills: add download installs with OS-filtered options. https://docs.clawd.bot/tools/skills</li>
|
<li>CLI: add <code>clawdbot system</code> for system events + heartbeat controls; remove standalone <code>wake</code>.</li>
|
||||||
<li>Skills: add the local sherpa-onnx-tts skill. https://docs.clawd.bot/tools/skills</li>
|
<li>Agents: add Bedrock auto-discovery defaults + config overrides. (#1553) Thanks @fal3.</li>
|
||||||
<li>Memory: add hybrid BM25 + vector search (FTS5) with weighted merging and fallback. https://docs.clawd.bot/concepts/memory</li>
|
<li>Docs: add cron vs heartbeat decision guide (with Lobster workflow notes). (#1533) Thanks @JustYannicc.</li>
|
||||||
<li>Memory: add SQLite embedding cache to speed up reindexing and frequent updates. https://docs.clawd.bot/concepts/memory</li>
|
<li>Docs: clarify HEARTBEAT.md empty file skips heartbeats, missing file still runs. (#1535) Thanks @JustYannicc.</li>
|
||||||
<li>Memory: add OpenAI batch indexing for embeddings when configured. https://docs.clawd.bot/concepts/memory</li>
|
<li>Markdown: add per-channel table conversion (bullets for Signal/WhatsApp, code blocks elsewhere). (#1495) Thanks @odysseus0.</li>
|
||||||
<li>Memory: enable OpenAI batch indexing by default for OpenAI embeddings. https://docs.clawd.bot/concepts/memory</li>
|
<li>Tlon: add Urbit channel plugin (DMs, group mentions, thread replies). (#1544) Thanks @wca4a.</li>
|
||||||
<li>Memory: allow parallel OpenAI batch indexing jobs (default concurrency: 2). https://docs.clawd.bot/concepts/memory</li>
|
<li>Channels: allow per-group tool allow/deny policies across built-in + plugin channels. (#1546) Thanks @adam91holt.</li>
|
||||||
<li>Memory: render progress immediately, color batch statuses in verbose logs, and poll OpenAI batch status every 2s by default. https://docs.clawd.bot/concepts/memory</li>
|
<li>TTS: move Telegram TTS into core with auto-replies, commands, and gateway methods. (#1559) Thanks @Glucksberg.</li>
|
||||||
<li>Memory: add <code>--verbose</code> logging for memory status + batch indexing details. https://docs.clawd.bot/concepts/memory</li>
|
|
||||||
<li>Memory: add native Gemini embeddings provider for memory search. (#1151) https://docs.clawd.bot/concepts/memory</li>
|
|
||||||
<li>Browser: allow config defaults for efficient snapshots in the tool/CLI. (#1336) https://docs.clawd.bot/tools/browser</li>
|
|
||||||
<li>Nostr: add the Nostr channel plugin with profile management + onboarding defaults. (#1323) https://docs.clawd.bot/channels/nostr</li>
|
|
||||||
<li>Matrix: migrate to matrix-bot-sdk with E2EE support, location handling, and group allowlist upgrades. (#1298) https://docs.clawd.bot/channels/matrix</li>
|
|
||||||
<li>Slack: add HTTP webhook mode via Bolt HTTP receiver. (#1143) https://docs.clawd.bot/channels/slack</li>
|
|
||||||
<li>Telegram: enrich forwarded-message context with normalized origin details + legacy fallback. (#1090) https://docs.clawd.bot/channels/telegram</li>
|
|
||||||
<li>Discord: fall back to <code>/skill</code> when native command limits are exceeded. (#1287)</li>
|
|
||||||
<li>Discord: expose <code>/skill</code> globally. (#1287)</li>
|
|
||||||
<li>Zalouser: add channel dock metadata, config schema, setup wiring, probe, and status issues. (#1219) https://docs.clawd.bot/plugins/zalouser</li>
|
|
||||||
<li>Plugins: require manifest-embedded config schemas with preflight validation warnings. (#1272) https://docs.clawd.bot/plugins/manifest</li>
|
|
||||||
<li>Plugins: move channel catalog metadata into plugin manifests. (#1290) https://docs.clawd.bot/plugins/manifest</li>
|
|
||||||
<li>Plugins: align Nextcloud Talk policy helpers with core patterns. (#1290) https://docs.clawd.bot/plugins/manifest</li>
|
|
||||||
<li>Plugins/UI: let channel plugin metadata drive UI labels/icons and cron channel options. (#1306) https://docs.clawd.bot/web/control-ui</li>
|
|
||||||
<li>Plugins: add plugin slots with a dedicated memory slot selector. https://docs.clawd.bot/plugins/agent-tools</li>
|
|
||||||
<li>Plugins: ship the bundled BlueBubbles channel plugin (disabled by default). https://docs.clawd.bot/channels/bluebubbles</li>
|
|
||||||
<li>Plugins: migrate bundled messaging extensions to the plugin SDK and resolve plugin-sdk imports in the loader.</li>
|
|
||||||
<li>Plugins: migrate the Zalo plugin to the shared plugin SDK runtime. https://docs.clawd.bot/channels/zalo</li>
|
|
||||||
<li>Plugins: migrate the Zalo Personal plugin to the shared plugin SDK runtime. https://docs.clawd.bot/plugins/zalouser</li>
|
|
||||||
<li>Plugins: allow optional agent tools with explicit allowlists and add the plugin tool authoring guide. https://docs.clawd.bot/plugins/agent-tools</li>
|
|
||||||
<li>Plugins: auto-enable bundled channel/provider plugins when configuration is present.</li>
|
|
||||||
<li>Plugins: sync plugin sources on channel switches and update npm-installed plugins during <code>clawdbot update</code>.</li>
|
|
||||||
<li>Plugins: share npm plugin update logic between <code>clawdbot update</code> and <code>clawdbot plugins update</code>.</li>
|
|
||||||
<li>Gateway/API: add <code>/v1/responses</code> (OpenResponses) with item-based input + semantic streaming events. (#1229)</li>
|
|
||||||
<li>Gateway/API: expand <code>/v1/responses</code> to support file/image inputs, tool_choice, usage, and output limits. (#1229)</li>
|
|
||||||
<li>Usage: add <code>/usage cost</code> summaries and macOS menu cost charts. https://docs.clawd.bot/reference/api-usage-costs</li>
|
|
||||||
<li>Security: warn when <=300B models run without sandboxing while web tools are enabled. https://docs.clawd.bot/cli/security</li>
|
|
||||||
<li>Exec: add host/security/ask routing for gateway + node exec. https://docs.clawd.bot/tools/exec</li>
|
|
||||||
<li>Exec: add <code>/exec</code> directive for per-session exec defaults (host/security/ask/node). https://docs.clawd.bot/tools/exec</li>
|
|
||||||
<li>Exec approvals: migrate approvals to <code>~/.clawdbot/exec-approvals.json</code> with per-agent allowlists + skill auto-allow toggle, and add approvals UI + node exec lifecycle events. https://docs.clawd.bot/tools/exec-approvals</li>
|
|
||||||
<li>Nodes: add headless node host (<code>clawdbot node start</code>) for <code>system.run</code>/<code>system.which</code>. https://docs.clawd.bot/cli/node</li>
|
|
||||||
<li>Nodes: add node daemon service install/status/start/stop/restart. https://docs.clawd.bot/cli/node</li>
|
|
||||||
<li>Bridge: add <code>skills.bins</code> RPC to support node host auto-allow skill bins.</li>
|
|
||||||
<li>Sessions: add daily reset policy with per-type overrides and idle windows (default 4am local), preserving legacy idle-only configs. (#1146) https://docs.clawd.bot/concepts/session</li>
|
|
||||||
<li>Sessions: allow <code>sessions_spawn</code> to override thinking level for sub-agent runs. https://docs.clawd.bot/tools/subagents</li>
|
|
||||||
<li>Channels: unify thread/topic allowlist matching + command/mention gating helpers across core providers. https://docs.clawd.bot/concepts/groups</li>
|
|
||||||
<li>Models: add Qwen Portal OAuth provider support. (#1120) https://docs.clawd.bot/providers/qwen</li>
|
|
||||||
<li>Onboarding: add allowlist prompts and username-to-id resolution across core and extension channels. https://docs.clawd.bot/start/onboarding</li>
|
|
||||||
<li>Docs: clarify allowlist input types and onboarding behavior for messaging channels. https://docs.clawd.bot/start/onboarding</li>
|
|
||||||
<li>Docs: refresh Android node discovery docs for the Gateway WS service type. https://docs.clawd.bot/platforms/android</li>
|
|
||||||
<li>Docs: surface Amazon Bedrock in provider lists and clarify Bedrock auth env vars. (#1289) https://docs.clawd.bot/bedrock</li>
|
|
||||||
<li>Docs: clarify WhatsApp voice notes. https://docs.clawd.bot/channels/whatsapp</li>
|
|
||||||
<li>Docs: clarify Windows WSL portproxy LAN access notes. https://docs.clawd.bot/platforms/windows</li>
|
|
||||||
<li>Docs: refresh bird skill install metadata and usage notes. (#1302) https://docs.clawd.bot/tools/browser-login</li>
|
|
||||||
<li>Agents: add local docs path resolution and include docs/mirror/source/community pointers in the system prompt.</li>
|
|
||||||
<li>Agents: clarify node_modules read-only guidance in agent instructions.</li>
|
|
||||||
<li>Config: stamp last-touched metadata on write and warn if the config is newer than the running build.</li>
|
|
||||||
<li>macOS: hide usage section when usage is unavailable instead of showing provider errors.</li>
|
|
||||||
<li>Android: migrate node transport to the Gateway WebSocket protocol with TLS pinning support + gateway discovery naming.</li>
|
|
||||||
<li>Android: send structured payloads in node events/invokes and include user-agent metadata in gateway connects.</li>
|
|
||||||
<li>Android: remove legacy bridge transport code now that nodes use the gateway protocol.</li>
|
|
||||||
<li>Android: bump okhttp + dnsjava to satisfy lint dependency checks.</li>
|
|
||||||
<li>Build: update workspace + core/plugin deps.</li>
|
|
||||||
<li>Build: use tsgo for dev/watch builds by default (opt out with <code>CLAWDBOT_TS_COMPILER=tsc</code>).</li>
|
|
||||||
<li>Repo: remove the Peekaboo git submodule now that the SPM release is used.</li>
|
|
||||||
<li>macOS: switch PeekabooBridge integration to the tagged Swift Package Manager release.</li>
|
|
||||||
<li>macOS: stop syncing Peekaboo in postinstall.</li>
|
|
||||||
<li>Swabble: use the tagged Commander Swift package release.</li>
|
|
||||||
</ul>
|
|
||||||
<h3>Breaking</h3>
|
|
||||||
<ul>
|
|
||||||
<li><strong>BREAKING:</strong> Reject invalid/unknown config entries and refuse to start the gateway for safety. Run <code>clawdbot doctor --fix</code> to repair, then update plugins (<code>clawdbot plugins update</code>) if you use any.</li>
|
|
||||||
</ul>
|
</ul>
|
||||||
<h3>Fixes</h3>
|
<h3>Fixes</h3>
|
||||||
<ul>
|
<ul>
|
||||||
<li>Discovery: shorten Bonjour DNS-SD service type to <code>_clawdbot-gw._tcp</code> and update discovery clients/docs.</li>
|
<li>Sessions: accept non-UUID sessionIds for history/send/status while preserving agent scoping. (#1518)</li>
|
||||||
<li>Diagnostics: export OTLP logs, correct queue depth tracking, and document message-flow telemetry.</li>
|
<li>Gateway: compare Linux process start time to avoid PID recycling lock loops; keep locks unless stale. (#1572) Thanks @steipete.</li>
|
||||||
<li>Diagnostics: emit message-flow diagnostics across channels via shared dispatch. (#1244)</li>
|
<li>Messaging: mirror outbound sends into target session keys (threads + dmScope) and create session entries on send. (#1520)</li>
|
||||||
<li>Diagnostics: gate heartbeat/webhook logging. (#1244)</li>
|
<li>Sessions: normalize session key casing to lowercase for consistent routing.</li>
|
||||||
<li>Gateway: strip inbound envelope headers from chat history messages to keep clients clean.</li>
|
<li>BlueBubbles: normalize group session keys for outbound mirroring. (#1520)</li>
|
||||||
<li>Gateway: clarify unauthorized handshake responses with token/password mismatch guidance.</li>
|
<li>Skills: gate bird Homebrew install to macOS. (#1569) Thanks @bradleypriest.</li>
|
||||||
<li>Gateway: allow mobile node client ids for iOS + Android handshake validation. (#1354)</li>
|
<li>Slack: honor open groupPolicy for unlisted channels in message + slash gating. (#1563) Thanks @itsjaydesu.</li>
|
||||||
<li>Gateway: clarify connect/validation errors for gateway params. (#1347)</li>
|
<li>Agents: show tool error fallback when the last assistant turn only invoked tools (prevents silent stops).</li>
|
||||||
<li>Gateway: preserve restart wake routing + thread replies across restarts. (#1337)</li>
|
<li>Agents: ignore IDENTITY.md template placeholders when parsing identity to avoid placeholder replies. (#1556)</li>
|
||||||
<li>Gateway: reschedule per-agent heartbeats on config hot reload without restarting the runner.</li>
|
<li>Agents: drop orphaned OpenAI Responses reasoning blocks on model switches. (#1562) Thanks @roshanasingh4.</li>
|
||||||
<li>Gateway: require authorized restarts for SIGUSR1 (restart/apply/update) so config gating can't be bypassed.</li>
|
<li>Docker: update gateway command in docker-compose and Hetzner guide. (#1514)</li>
|
||||||
<li>Cron: auto-deliver isolated agent output to explicit targets without tool calls. (#1285)</li>
|
<li>Sessions: reject array-backed session stores to prevent silent wipes. (#1469)</li>
|
||||||
<li>Agents: preserve subagent announce thread/topic routing + queued replies across channels. (#1241)</li>
|
<li>Voice wake: auto-save wake words on blur/submit across iOS/Android and align limits with macOS.</li>
|
||||||
<li>Agents: propagate accountId into embedded runs so sub-agent announce routing honors the originating account. (#1058)</li>
|
<li>UI: keep the Control UI sidebar visible while scrolling long pages. (#1515) Thanks @pookNast.</li>
|
||||||
<li>Agents: avoid treating timeout errors with "aborted" messages as user aborts, so model fallback still runs. (#1137)</li>
|
<li>UI: cache Control UI markdown rendering + memoize chat text extraction to reduce Safari typing jank.</li>
|
||||||
<li>Agents: sanitize oversized image payloads before send and surface image-dimension errors.</li>
|
<li>Tailscale: retry serve/funnel with sudo only for permission errors and keep original failure details. (#1551) Thanks @sweepies.</li>
|
||||||
<li>Sessions: fall back to session labels when listing display names. (#1124)</li>
|
<li>Agents: add CLI log hint to "agent failed before reply" messages. (#1550) Thanks @sweepies.</li>
|
||||||
<li>Compaction: include tool failure summaries in safeguard compaction to prevent retry loops. (#1084)</li>
|
<li>Discord: limit autoThread mention bypass to bot-owned threads; keep ack reactions mention-gated. (#1511) Thanks @pvoo.</li>
|
||||||
<li>Config: log invalid config issues once per run and keep invalid-config errors stackless.</li>
|
<li>Discord: retry rate-limited allowlist resolution + command deploy to avoid gateway crashes.</li>
|
||||||
<li>Config: allow Perplexity as a web_search provider in config validation. (#1230)</li>
|
<li>Mentions: ignore mentionPattern matches when another explicit mention is present in group chats (Slack/Discord/Telegram/WhatsApp).</li>
|
||||||
<li>Config: allow custom fields under <code>skills.entries.<name>.config</code> for skill credentials/config. (#1226)</li>
|
<li>Gateway: accept null optional fields in exec approval requests. (#1511) Thanks @pvoo.</li>
|
||||||
<li>Doctor: clarify plugin auto-enable hint text in the startup banner.</li>
|
<li>Exec: honor tools.exec ask/security defaults for elevated approvals (avoid unwanted prompts).</li>
|
||||||
<li>Doctor: canonicalize legacy session keys in session stores to prevent stale metadata. (#1169)</li>
|
<li>TUI: forward unknown slash commands (for example, <code>/context</code>) to the Gateway.</li>
|
||||||
<li>Docs: make docs:list fail fast with a clear error if the docs directory is missing.</li>
|
<li>TUI: include Gateway slash commands in autocomplete and <code>/help</code>.</li>
|
||||||
<li>Plugins: add Nextcloud Talk manifest for plugin config validation. (#1297)</li>
|
<li>CLI: skip usage lines in <code>clawdbot models status</code> when provider usage is unavailable.</li>
|
||||||
<li>Plugins: surface plugin load/register/config errors in gateway logs with plugin/source context.</li>
|
<li>CLI: suppress diagnostic session/run noise during auth probes.</li>
|
||||||
<li>CLI: preserve cron delivery settings when editing message payloads. (#1322)</li>
|
<li>CLI: hide auth probe timeout warnings from embedded runs.</li>
|
||||||
<li>CLI: keep <code>clawdbot logs</code> output resilient to broken pipes while preserving progress output.</li>
|
<li>CLI: render auth probe results as a table in <code>clawdbot models status</code>.</li>
|
||||||
<li>CLI: avoid duplicating --profile/--dev flags when formatting commands.</li>
|
<li>CLI: suppress probe-only embedded logs unless <code>--verbose</code> is set.</li>
|
||||||
<li>CLI: centralize CLI command registration to keep fast-path routing and program wiring in sync. (#1207)</li>
|
<li>CLI: move auth probe errors below the table to reduce wrapping.</li>
|
||||||
<li>CLI: keep banners on routed commands, restore config guarding outside fast-path routing, and tighten fast-path flag parsing while skipping console capture for extra speed. (#1195)</li>
|
<li>CLI: prevent ANSI color bleed when table cells wrap.</li>
|
||||||
<li>CLI: skip runner rebuilds when dist is fresh. (#1231)</li>
|
<li>CLI: explain when auth profiles are excluded by auth.order in probe details.</li>
|
||||||
<li>CLI: add WSL2/systemd unavailable hints in daemon status/doctor output.</li>
|
<li>CLI: drop the em dash when the banner tagline wraps to a second line.</li>
|
||||||
<li>Status: route native <code>/status</code> to the active agent so model selection reflects the correct profile. (#1301)</li>
|
<li>CLI: inline auth probe errors in status rows to reduce wrapping.</li>
|
||||||
<li>Status: show both usage windows with reset hints when usage data is available. (#1101)</li>
|
<li>Telegram: render markdown in media captions. (#1478)</li>
|
||||||
<li>UI: keep config form enums typed, preserve empty strings, protect sensitive defaults, and deepen config search. (#1315)</li>
|
<li>Agents: honor enqueue overrides for embedded runs to avoid queue deadlocks in tests.</li>
|
||||||
<li>UI: preserve ordered list numbering in chat markdown. (#1341)</li>
|
<li>Agents: trigger model fallback when auth profiles are all in cooldown or unavailable. (#1522)</li>
|
||||||
<li>UI: allow Control UI to read gatewayUrl from URL params for remote WebSocket targets. (#1342)</li>
|
<li>Daemon: use platform PATH delimiters when building minimal service paths.</li>
|
||||||
<li>UI: prevent double-scroll in Control UI chat by locking chat layout to the viewport. (#1283)</li>
|
<li>Tests: skip embedded runner ordering assertion on Windows to avoid CI timeouts.</li>
|
||||||
<li>UI: enable shell mode for sync Windows spawns to avoid <code>pnpm ui:build</code> EINVAL. (#1212)</li>
|
<li>Linux: include env-configured user bin roots in systemd PATH and align PATH audits. (#1512) Thanks @robbyczgw-cla.</li>
|
||||||
<li>TUI: keep thinking blocks ordered before content during streaming and isolate per-run assembly. (#1202)</li>
|
<li>TUI: render Gateway slash-command replies as system output (for example, <code>/context</code>).</li>
|
||||||
<li>TUI: align custom editor initialization with the latest pi-tui API. (#1298)</li>
|
<li>Media: only parse <code>MEDIA:</code> tags when they start the line to avoid stripping prose mentions. (#1206)</li>
|
||||||
<li>TUI: show generic empty-state text for searchable pickers. (#1201)</li>
|
<li>Media: preserve PNG alpha when possible; fall back to JPEG when still over size cap. (#1491) Thanks @robbyczgw-cla.</li>
|
||||||
<li>TUI: highlight model search matches and stabilize search ordering.</li>
|
<li>Agents: treat plugin-only tool allowlists as opt-ins; keep core tools enabled. (#1467)</li>
|
||||||
<li>Configure: hide OpenRouter auto routing model from the model picker. (#1182)</li>
|
<li>Exec approvals: persist allowlist entry ids to keep macOS allowlist rows stable. (#1521) Thanks @ngutman.</li>
|
||||||
<li>Memory: show total file counts + scan issues in <code>clawdbot memory status</code>.</li>
|
<li>MS Teams (plugin): remove <code>.default</code> suffix from Graph scopes to avoid double-appending. (#1507) Thanks @Evizero.</li>
|
||||||
<li>Memory: fall back to non-batch embeddings after repeated batch failures.</li>
|
<li>MS Teams (plugin): remove <code>.default</code> suffix from Bot Framework probe scope to avoid double-appending. (#1574) Thanks @Evizero.</li>
|
||||||
<li>Memory: apply OpenAI batch defaults even without explicit remote config.</li>
|
<li>Browser: keep extension relay tabs controllable when the extension reuses a session id after switching tabs. (#1160)</li>
|
||||||
<li>Memory: index atomically so failed reindex preserves the previous memory database. (#1151)</li>
|
<li>Agents: warn and ignore tool allowlists that only reference unknown or unloaded plugin tools. (#1566)</li>
|
||||||
<li>Memory: avoid sqlite-vec unique constraint failures when reindexing duplicate chunk ids. (#1151)</li>
|
|
||||||
<li>Memory: retry transient 5xx errors (Cloudflare) during embedding indexing.</li>
|
|
||||||
<li>Memory: parallelize embedding indexing with rate-limit retries.</li>
|
|
||||||
<li>Memory: split overly long lines to keep embeddings under token limits.</li>
|
|
||||||
<li>Memory: skip empty chunks to avoid invalid embedding inputs.</li>
|
|
||||||
<li>Memory: split embedding batches to avoid OpenAI token limits during indexing.</li>
|
|
||||||
<li>Memory: probe sqlite-vec availability in <code>clawdbot memory status</code>.</li>
|
|
||||||
<li>Exec approvals: enforce allowlist when ask is off.</li>
|
|
||||||
<li>Exec approvals: prefer raw command for node approvals/events.</li>
|
|
||||||
<li>Tools: show exec elevated flag before the command and keep it outside markdown in tool summaries.</li>
|
|
||||||
<li>Tools: return a companion-app-required message when node exec is requested with no paired node.</li>
|
|
||||||
<li>Tools: return a companion-app-required message when <code>system.run</code> is requested without a supporting node.</li>
|
|
||||||
<li>Exec: default gateway/node exec security to allowlist when unset (sandbox stays deny).</li>
|
|
||||||
<li>Exec: prefer bash when fish is default shell, falling back to sh if bash is missing. (#1297)</li>
|
|
||||||
<li>Exec: merge login-shell PATH for host=gateway exec while keeping daemon PATH minimal. (#1304)</li>
|
|
||||||
<li>Streaming: emit assistant deltas for OpenAI-compatible SSE chunks. (#1147)</li>
|
|
||||||
<li>Discord: make resolve warnings avoid raw JSON payloads on rate limits.</li>
|
|
||||||
<li>Discord: process message handlers in parallel across sessions to avoid event queue blocking. (#1295)</li>
|
|
||||||
<li>Discord: stop reconnecting the gateway after aborts to prevent duplicate listeners.</li>
|
|
||||||
<li>Discord: only emit slow listener warnings after 30s.</li>
|
|
||||||
<li>Discord: inherit parent channel allowlists for thread slash commands and reactions. (#1123)</li>
|
|
||||||
<li>Telegram: honor pairing allowlists for native slash commands.</li>
|
|
||||||
<li>Telegram: preserve hidden text_link URLs by expanding entities in inbound text. (#1118)</li>
|
|
||||||
<li>Slack: resolve Bolt import interop for Bun + Node. (#1191)</li>
|
|
||||||
<li>Web search: infer Perplexity base URL from API key source (direct vs OpenRouter).</li>
|
|
||||||
<li>Web fetch: harden SSRF protection with shared hostname checks and redirect limits. (#1346)</li>
|
|
||||||
<li>Browser: register AI snapshot refs for act commands. (#1282)</li>
|
|
||||||
<li>Voice call: include request query in Twilio webhook verification when publicUrl is set. (#864)</li>
|
|
||||||
<li>Anthropic: default API prompt caching to 1h with configurable TTL override.</li>
|
|
||||||
<li>Anthropic: ignore TTL for OAuth.</li>
|
|
||||||
<li>Auth profiles: keep auto-pinned preference while allowing rotation on failover. (#1138)</li>
|
|
||||||
<li>Auth profiles: user pins stay locked. (#1138)</li>
|
|
||||||
<li>Model catalog: avoid caching import failures, log transient discovery errors, and keep partial results. (#1332)</li>
|
|
||||||
<li>Tests: stabilize Windows gateway/CLI tests by skipping sidecars, normalizing argv, and extending timeouts.</li>
|
|
||||||
<li>Tests: stabilize plugin SDK resolution and embedded agent timeouts.</li>
|
|
||||||
<li>Windows: install gateway scheduled task as the current user.</li>
|
|
||||||
<li>Windows: show friendly guidance instead of failing on access denied.</li>
|
|
||||||
<li>macOS: load menu session previews asynchronously so items populate while the menu is open.</li>
|
|
||||||
<li>macOS: use label colors for session preview text so previews render in menu subviews.</li>
|
|
||||||
<li>macOS: suppress usage error text in the menubar cost view.</li>
|
|
||||||
<li>macOS: Doctor repairs LaunchAgent bootstrap issues for Gateway + Node when listed but not loaded. (#1166)</li>
|
|
||||||
<li>macOS: avoid touching launchd in Remote over SSH so quitting the app no longer disables the remote gateway. (#1105)</li>
|
|
||||||
<li>macOS: bundle Textual resources in packaged app builds to avoid code block crashes. (#1006)</li>
|
|
||||||
<li>Daemon: include HOME in service environments to avoid missing HOME errors. (#1214)</li>
|
|
||||||
</ul>
|
|
||||||
Thanks @AlexMikhalev, @CoreyH, @John-Rood, @KrauseFx, @MaudeBot, @Nachx639, @NicholaiVogel, @RyanLisse, @ThePickle31, @VACInc, @Whoaa512, @YuriNachos, @aaronveklabs, @abdaraxus, @alauppe, @ameno-, @artuskg, @austinm911, @bradleypriest, @cheeeee, @dougvk, @fogboots, @gnarco, @gumadeiras, @jdrhyne, @joelklabo, @longmaba, @mukhtharcm, @odysseus0, @oscargavin, @rhjoh, @sebslight, @sibbl, @sleontenko, @steipete, @suminhthanh, @thewilloftheshadow, @tyler6204, @vignesh07, @visionik, @ysqander, @zerone0x.
|
|
||||||
<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.21/Clawdbot-2026.1.21.zip" length="12208102" type="application/octet-stream" sparkle:edSignature="hU495Eii8O3qmmUnxYFhXyEGv+qan6KL+GpeuBhPIXf+7B5F/gBh5Oz9cHaqaAPoZ4/3Bo6xgvic0HTkbz6gDw=="/>
|
|
||||||
</item>
|
|
||||||
<item>
|
|
||||||
<title>2026.1.16-2</title>
|
|
||||||
<pubDate>Sat, 17 Jan 2026 12:46:22 +0000</pubDate>
|
|
||||||
<link>https://raw.githubusercontent.com/clawdbot/clawdbot/main/appcast.xml</link>
|
|
||||||
<sparkle:version>6273</sparkle:version>
|
|
||||||
<sparkle:shortVersionString>2026.1.16-2</sparkle:shortVersionString>
|
|
||||||
<sparkle:minimumSystemVersion>15.0</sparkle:minimumSystemVersion>
|
|
||||||
<description><![CDATA[<h2>Clawdbot 2026.1.16-2</h2>
|
|
||||||
<h3>Changes</h3>
|
|
||||||
<ul>
|
|
||||||
<li>CLI: stamp build commit into dist metadata so banners show the commit in npm installs.</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.16-2/Clawdbot-2026.1.16-2.zip" length="21399591" type="application/octet-stream" sparkle:edSignature="zelT+KzN32cXsihbFniPF5Heq0hkwFfL3Agrh/AaoKUkr7kJAFarkGSOZRTWZ9y+DvOluzn2wHHjVigRjMzrBA=="/>
|
<enclosure url="https://github.com/clawdbot/clawdbot/releases/download/v2026.1.23/Clawdbot-2026.1.23.zip" length="22326233" type="application/octet-stream" sparkle:edSignature="p40dFczUfmMpsif4BrEUYVqUPG2WiBXleWgefwu4WiqjuyXbw7CAaH5CpQKig/k2qRLlE59kX7AR/qJqmy+yCA=="/>
|
||||||
</item>
|
</item>
|
||||||
</channel>
|
</channel>
|
||||||
</rss>
|
</rss>
|
||||||
@ -21,8 +21,8 @@ android {
|
|||||||
applicationId = "com.clawdbot.android"
|
applicationId = "com.clawdbot.android"
|
||||||
minSdk = 31
|
minSdk = 31
|
||||||
targetSdk = 36
|
targetSdk = 36
|
||||||
versionCode = 202601210
|
versionCode = 202601250
|
||||||
versionName = "2026.1.21"
|
versionName = "2026.1.25"
|
||||||
}
|
}
|
||||||
|
|
||||||
buildTypes {
|
buildTypes {
|
||||||
|
|||||||
@ -12,4 +12,3 @@ data class CameraHudState(
|
|||||||
val kind: CameraHudKind,
|
val kind: CameraHudKind,
|
||||||
val message: String,
|
val message: String,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@ -12,4 +12,3 @@ enum class VoiceWakeMode(val rawValue: String) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -8,10 +8,14 @@ object WakeWords {
|
|||||||
return input.split(",").map { it.trim() }.filter { it.isNotEmpty() }
|
return input.split(",").map { it.trim() }.filter { it.isNotEmpty() }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun parseIfChanged(input: String, current: List<String>): List<String>? {
|
||||||
|
val parsed = parseCommaSeparated(input)
|
||||||
|
return if (parsed == current) null else parsed
|
||||||
|
}
|
||||||
|
|
||||||
fun sanitize(words: List<String>, defaults: List<String>): List<String> {
|
fun sanitize(words: List<String>, defaults: List<String>): List<String> {
|
||||||
val cleaned =
|
val cleaned =
|
||||||
words.map { it.trim() }.filter { it.isNotEmpty() }.take(maxWords).map { it.take(maxWordLength) }
|
words.map { it.trim() }.filter { it.isNotEmpty() }.take(maxWords).map { it.take(maxWordLength) }
|
||||||
return cleaned.ifEmpty { defaults }
|
return cleaned.ifEmpty { defaults }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -28,6 +28,8 @@ import androidx.compose.foundation.layout.windowInsetsPadding
|
|||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
import androidx.compose.foundation.lazy.items
|
import androidx.compose.foundation.lazy.items
|
||||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||||
|
import androidx.compose.foundation.text.KeyboardActions
|
||||||
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.ExpandLess
|
import androidx.compose.material.icons.filled.ExpandLess
|
||||||
import androidx.compose.material.icons.filled.ExpandMore
|
import androidx.compose.material.icons.filled.ExpandMore
|
||||||
@ -49,7 +51,10 @@ import androidx.compose.runtime.remember
|
|||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.alpha
|
import androidx.compose.ui.draw.alpha
|
||||||
|
import androidx.compose.ui.focus.onFocusChanged
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.platform.LocalFocusManager
|
||||||
|
import androidx.compose.ui.text.input.ImeAction
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.core.content.ContextCompat
|
import androidx.core.content.ContextCompat
|
||||||
@ -58,6 +63,7 @@ import com.clawdbot.android.LocationMode
|
|||||||
import com.clawdbot.android.MainViewModel
|
import com.clawdbot.android.MainViewModel
|
||||||
import com.clawdbot.android.NodeForegroundService
|
import com.clawdbot.android.NodeForegroundService
|
||||||
import com.clawdbot.android.VoiceWakeMode
|
import com.clawdbot.android.VoiceWakeMode
|
||||||
|
import com.clawdbot.android.WakeWords
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun SettingsSheet(viewModel: MainViewModel) {
|
fun SettingsSheet(viewModel: MainViewModel) {
|
||||||
@ -86,6 +92,8 @@ fun SettingsSheet(viewModel: MainViewModel) {
|
|||||||
val listState = rememberLazyListState()
|
val listState = rememberLazyListState()
|
||||||
val (wakeWordsText, setWakeWordsText) = remember { mutableStateOf("") }
|
val (wakeWordsText, setWakeWordsText) = remember { mutableStateOf("") }
|
||||||
val (advancedExpanded, setAdvancedExpanded) = remember { mutableStateOf(false) }
|
val (advancedExpanded, setAdvancedExpanded) = remember { mutableStateOf(false) }
|
||||||
|
val focusManager = LocalFocusManager.current
|
||||||
|
var wakeWordsHadFocus by remember { mutableStateOf(false) }
|
||||||
val deviceModel =
|
val deviceModel =
|
||||||
remember {
|
remember {
|
||||||
listOfNotNull(Build.MANUFACTURER, Build.MODEL)
|
listOfNotNull(Build.MANUFACTURER, Build.MODEL)
|
||||||
@ -104,6 +112,12 @@ fun SettingsSheet(viewModel: MainViewModel) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
LaunchedEffect(wakeWords) { setWakeWordsText(wakeWords.joinToString(", ")) }
|
LaunchedEffect(wakeWords) { setWakeWordsText(wakeWords.joinToString(", ")) }
|
||||||
|
val commitWakeWords = {
|
||||||
|
val parsed = WakeWords.parseIfChanged(wakeWordsText, wakeWords)
|
||||||
|
if (parsed != null) {
|
||||||
|
viewModel.setWakeWords(parsed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
val permissionLauncher =
|
val permissionLauncher =
|
||||||
rememberLauncherForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { perms ->
|
rememberLauncherForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { perms ->
|
||||||
@ -481,25 +495,27 @@ fun SettingsSheet(viewModel: MainViewModel) {
|
|||||||
value = wakeWordsText,
|
value = wakeWordsText,
|
||||||
onValueChange = setWakeWordsText,
|
onValueChange = setWakeWordsText,
|
||||||
label = { Text("Wake Words (comma-separated)") },
|
label = { Text("Wake Words (comma-separated)") },
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier =
|
||||||
|
Modifier.fillMaxWidth().onFocusChanged { focusState ->
|
||||||
|
if (focusState.isFocused) {
|
||||||
|
wakeWordsHadFocus = true
|
||||||
|
} else if (wakeWordsHadFocus) {
|
||||||
|
wakeWordsHadFocus = false
|
||||||
|
commitWakeWords()
|
||||||
|
}
|
||||||
|
},
|
||||||
singleLine = true,
|
singleLine = true,
|
||||||
|
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
|
||||||
|
keyboardActions =
|
||||||
|
KeyboardActions(
|
||||||
|
onDone = {
|
||||||
|
commitWakeWords()
|
||||||
|
focusManager.clearFocus()
|
||||||
|
},
|
||||||
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
item {
|
item { Button(onClick = viewModel::resetWakeWordsDefaults) { Text("Reset defaults") } }
|
||||||
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
|
||||||
Button(
|
|
||||||
onClick = {
|
|
||||||
val parsed = com.clawdbot.android.WakeWords.parseCommaSeparated(wakeWordsText)
|
|
||||||
viewModel.setWakeWords(parsed)
|
|
||||||
},
|
|
||||||
enabled = isConnected,
|
|
||||||
) {
|
|
||||||
Text("Save + Sync")
|
|
||||||
}
|
|
||||||
|
|
||||||
Button(onClick = viewModel::resetWakeWordsDefaults) { Text("Reset defaults") }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
item {
|
item {
|
||||||
Text(
|
Text(
|
||||||
if (isConnected) {
|
if (isConnected) {
|
||||||
|
|||||||
@ -1,4 +1,3 @@
|
|||||||
<resources>
|
<resources>
|
||||||
<color name="ic_launcher_background">#0A0A0A</color>
|
<color name="ic_launcher_background">#0A0A0A</color>
|
||||||
</resources>
|
</resources>
|
||||||
|
|
||||||
|
|||||||
@ -1,4 +1,3 @@
|
|||||||
<resources>
|
<resources>
|
||||||
<string name="app_name">Clawdbot Node</string>
|
<string name="app_name">Clawdbot Node</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
package com.clawdbot.android
|
package com.clawdbot.android
|
||||||
|
|
||||||
import org.junit.Assert.assertEquals
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertNull
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
|
|
||||||
class WakeWordsTest {
|
class WakeWordsTest {
|
||||||
@ -32,5 +33,18 @@ class WakeWordsTest {
|
|||||||
assertEquals("w1", sanitized.first())
|
assertEquals("w1", sanitized.first())
|
||||||
assertEquals("w${WakeWords.maxWords}", sanitized.last())
|
assertEquals("w${WakeWords.maxWords}", sanitized.last())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun parseIfChangedSkipsWhenUnchanged() {
|
||||||
|
val current = listOf("clawd", "claude")
|
||||||
|
val parsed = WakeWords.parseIfChanged(" clawd , claude ", current)
|
||||||
|
assertNull(parsed)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun parseIfChangedReturnsUpdatedList() {
|
||||||
|
val current = listOf("clawd")
|
||||||
|
val parsed = WakeWords.parseIfChanged(" clawd , jarvis ", current)
|
||||||
|
assertEquals(listOf("clawd", "jarvis"), parsed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -23,4 +23,3 @@ class VoiceWakeCommandExtractorTest {
|
|||||||
assertNull(VoiceWakeCommandExtractor.extractCommand("hey claude!", listOf("claude")))
|
assertNull(VoiceWakeCommandExtractor.extractCommand("hey claude!", listOf("claude")))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -16,4 +16,3 @@ dependencyResolutionManagement {
|
|||||||
|
|
||||||
rootProject.name = "ClawdbotNodeAndroid"
|
rootProject.name = "ClawdbotNodeAndroid"
|
||||||
include(":app")
|
include(":app")
|
||||||
|
|
||||||
|
|||||||
@ -3,4 +3,3 @@ parent_config: ../../.swiftlint.yml
|
|||||||
included:
|
included:
|
||||||
- Sources
|
- Sources
|
||||||
- ../shared/ClawdisNodeKit/Sources
|
- ../shared/ClawdisNodeKit/Sources
|
||||||
|
|
||||||
|
|||||||
@ -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.21</string>
|
<string>2026.1.25</string>
|
||||||
<key>CFBundleVersion</key>
|
<key>CFBundleVersion</key>
|
||||||
<string>20260121</string>
|
<string>20260125</string>
|
||||||
<key>NSAppTransportSecurity</key>
|
<key>NSAppTransportSecurity</key>
|
||||||
<dict>
|
<dict>
|
||||||
<key>NSAllowsArbitraryLoadsInWebContent</key>
|
<key>NSAllowsArbitraryLoadsInWebContent</key>
|
||||||
|
|||||||
@ -1,8 +1,10 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
import Combine
|
||||||
|
|
||||||
struct VoiceWakeWordsSettingsView: View {
|
struct VoiceWakeWordsSettingsView: View {
|
||||||
@Environment(NodeAppModel.self) private var appModel
|
@Environment(NodeAppModel.self) private var appModel
|
||||||
@State private var triggerWords: [String] = VoiceWakePreferences.loadTriggerWords()
|
@State private var triggerWords: [String] = VoiceWakePreferences.loadTriggerWords()
|
||||||
|
@FocusState private var focusedTriggerIndex: Int?
|
||||||
@State private var syncTask: Task<Void, Never>?
|
@State private var syncTask: Task<Void, Never>?
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
@ -12,6 +14,10 @@ struct VoiceWakeWordsSettingsView: View {
|
|||||||
TextField("Wake word", text: self.binding(for: index))
|
TextField("Wake word", text: self.binding(for: index))
|
||||||
.textInputAutocapitalization(.never)
|
.textInputAutocapitalization(.never)
|
||||||
.autocorrectionDisabled()
|
.autocorrectionDisabled()
|
||||||
|
.focused(self.$focusedTriggerIndex, equals: index)
|
||||||
|
.onSubmit {
|
||||||
|
self.commitTriggerWords()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.onDelete(perform: self.removeWords)
|
.onDelete(perform: self.removeWords)
|
||||||
|
|
||||||
@ -39,17 +45,18 @@ struct VoiceWakeWordsSettingsView: View {
|
|||||||
.onAppear {
|
.onAppear {
|
||||||
if self.triggerWords.isEmpty {
|
if self.triggerWords.isEmpty {
|
||||||
self.triggerWords = VoiceWakePreferences.defaultTriggerWords
|
self.triggerWords = VoiceWakePreferences.defaultTriggerWords
|
||||||
|
self.commitTriggerWords()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.onChange(of: self.triggerWords) { _, newValue in
|
.onChange(of: self.focusedTriggerIndex) { oldValue, newValue in
|
||||||
// Keep local voice wake responsive even if the gateway isn't connected yet.
|
guard oldValue != nil, oldValue != newValue else { return }
|
||||||
VoiceWakePreferences.saveTriggerWords(newValue)
|
self.commitTriggerWords()
|
||||||
|
}
|
||||||
let snapshot = VoiceWakePreferences.sanitizeTriggerWords(newValue)
|
.onReceive(NotificationCenter.default.publisher(for: UserDefaults.didChangeNotification)) { _ in
|
||||||
self.syncTask?.cancel()
|
guard self.focusedTriggerIndex == nil else { return }
|
||||||
self.syncTask = Task { [snapshot, weak appModel = self.appModel] in
|
let updated = VoiceWakePreferences.loadTriggerWords()
|
||||||
try? await Task.sleep(nanoseconds: 650_000_000)
|
if updated != self.triggerWords {
|
||||||
await appModel?.setGlobalWakeWords(snapshot)
|
self.triggerWords = updated
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -63,6 +70,7 @@ struct VoiceWakeWordsSettingsView: View {
|
|||||||
if self.triggerWords.isEmpty {
|
if self.triggerWords.isEmpty {
|
||||||
self.triggerWords = VoiceWakePreferences.defaultTriggerWords
|
self.triggerWords = VoiceWakePreferences.defaultTriggerWords
|
||||||
}
|
}
|
||||||
|
self.commitTriggerWords()
|
||||||
}
|
}
|
||||||
|
|
||||||
private func binding(for index: Int) -> Binding<String> {
|
private func binding(for index: Int) -> Binding<String> {
|
||||||
@ -76,4 +84,15 @@ struct VoiceWakeWordsSettingsView: View {
|
|||||||
self.triggerWords[index] = newValue
|
self.triggerWords[index] = newValue
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func commitTriggerWords() {
|
||||||
|
VoiceWakePreferences.saveTriggerWords(self.triggerWords)
|
||||||
|
|
||||||
|
let snapshot = VoiceWakePreferences.sanitizeTriggerWords(self.triggerWords)
|
||||||
|
self.syncTask?.cancel()
|
||||||
|
self.syncTask = Task { [snapshot, weak appModel = self.appModel] in
|
||||||
|
try? await Task.sleep(nanoseconds: 650_000_000)
|
||||||
|
await appModel?.setGlobalWakeWords(snapshot)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -6,6 +6,8 @@ enum VoiceWakePreferences {
|
|||||||
|
|
||||||
// Keep defaults aligned with the mac app.
|
// Keep defaults aligned with the mac app.
|
||||||
static let defaultTriggerWords: [String] = ["clawd", "claude"]
|
static let defaultTriggerWords: [String] = ["clawd", "claude"]
|
||||||
|
static let maxWords = 32
|
||||||
|
static let maxWordLength = 64
|
||||||
|
|
||||||
static func decodeGatewayTriggers(from payloadJSON: String) -> [String]? {
|
static func decodeGatewayTriggers(from payloadJSON: String) -> [String]? {
|
||||||
guard let data = payloadJSON.data(using: .utf8) else { return nil }
|
guard let data = payloadJSON.data(using: .utf8) else { return nil }
|
||||||
@ -30,6 +32,8 @@ enum VoiceWakePreferences {
|
|||||||
let cleaned = words
|
let cleaned = words
|
||||||
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
|
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
|
||||||
.filter { !$0.isEmpty }
|
.filter { !$0.isEmpty }
|
||||||
|
.prefix(Self.maxWords)
|
||||||
|
.map { String($0.prefix(Self.maxWordLength)) }
|
||||||
return cleaned.isEmpty ? Self.defaultTriggerWords : cleaned
|
return cleaned.isEmpty ? Self.defaultTriggerWords : cleaned
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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.21</string>
|
<string>2026.1.25</string>
|
||||||
<key>CFBundleVersion</key>
|
<key>CFBundleVersion</key>
|
||||||
<string>20260121</string>
|
<string>20260125</string>
|
||||||
</dict>
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
|
|||||||
@ -11,6 +11,18 @@ import Testing
|
|||||||
#expect(VoiceWakePreferences.sanitizeTriggerWords(["", " "]) == VoiceWakePreferences.defaultTriggerWords)
|
#expect(VoiceWakePreferences.sanitizeTriggerWords(["", " "]) == VoiceWakePreferences.defaultTriggerWords)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test func sanitizeTriggerWordsLimitsWordLength() {
|
||||||
|
let long = String(repeating: "x", count: VoiceWakePreferences.maxWordLength + 5)
|
||||||
|
let cleaned = VoiceWakePreferences.sanitizeTriggerWords(["ok", long])
|
||||||
|
#expect(cleaned[1].count == VoiceWakePreferences.maxWordLength)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func sanitizeTriggerWordsLimitsWordCount() {
|
||||||
|
let words = (1...VoiceWakePreferences.maxWords + 3).map { "w\($0)" }
|
||||||
|
let cleaned = VoiceWakePreferences.sanitizeTriggerWords(words)
|
||||||
|
#expect(cleaned.count == VoiceWakePreferences.maxWords)
|
||||||
|
}
|
||||||
|
|
||||||
@Test func displayStringUsesSanitizedWords() {
|
@Test func displayStringUsesSanitizedWords() {
|
||||||
#expect(VoiceWakePreferences.displayString(for: ["", " "]) == "clawd, claude")
|
#expect(VoiceWakePreferences.displayString(for: ["", " "]) == "clawd, claude")
|
||||||
}
|
}
|
||||||
|
|||||||
@ -81,8 +81,8 @@ targets:
|
|||||||
properties:
|
properties:
|
||||||
CFBundleDisplayName: Clawdbot
|
CFBundleDisplayName: Clawdbot
|
||||||
CFBundleIconName: AppIcon
|
CFBundleIconName: AppIcon
|
||||||
CFBundleShortVersionString: "2026.1.21"
|
CFBundleShortVersionString: "2026.1.25"
|
||||||
CFBundleVersion: "20260121"
|
CFBundleVersion: "20260125"
|
||||||
UILaunchScreen: {}
|
UILaunchScreen: {}
|
||||||
UIApplicationSceneManifest:
|
UIApplicationSceneManifest:
|
||||||
UIApplicationSupportsMultipleScenes: false
|
UIApplicationSupportsMultipleScenes: false
|
||||||
@ -130,5 +130,5 @@ targets:
|
|||||||
path: Tests/Info.plist
|
path: Tests/Info.plist
|
||||||
properties:
|
properties:
|
||||||
CFBundleDisplayName: ClawdbotTests
|
CFBundleDisplayName: ClawdbotTests
|
||||||
CFBundleShortVersionString: "2026.1.21"
|
CFBundleShortVersionString: "2026.1.25"
|
||||||
CFBundleVersion: "20260121"
|
CFBundleVersion: "20260125"
|
||||||
|
|||||||
@ -123,8 +123,8 @@
|
|||||||
"kind" : "remoteSourceControl",
|
"kind" : "remoteSourceControl",
|
||||||
"location" : "https://github.com/gonzalezreal/textual",
|
"location" : "https://github.com/gonzalezreal/textual",
|
||||||
"state" : {
|
"state" : {
|
||||||
"revision" : "a03c1e103d88de4ea0dd8320ea1611ec0d4b29b3",
|
"revision" : "5b06b811c0f5313b6b84bbef98c635a630638c38",
|
||||||
"version" : "0.2.0"
|
"version" : "0.3.1"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
|||||||
@ -24,6 +24,11 @@ final class AppState {
|
|||||||
case remote
|
case remote
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enum RemoteTransport: String {
|
||||||
|
case ssh
|
||||||
|
case direct
|
||||||
|
}
|
||||||
|
|
||||||
var isPaused: Bool {
|
var isPaused: Bool {
|
||||||
didSet { self.ifNotPreview { UserDefaults.standard.set(self.isPaused, forKey: pauseDefaultsKey) } }
|
didSet { self.ifNotPreview { UserDefaults.standard.set(self.isPaused, forKey: pauseDefaultsKey) } }
|
||||||
}
|
}
|
||||||
@ -166,6 +171,10 @@ final class AppState {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var remoteTransport: RemoteTransport {
|
||||||
|
didSet { self.syncGatewayConfigIfNeeded() }
|
||||||
|
}
|
||||||
|
|
||||||
var canvasEnabled: Bool {
|
var canvasEnabled: Bool {
|
||||||
didSet { self.ifNotPreview { UserDefaults.standard.set(self.canvasEnabled, forKey: canvasEnabledKey) } }
|
didSet { self.ifNotPreview { UserDefaults.standard.set(self.canvasEnabled, forKey: canvasEnabledKey) } }
|
||||||
}
|
}
|
||||||
@ -200,6 +209,10 @@ final class AppState {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var remoteUrl: String {
|
||||||
|
didSet { self.syncGatewayConfigIfNeeded() }
|
||||||
|
}
|
||||||
|
|
||||||
var remoteIdentity: String {
|
var remoteIdentity: String {
|
||||||
didSet { self.ifNotPreview { UserDefaults.standard.set(self.remoteIdentity, forKey: remoteIdentityKey) } }
|
didSet { self.ifNotPreview { UserDefaults.standard.set(self.remoteIdentity, forKey: remoteIdentityKey) } }
|
||||||
}
|
}
|
||||||
@ -263,13 +276,15 @@ final class AppState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let configRoot = ClawdbotConfigFile.loadDict()
|
let configRoot = ClawdbotConfigFile.loadDict()
|
||||||
let configGateway = configRoot["gateway"] as? [String: Any]
|
let configRemoteUrl = GatewayRemoteConfig.resolveUrlString(root: configRoot)
|
||||||
let configRemoteUrl = (configGateway?["remote"] as? [String: Any])?["url"] as? String
|
let configRemoteTransport = GatewayRemoteConfig.resolveTransport(root: configRoot)
|
||||||
let resolvedConnectionMode = ConnectionModeResolver.resolve(root: configRoot).mode
|
let resolvedConnectionMode = ConnectionModeResolver.resolve(root: configRoot).mode
|
||||||
|
self.remoteTransport = configRemoteTransport
|
||||||
self.connectionMode = resolvedConnectionMode
|
self.connectionMode = resolvedConnectionMode
|
||||||
|
|
||||||
let storedRemoteTarget = UserDefaults.standard.string(forKey: remoteTargetKey) ?? ""
|
let storedRemoteTarget = UserDefaults.standard.string(forKey: remoteTargetKey) ?? ""
|
||||||
if resolvedConnectionMode == .remote,
|
if resolvedConnectionMode == .remote,
|
||||||
|
configRemoteTransport != .direct,
|
||||||
storedRemoteTarget.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty,
|
storedRemoteTarget.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty,
|
||||||
let host = AppState.remoteHost(from: configRemoteUrl)
|
let host = AppState.remoteHost(from: configRemoteUrl)
|
||||||
{
|
{
|
||||||
@ -277,6 +292,7 @@ final class AppState {
|
|||||||
} else {
|
} else {
|
||||||
self.remoteTarget = storedRemoteTarget
|
self.remoteTarget = storedRemoteTarget
|
||||||
}
|
}
|
||||||
|
self.remoteUrl = configRemoteUrl ?? ""
|
||||||
self.remoteIdentity = UserDefaults.standard.string(forKey: remoteIdentityKey) ?? ""
|
self.remoteIdentity = UserDefaults.standard.string(forKey: remoteIdentityKey) ?? ""
|
||||||
self.remoteProjectRoot = UserDefaults.standard.string(forKey: remoteProjectRootKey) ?? ""
|
self.remoteProjectRoot = UserDefaults.standard.string(forKey: remoteProjectRootKey) ?? ""
|
||||||
self.remoteCliPath = UserDefaults.standard.string(forKey: remoteCliPathKey) ?? ""
|
self.remoteCliPath = UserDefaults.standard.string(forKey: remoteCliPathKey) ?? ""
|
||||||
@ -354,10 +370,11 @@ final class AppState {
|
|||||||
private func applyConfigOverrides(_ root: [String: Any]) {
|
private func applyConfigOverrides(_ root: [String: Any]) {
|
||||||
let gateway = root["gateway"] as? [String: Any]
|
let gateway = root["gateway"] as? [String: Any]
|
||||||
let modeRaw = (gateway?["mode"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines)
|
let modeRaw = (gateway?["mode"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
let remoteUrl = (gateway?["remote"] as? [String: Any])?["url"] as? String
|
let remoteUrl = GatewayRemoteConfig.resolveUrlString(root: root)
|
||||||
let hasRemoteUrl = !(remoteUrl?
|
let hasRemoteUrl = !(remoteUrl?
|
||||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
.isEmpty ?? true)
|
.isEmpty ?? true)
|
||||||
|
let remoteTransport = GatewayRemoteConfig.resolveTransport(root: root)
|
||||||
|
|
||||||
let desiredMode: ConnectionMode? = switch modeRaw {
|
let desiredMode: ConnectionMode? = switch modeRaw {
|
||||||
case "local":
|
case "local":
|
||||||
@ -378,8 +395,17 @@ final class AppState {
|
|||||||
self.connectionMode = .remote
|
self.connectionMode = .remote
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if remoteTransport != self.remoteTransport {
|
||||||
|
self.remoteTransport = remoteTransport
|
||||||
|
}
|
||||||
|
let remoteUrlText = remoteUrl ?? ""
|
||||||
|
if remoteUrlText != self.remoteUrl {
|
||||||
|
self.remoteUrl = remoteUrlText
|
||||||
|
}
|
||||||
|
|
||||||
let targetMode = desiredMode ?? self.connectionMode
|
let targetMode = desiredMode ?? self.connectionMode
|
||||||
if targetMode == .remote,
|
if targetMode == .remote,
|
||||||
|
remoteTransport != .direct,
|
||||||
let host = AppState.remoteHost(from: remoteUrl)
|
let host = AppState.remoteHost(from: remoteUrl)
|
||||||
{
|
{
|
||||||
self.updateRemoteTarget(host: host)
|
self.updateRemoteTarget(host: host)
|
||||||
@ -387,10 +413,17 @@ final class AppState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func updateRemoteTarget(host: String) {
|
private func updateRemoteTarget(host: String) {
|
||||||
let parsed = CommandResolver.parseSSHTarget(self.remoteTarget)
|
let trimmed = self.remoteTarget.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
let user = parsed?.user ?? NSUserName()
|
guard let parsed = CommandResolver.parseSSHTarget(trimmed) else { return }
|
||||||
let port = parsed?.port ?? 22
|
let trimmedUser = parsed.user?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
let assembled = port == 22 ? "\(user)@\(host)" : "\(user)@\(host):\(port)"
|
let user = (trimmedUser?.isEmpty ?? true) ? nil : trimmedUser
|
||||||
|
let port = parsed.port
|
||||||
|
let assembled: String
|
||||||
|
if let user {
|
||||||
|
assembled = port == 22 ? "\(user)@\(host)" : "\(user)@\(host):\(port)"
|
||||||
|
} else {
|
||||||
|
assembled = port == 22 ? host : "\(host):\(port)"
|
||||||
|
}
|
||||||
if assembled != self.remoteTarget {
|
if assembled != self.remoteTarget {
|
||||||
self.remoteTarget = assembled
|
self.remoteTarget = assembled
|
||||||
}
|
}
|
||||||
@ -402,6 +435,8 @@ final class AppState {
|
|||||||
let connectionMode = self.connectionMode
|
let connectionMode = self.connectionMode
|
||||||
let remoteTarget = self.remoteTarget
|
let remoteTarget = self.remoteTarget
|
||||||
let remoteIdentity = self.remoteIdentity
|
let remoteIdentity = self.remoteIdentity
|
||||||
|
let remoteTransport = self.remoteTransport
|
||||||
|
let remoteUrl = self.remoteUrl
|
||||||
let desiredMode: String? = switch connectionMode {
|
let desiredMode: String? = switch connectionMode {
|
||||||
case .local:
|
case .local:
|
||||||
"local"
|
"local"
|
||||||
@ -435,6 +470,29 @@ final class AppState {
|
|||||||
var remote = gateway["remote"] as? [String: Any] ?? [:]
|
var remote = gateway["remote"] as? [String: Any] ?? [:]
|
||||||
var remoteChanged = false
|
var remoteChanged = false
|
||||||
|
|
||||||
|
if remoteTransport == .direct {
|
||||||
|
let trimmedUrl = remoteUrl.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
if trimmedUrl.isEmpty {
|
||||||
|
if remote["url"] != nil {
|
||||||
|
remote.removeValue(forKey: "url")
|
||||||
|
remoteChanged = true
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
let normalizedUrl = GatewayRemoteConfig.normalizeGatewayUrlString(trimmedUrl) ?? trimmedUrl
|
||||||
|
if (remote["url"] as? String) != normalizedUrl {
|
||||||
|
remote["url"] = normalizedUrl
|
||||||
|
remoteChanged = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (remote["transport"] as? String) != RemoteTransport.direct.rawValue {
|
||||||
|
remote["transport"] = RemoteTransport.direct.rawValue
|
||||||
|
remoteChanged = true
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if remote["transport"] != nil {
|
||||||
|
remote.removeValue(forKey: "transport")
|
||||||
|
remoteChanged = true
|
||||||
|
}
|
||||||
if let host = remoteHost {
|
if let host = remoteHost {
|
||||||
let existingUrl = (remote["url"] as? String)?
|
let existingUrl = (remote["url"] as? String)?
|
||||||
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||||
@ -469,6 +527,7 @@ final class AppState {
|
|||||||
remote.removeValue(forKey: "sshIdentity")
|
remote.removeValue(forKey: "sshIdentity")
|
||||||
remoteChanged = true
|
remoteChanged = true
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if remoteChanged {
|
if remoteChanged {
|
||||||
gateway["remote"] = remote
|
gateway["remote"] = remote
|
||||||
@ -621,8 +680,10 @@ extension AppState {
|
|||||||
state.iconOverride = .system
|
state.iconOverride = .system
|
||||||
state.heartbeatsEnabled = true
|
state.heartbeatsEnabled = true
|
||||||
state.connectionMode = .local
|
state.connectionMode = .local
|
||||||
|
state.remoteTransport = .ssh
|
||||||
state.canvasEnabled = true
|
state.canvasEnabled = true
|
||||||
state.remoteTarget = "user@example.com"
|
state.remoteTarget = "user@example.com"
|
||||||
|
state.remoteUrl = "wss://gateway.example.ts.net"
|
||||||
state.remoteIdentity = "~/.ssh/id_ed25519"
|
state.remoteIdentity = "~/.ssh/id_ed25519"
|
||||||
state.remoteProjectRoot = "~/Projects/clawdbot"
|
state.remoteProjectRoot = "~/Projects/clawdbot"
|
||||||
state.remoteCliPath = ""
|
state.remoteCliPath = ""
|
||||||
|
|||||||
@ -40,6 +40,16 @@ extension ChannelsSettings {
|
|||||||
return .orange
|
return .orange
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var googlechatTint: Color {
|
||||||
|
guard let status = self.channelStatus("googlechat", as: ChannelsStatusSnapshot.GoogleChatStatus.self)
|
||||||
|
else { return .secondary }
|
||||||
|
if !status.configured { return .secondary }
|
||||||
|
if status.lastError != nil { return .orange }
|
||||||
|
if status.probe?.ok == false { return .orange }
|
||||||
|
if status.running { return .green }
|
||||||
|
return .orange
|
||||||
|
}
|
||||||
|
|
||||||
var signalTint: Color {
|
var signalTint: Color {
|
||||||
guard let status = self.channelStatus("signal", as: ChannelsStatusSnapshot.SignalStatus.self)
|
guard let status = self.channelStatus("signal", as: ChannelsStatusSnapshot.SignalStatus.self)
|
||||||
else { return .secondary }
|
else { return .secondary }
|
||||||
@ -85,6 +95,14 @@ extension ChannelsSettings {
|
|||||||
return "Configured"
|
return "Configured"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var googlechatSummary: String {
|
||||||
|
guard let status = self.channelStatus("googlechat", as: ChannelsStatusSnapshot.GoogleChatStatus.self)
|
||||||
|
else { return "Checking…" }
|
||||||
|
if !status.configured { return "Not configured" }
|
||||||
|
if status.running { return "Running" }
|
||||||
|
return "Configured"
|
||||||
|
}
|
||||||
|
|
||||||
var signalSummary: String {
|
var signalSummary: String {
|
||||||
guard let status = self.channelStatus("signal", as: ChannelsStatusSnapshot.SignalStatus.self)
|
guard let status = self.channelStatus("signal", as: ChannelsStatusSnapshot.SignalStatus.self)
|
||||||
else { return "Checking…" }
|
else { return "Checking…" }
|
||||||
@ -193,6 +211,37 @@ extension ChannelsSettings {
|
|||||||
return lines.isEmpty ? nil : lines.joined(separator: " · ")
|
return lines.isEmpty ? nil : lines.joined(separator: " · ")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var googlechatDetails: String? {
|
||||||
|
guard let status = self.channelStatus("googlechat", as: ChannelsStatusSnapshot.GoogleChatStatus.self)
|
||||||
|
else { return nil }
|
||||||
|
var lines: [String] = []
|
||||||
|
if let source = status.credentialSource {
|
||||||
|
lines.append("Credential: \(source)")
|
||||||
|
}
|
||||||
|
if let audienceType = status.audienceType {
|
||||||
|
let audience = status.audience ?? ""
|
||||||
|
let label = audience.isEmpty ? audienceType : "\(audienceType) \(audience)"
|
||||||
|
lines.append("Audience: \(label)")
|
||||||
|
}
|
||||||
|
if let probe = status.probe {
|
||||||
|
if probe.ok {
|
||||||
|
if let elapsed = probe.elapsedMs {
|
||||||
|
lines.append("Probe \(Int(elapsed))ms")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
let code = probe.status.map { String($0) } ?? "unknown"
|
||||||
|
lines.append("Probe failed (\(code))")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let last = self.date(fromMs: status.lastProbeAt) {
|
||||||
|
lines.append("Last probe \(relativeAge(from: last))")
|
||||||
|
}
|
||||||
|
if let err = status.lastError, !err.isEmpty {
|
||||||
|
lines.append("Error: \(err)")
|
||||||
|
}
|
||||||
|
return lines.isEmpty ? nil : lines.joined(separator: " · ")
|
||||||
|
}
|
||||||
|
|
||||||
var signalDetails: String? {
|
var signalDetails: String? {
|
||||||
guard let status = self.channelStatus("signal", as: ChannelsStatusSnapshot.SignalStatus.self)
|
guard let status = self.channelStatus("signal", as: ChannelsStatusSnapshot.SignalStatus.self)
|
||||||
else { return nil }
|
else { return nil }
|
||||||
@ -244,7 +293,7 @@ extension ChannelsSettings {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var orderedChannels: [ChannelItem] {
|
var orderedChannels: [ChannelItem] {
|
||||||
let fallback = ["whatsapp", "telegram", "discord", "slack", "signal", "imessage"]
|
let fallback = ["whatsapp", "telegram", "discord", "googlechat", "slack", "signal", "imessage"]
|
||||||
let order = self.store.snapshot?.channelOrder ?? fallback
|
let order = self.store.snapshot?.channelOrder ?? fallback
|
||||||
let channels = order.enumerated().map { index, id in
|
let channels = order.enumerated().map { index, id in
|
||||||
ChannelItem(
|
ChannelItem(
|
||||||
@ -307,6 +356,8 @@ extension ChannelsSettings {
|
|||||||
return self.telegramTint
|
return self.telegramTint
|
||||||
case "discord":
|
case "discord":
|
||||||
return self.discordTint
|
return self.discordTint
|
||||||
|
case "googlechat":
|
||||||
|
return self.googlechatTint
|
||||||
case "signal":
|
case "signal":
|
||||||
return self.signalTint
|
return self.signalTint
|
||||||
case "imessage":
|
case "imessage":
|
||||||
@ -326,6 +377,8 @@ extension ChannelsSettings {
|
|||||||
return self.telegramSummary
|
return self.telegramSummary
|
||||||
case "discord":
|
case "discord":
|
||||||
return self.discordSummary
|
return self.discordSummary
|
||||||
|
case "googlechat":
|
||||||
|
return self.googlechatSummary
|
||||||
case "signal":
|
case "signal":
|
||||||
return self.signalSummary
|
return self.signalSummary
|
||||||
case "imessage":
|
case "imessage":
|
||||||
@ -345,6 +398,8 @@ extension ChannelsSettings {
|
|||||||
return self.telegramDetails
|
return self.telegramDetails
|
||||||
case "discord":
|
case "discord":
|
||||||
return self.discordDetails
|
return self.discordDetails
|
||||||
|
case "googlechat":
|
||||||
|
return self.googlechatDetails
|
||||||
case "signal":
|
case "signal":
|
||||||
return self.signalDetails
|
return self.signalDetails
|
||||||
case "imessage":
|
case "imessage":
|
||||||
@ -377,6 +432,10 @@ extension ChannelsSettings {
|
|||||||
return self
|
return self
|
||||||
.date(fromMs: self.channelStatus("discord", as: ChannelsStatusSnapshot.DiscordStatus.self)?
|
.date(fromMs: self.channelStatus("discord", as: ChannelsStatusSnapshot.DiscordStatus.self)?
|
||||||
.lastProbeAt)
|
.lastProbeAt)
|
||||||
|
case "googlechat":
|
||||||
|
return self
|
||||||
|
.date(fromMs: self.channelStatus("googlechat", as: ChannelsStatusSnapshot.GoogleChatStatus.self)?
|
||||||
|
.lastProbeAt)
|
||||||
case "signal":
|
case "signal":
|
||||||
return self
|
return self
|
||||||
.date(fromMs: self.channelStatus("signal", as: ChannelsStatusSnapshot.SignalStatus.self)?.lastProbeAt)
|
.date(fromMs: self.channelStatus("signal", as: ChannelsStatusSnapshot.SignalStatus.self)?.lastProbeAt)
|
||||||
@ -411,6 +470,10 @@ extension ChannelsSettings {
|
|||||||
guard let status = self.channelStatus("discord", as: ChannelsStatusSnapshot.DiscordStatus.self)
|
guard let status = self.channelStatus("discord", as: ChannelsStatusSnapshot.DiscordStatus.self)
|
||||||
else { return false }
|
else { return false }
|
||||||
return status.lastError?.isEmpty == false || status.probe?.ok == false
|
return status.lastError?.isEmpty == false || status.probe?.ok == false
|
||||||
|
case "googlechat":
|
||||||
|
guard let status = self.channelStatus("googlechat", as: ChannelsStatusSnapshot.GoogleChatStatus.self)
|
||||||
|
else { return false }
|
||||||
|
return status.lastError?.isEmpty == false || status.probe?.ok == false
|
||||||
case "signal":
|
case "signal":
|
||||||
guard let status = self.channelStatus("signal", as: ChannelsStatusSnapshot.SignalStatus.self)
|
guard let status = self.channelStatus("signal", as: ChannelsStatusSnapshot.SignalStatus.self)
|
||||||
else { return false }
|
else { return false }
|
||||||
|
|||||||
@ -85,6 +85,28 @@ struct ChannelsStatusSnapshot: Codable {
|
|||||||
let lastProbeAt: Double?
|
let lastProbeAt: Double?
|
||||||
}
|
}
|
||||||
|
|
||||||
|
struct GoogleChatProbe: Codable {
|
||||||
|
let ok: Bool
|
||||||
|
let status: Int?
|
||||||
|
let error: String?
|
||||||
|
let elapsedMs: Double?
|
||||||
|
}
|
||||||
|
|
||||||
|
struct GoogleChatStatus: Codable {
|
||||||
|
let configured: Bool
|
||||||
|
let credentialSource: String?
|
||||||
|
let audienceType: String?
|
||||||
|
let audience: String?
|
||||||
|
let webhookPath: String?
|
||||||
|
let webhookUrl: String?
|
||||||
|
let running: Bool
|
||||||
|
let lastStartAt: Double?
|
||||||
|
let lastStopAt: Double?
|
||||||
|
let lastError: String?
|
||||||
|
let probe: GoogleChatProbe?
|
||||||
|
let lastProbeAt: Double?
|
||||||
|
}
|
||||||
|
|
||||||
struct SignalProbe: Codable {
|
struct SignalProbe: Codable {
|
||||||
let ok: Bool
|
let ok: Bool
|
||||||
let status: Int?
|
let status: Int?
|
||||||
|
|||||||
@ -282,22 +282,6 @@ enum CommandResolver {
|
|||||||
guard !settings.target.isEmpty else { return nil }
|
guard !settings.target.isEmpty else { return nil }
|
||||||
guard let parsed = self.parseSSHTarget(settings.target) else { return nil }
|
guard let parsed = self.parseSSHTarget(settings.target) else { return nil }
|
||||||
|
|
||||||
var args: [String] = [
|
|
||||||
"-o", "BatchMode=yes",
|
|
||||||
"-o", "StrictHostKeyChecking=accept-new",
|
|
||||||
"-o", "UpdateHostKeys=yes",
|
|
||||||
]
|
|
||||||
if parsed.port > 0 { args.append(contentsOf: ["-p", String(parsed.port)]) }
|
|
||||||
let identity = settings.identity.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
||||||
if !identity.isEmpty {
|
|
||||||
// Only use IdentitiesOnly when an explicit identity file is provided.
|
|
||||||
// This allows 1Password SSH agent and other SSH agents to provide keys.
|
|
||||||
args.append(contentsOf: ["-o", "IdentitiesOnly=yes"])
|
|
||||||
args.append(contentsOf: ["-i", identity])
|
|
||||||
}
|
|
||||||
let userHost = parsed.user.map { "\($0)@\(parsed.host)" } ?? parsed.host
|
|
||||||
args.append(userHost)
|
|
||||||
|
|
||||||
// Run the real clawdbot CLI on the remote host.
|
// Run the real clawdbot CLI on the remote host.
|
||||||
let exportedPath = [
|
let exportedPath = [
|
||||||
"/opt/homebrew/bin",
|
"/opt/homebrew/bin",
|
||||||
@ -324,7 +308,7 @@ enum CommandResolver {
|
|||||||
} else {
|
} else {
|
||||||
"""
|
"""
|
||||||
PRJ=\(self.shellQuote(userPRJ))
|
PRJ=\(self.shellQuote(userPRJ))
|
||||||
cd \(self.shellQuote(userPRJ)) || { echo "Project root not found: \(userPRJ)"; exit 127; }
|
cd "$PRJ" || { echo "Project root not found: $PRJ"; exit 127; }
|
||||||
"""
|
"""
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -378,7 +362,16 @@ enum CommandResolver {
|
|||||||
echo "clawdbot CLI missing on remote host"; exit 127;
|
echo "clawdbot CLI missing on remote host"; exit 127;
|
||||||
fi
|
fi
|
||||||
"""
|
"""
|
||||||
args.append(contentsOf: ["/bin/sh", "-c", scriptBody])
|
let options: [String] = [
|
||||||
|
"-o", "BatchMode=yes",
|
||||||
|
"-o", "StrictHostKeyChecking=accept-new",
|
||||||
|
"-o", "UpdateHostKeys=yes",
|
||||||
|
]
|
||||||
|
let args = self.sshArguments(
|
||||||
|
target: parsed,
|
||||||
|
identity: settings.identity,
|
||||||
|
options: options,
|
||||||
|
remoteCommand: ["/bin/sh", "-c", scriptBody])
|
||||||
return ["/usr/bin/ssh"] + args
|
return ["/usr/bin/ssh"] + args
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -427,8 +420,11 @@ enum CommandResolver {
|
|||||||
}
|
}
|
||||||
|
|
||||||
static func parseSSHTarget(_ target: String) -> SSHParsedTarget? {
|
static func parseSSHTarget(_ target: String) -> SSHParsedTarget? {
|
||||||
let trimmed = target.trimmingCharacters(in: .whitespacesAndNewlines)
|
let trimmed = self.normalizeSSHTargetInput(target)
|
||||||
guard !trimmed.isEmpty else { return nil }
|
guard !trimmed.isEmpty else { return nil }
|
||||||
|
if trimmed.rangeOfCharacter(from: CharacterSet.whitespacesAndNewlines.union(.controlCharacters)) != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
let userHostPort: String
|
let userHostPort: String
|
||||||
let user: String?
|
let user: String?
|
||||||
if let atRange = trimmed.range(of: "@") {
|
if let atRange = trimmed.range(of: "@") {
|
||||||
@ -444,13 +440,31 @@ enum CommandResolver {
|
|||||||
if let colon = userHostPort.lastIndex(of: ":"), colon != userHostPort.startIndex {
|
if let colon = userHostPort.lastIndex(of: ":"), colon != userHostPort.startIndex {
|
||||||
host = String(userHostPort[..<colon])
|
host = String(userHostPort[..<colon])
|
||||||
let portStr = String(userHostPort[userHostPort.index(after: colon)...])
|
let portStr = String(userHostPort[userHostPort.index(after: colon)...])
|
||||||
port = Int(portStr) ?? 22
|
guard let parsedPort = Int(portStr), parsedPort > 0, parsedPort <= 65535 else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
port = parsedPort
|
||||||
} else {
|
} else {
|
||||||
host = userHostPort
|
host = userHostPort
|
||||||
port = 22
|
port = 22
|
||||||
}
|
}
|
||||||
|
|
||||||
return SSHParsedTarget(user: user, host: host, port: port)
|
return self.makeSSHTarget(user: user, host: host, port: port)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func sshTargetValidationMessage(_ target: String) -> String? {
|
||||||
|
let trimmed = self.normalizeSSHTargetInput(target)
|
||||||
|
guard !trimmed.isEmpty else { return nil }
|
||||||
|
if trimmed.hasPrefix("-") {
|
||||||
|
return "SSH target cannot start with '-'"
|
||||||
|
}
|
||||||
|
if trimmed.rangeOfCharacter(from: CharacterSet.whitespacesAndNewlines.union(.controlCharacters)) != nil {
|
||||||
|
return "SSH target cannot contain spaces"
|
||||||
|
}
|
||||||
|
if self.parseSSHTarget(trimmed) == nil {
|
||||||
|
return "SSH target must look like user@host[:port]"
|
||||||
|
}
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
private static func shellQuote(_ text: String) -> String {
|
private static func shellQuote(_ text: String) -> String {
|
||||||
@ -468,6 +482,64 @@ enum CommandResolver {
|
|||||||
return URL(fileURLWithPath: expanded)
|
return URL(fileURLWithPath: expanded)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static func normalizeSSHTargetInput(_ target: String) -> String {
|
||||||
|
var trimmed = target.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
if trimmed.hasPrefix("ssh ") {
|
||||||
|
trimmed = trimmed.replacingOccurrences(of: "ssh ", with: "")
|
||||||
|
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
}
|
||||||
|
return trimmed
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func isValidSSHComponent(_ value: String, allowLeadingDash: Bool = false) -> Bool {
|
||||||
|
if value.isEmpty { return false }
|
||||||
|
if !allowLeadingDash, value.hasPrefix("-") { return false }
|
||||||
|
let invalid = CharacterSet.whitespacesAndNewlines.union(.controlCharacters)
|
||||||
|
return value.rangeOfCharacter(from: invalid) == nil
|
||||||
|
}
|
||||||
|
|
||||||
|
static func makeSSHTarget(user: String?, host: String, port: Int) -> SSHParsedTarget? {
|
||||||
|
let trimmedHost = host.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
guard self.isValidSSHComponent(trimmedHost) else { return nil }
|
||||||
|
let trimmedUser = user?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
let normalizedUser: String?
|
||||||
|
if let trimmedUser {
|
||||||
|
guard self.isValidSSHComponent(trimmedUser) else { return nil }
|
||||||
|
normalizedUser = trimmedUser.isEmpty ? nil : trimmedUser
|
||||||
|
} else {
|
||||||
|
normalizedUser = nil
|
||||||
|
}
|
||||||
|
guard port > 0, port <= 65535 else { return nil }
|
||||||
|
return SSHParsedTarget(user: normalizedUser, host: trimmedHost, port: port)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func sshTargetString(_ target: SSHParsedTarget) -> String {
|
||||||
|
target.user.map { "\($0)@\(target.host)" } ?? target.host
|
||||||
|
}
|
||||||
|
|
||||||
|
static func sshArguments(
|
||||||
|
target: SSHParsedTarget,
|
||||||
|
identity: String,
|
||||||
|
options: [String],
|
||||||
|
remoteCommand: [String] = []) -> [String]
|
||||||
|
{
|
||||||
|
var args = options
|
||||||
|
if target.port > 0 {
|
||||||
|
args.append(contentsOf: ["-p", String(target.port)])
|
||||||
|
}
|
||||||
|
let trimmedIdentity = identity.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
if !trimmedIdentity.isEmpty {
|
||||||
|
// Only use IdentitiesOnly when an explicit identity file is provided.
|
||||||
|
// This allows 1Password SSH agent and other SSH agents to provide keys.
|
||||||
|
args.append(contentsOf: ["-o", "IdentitiesOnly=yes"])
|
||||||
|
args.append(contentsOf: ["-i", trimmedIdentity])
|
||||||
|
}
|
||||||
|
args.append("--")
|
||||||
|
args.append(self.sshTargetString(target))
|
||||||
|
args.append(contentsOf: remoteCommand)
|
||||||
|
return args
|
||||||
|
}
|
||||||
|
|
||||||
#if SWIFT_PACKAGE
|
#if SWIFT_PACKAGE
|
||||||
static func _testNodeManagerBinPaths(home: URL) -> [String] {
|
static func _testNodeManagerBinPaths(home: URL) -> [String] {
|
||||||
self.nodeManagerBinPaths(home: home)
|
self.nodeManagerBinPaths(home: home)
|
||||||
|
|||||||
@ -12,6 +12,8 @@ let voiceWakeTriggerChimeKey = "clawdbot.voiceWakeTriggerChime"
|
|||||||
let voiceWakeSendChimeKey = "clawdbot.voiceWakeSendChime"
|
let voiceWakeSendChimeKey = "clawdbot.voiceWakeSendChime"
|
||||||
let showDockIconKey = "clawdbot.showDockIcon"
|
let showDockIconKey = "clawdbot.showDockIcon"
|
||||||
let defaultVoiceWakeTriggers = ["clawd", "claude"]
|
let defaultVoiceWakeTriggers = ["clawd", "claude"]
|
||||||
|
let voiceWakeMaxWords = 32
|
||||||
|
let voiceWakeMaxWordLength = 64
|
||||||
let voiceWakeMicKey = "clawdbot.voiceWakeMicID"
|
let voiceWakeMicKey = "clawdbot.voiceWakeMicID"
|
||||||
let voiceWakeMicNameKey = "clawdbot.voiceWakeMicName"
|
let voiceWakeMicNameKey = "clawdbot.voiceWakeMicName"
|
||||||
let voiceWakeLocaleKey = "clawdbot.voiceWakeLocaleID"
|
let voiceWakeLocaleKey = "clawdbot.voiceWakeLocaleID"
|
||||||
|
|||||||
@ -84,11 +84,52 @@ enum ExecApprovalDecision: String, Codable, Sendable {
|
|||||||
case deny
|
case deny
|
||||||
}
|
}
|
||||||
|
|
||||||
struct ExecAllowlistEntry: Codable, Hashable {
|
struct ExecAllowlistEntry: Codable, Hashable, Identifiable {
|
||||||
|
var id: UUID
|
||||||
var pattern: String
|
var pattern: String
|
||||||
var lastUsedAt: Double?
|
var lastUsedAt: Double?
|
||||||
var lastUsedCommand: String?
|
var lastUsedCommand: String?
|
||||||
var lastResolvedPath: String?
|
var lastResolvedPath: String?
|
||||||
|
|
||||||
|
init(
|
||||||
|
id: UUID = UUID(),
|
||||||
|
pattern: String,
|
||||||
|
lastUsedAt: Double? = nil,
|
||||||
|
lastUsedCommand: String? = nil,
|
||||||
|
lastResolvedPath: String? = nil)
|
||||||
|
{
|
||||||
|
self.id = id
|
||||||
|
self.pattern = pattern
|
||||||
|
self.lastUsedAt = lastUsedAt
|
||||||
|
self.lastUsedCommand = lastUsedCommand
|
||||||
|
self.lastResolvedPath = lastResolvedPath
|
||||||
|
}
|
||||||
|
|
||||||
|
private enum CodingKeys: String, CodingKey {
|
||||||
|
case id
|
||||||
|
case pattern
|
||||||
|
case lastUsedAt
|
||||||
|
case lastUsedCommand
|
||||||
|
case lastResolvedPath
|
||||||
|
}
|
||||||
|
|
||||||
|
init(from decoder: Decoder) throws {
|
||||||
|
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||||
|
self.id = try container.decodeIfPresent(UUID.self, forKey: .id) ?? UUID()
|
||||||
|
self.pattern = try container.decode(String.self, forKey: .pattern)
|
||||||
|
self.lastUsedAt = try container.decodeIfPresent(Double.self, forKey: .lastUsedAt)
|
||||||
|
self.lastUsedCommand = try container.decodeIfPresent(String.self, forKey: .lastUsedCommand)
|
||||||
|
self.lastResolvedPath = try container.decodeIfPresent(String.self, forKey: .lastResolvedPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
func encode(to encoder: Encoder) throws {
|
||||||
|
var container = encoder.container(keyedBy: CodingKeys.self)
|
||||||
|
try container.encode(self.id, forKey: .id)
|
||||||
|
try container.encode(self.pattern, forKey: .pattern)
|
||||||
|
try container.encodeIfPresent(self.lastUsedAt, forKey: .lastUsedAt)
|
||||||
|
try container.encodeIfPresent(self.lastUsedCommand, forKey: .lastUsedCommand)
|
||||||
|
try container.encodeIfPresent(self.lastResolvedPath, forKey: .lastResolvedPath)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
struct ExecApprovalsDefaults: Codable {
|
struct ExecApprovalsDefaults: Codable {
|
||||||
@ -295,6 +336,7 @@ enum ExecApprovalsStore {
|
|||||||
let allowlist = ((wildcardEntry.allowlist ?? []) + (agentEntry.allowlist ?? []))
|
let allowlist = ((wildcardEntry.allowlist ?? []) + (agentEntry.allowlist ?? []))
|
||||||
.map { entry in
|
.map { entry in
|
||||||
ExecAllowlistEntry(
|
ExecAllowlistEntry(
|
||||||
|
id: entry.id,
|
||||||
pattern: entry.pattern.trimmingCharacters(in: .whitespacesAndNewlines),
|
pattern: entry.pattern.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||||
lastUsedAt: entry.lastUsedAt,
|
lastUsedAt: entry.lastUsedAt,
|
||||||
lastUsedCommand: entry.lastUsedCommand,
|
lastUsedCommand: entry.lastUsedCommand,
|
||||||
@ -379,6 +421,7 @@ enum ExecApprovalsStore {
|
|||||||
let allowlist = (entry.allowlist ?? []).map { item -> ExecAllowlistEntry in
|
let allowlist = (entry.allowlist ?? []).map { item -> ExecAllowlistEntry in
|
||||||
guard item.pattern == pattern else { return item }
|
guard item.pattern == pattern else { return item }
|
||||||
return ExecAllowlistEntry(
|
return ExecAllowlistEntry(
|
||||||
|
id: item.id,
|
||||||
pattern: item.pattern,
|
pattern: item.pattern,
|
||||||
lastUsedAt: Date().timeIntervalSince1970 * 1000,
|
lastUsedAt: Date().timeIntervalSince1970 * 1000,
|
||||||
lastUsedCommand: command,
|
lastUsedCommand: command,
|
||||||
@ -398,6 +441,7 @@ enum ExecApprovalsStore {
|
|||||||
let cleaned = allowlist
|
let cleaned = allowlist
|
||||||
.map { item in
|
.map { item in
|
||||||
ExecAllowlistEntry(
|
ExecAllowlistEntry(
|
||||||
|
id: item.id,
|
||||||
pattern: item.pattern.trimmingCharacters(in: .whitespacesAndNewlines),
|
pattern: item.pattern.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||||
lastUsedAt: item.lastUsedAt,
|
lastUsedAt: item.lastUsedAt,
|
||||||
lastUsedCommand: item.lastUsedCommand,
|
lastUsedCommand: item.lastUsedCommand,
|
||||||
|
|||||||
@ -11,6 +11,7 @@ enum GatewayAgentChannel: String, Codable, CaseIterable, Sendable {
|
|||||||
case whatsapp
|
case whatsapp
|
||||||
case telegram
|
case telegram
|
||||||
case discord
|
case discord
|
||||||
|
case googlechat
|
||||||
case slack
|
case slack
|
||||||
case signal
|
case signal
|
||||||
case imessage
|
case imessage
|
||||||
|
|||||||
47
apps/macos/Sources/Clawdbot/GatewayDiscoveryHelpers.swift
Normal file
47
apps/macos/Sources/Clawdbot/GatewayDiscoveryHelpers.swift
Normal file
@ -0,0 +1,47 @@
|
|||||||
|
import ClawdbotDiscovery
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
enum GatewayDiscoveryHelpers {
|
||||||
|
static func sshTarget(for gateway: GatewayDiscoveryModel.DiscoveredGateway) -> String? {
|
||||||
|
let host = self.sanitizedTailnetHost(gateway.tailnetDns) ?? gateway.lanHost
|
||||||
|
guard let host = self.trimmed(host), !host.isEmpty else { return nil }
|
||||||
|
let user = NSUserName()
|
||||||
|
var target = "\(user)@\(host)"
|
||||||
|
if gateway.sshPort != 22 {
|
||||||
|
target += ":\(gateway.sshPort)"
|
||||||
|
}
|
||||||
|
return target
|
||||||
|
}
|
||||||
|
|
||||||
|
static func directUrl(for gateway: GatewayDiscoveryModel.DiscoveredGateway) -> String? {
|
||||||
|
self.directGatewayUrl(
|
||||||
|
tailnetDns: gateway.tailnetDns,
|
||||||
|
lanHost: gateway.lanHost,
|
||||||
|
gatewayPort: gateway.gatewayPort)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func directGatewayUrl(
|
||||||
|
tailnetDns: String?,
|
||||||
|
lanHost: String?,
|
||||||
|
gatewayPort: Int?) -> String?
|
||||||
|
{
|
||||||
|
if let tailnetDns = self.sanitizedTailnetHost(tailnetDns) {
|
||||||
|
return "wss://\(tailnetDns)"
|
||||||
|
}
|
||||||
|
guard let lanHost = self.trimmed(lanHost), !lanHost.isEmpty else { return nil }
|
||||||
|
let port = gatewayPort ?? 18789
|
||||||
|
return "ws://\(lanHost):\(port)"
|
||||||
|
}
|
||||||
|
|
||||||
|
static func sanitizedTailnetHost(_ host: String?) -> String? {
|
||||||
|
guard let host = self.trimmed(host), !host.isEmpty else { return nil }
|
||||||
|
if host.hasSuffix(".internal.") || host.hasSuffix(".internal") {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return host
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func trimmed(_ value: String?) -> String? {
|
||||||
|
value?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -4,6 +4,8 @@ import SwiftUI
|
|||||||
struct GatewayDiscoveryInlineList: View {
|
struct GatewayDiscoveryInlineList: View {
|
||||||
var discovery: GatewayDiscoveryModel
|
var discovery: GatewayDiscoveryModel
|
||||||
var currentTarget: String?
|
var currentTarget: String?
|
||||||
|
var currentUrl: String?
|
||||||
|
var transport: AppState.RemoteTransport
|
||||||
var onSelect: (GatewayDiscoveryModel.DiscoveredGateway) -> Void
|
var onSelect: (GatewayDiscoveryModel.DiscoveredGateway) -> Void
|
||||||
@State private var hoveredGatewayID: GatewayDiscoveryModel.DiscoveredGateway.ID?
|
@State private var hoveredGatewayID: GatewayDiscoveryModel.DiscoveredGateway.ID?
|
||||||
|
|
||||||
@ -25,9 +27,8 @@ struct GatewayDiscoveryInlineList: View {
|
|||||||
} else {
|
} else {
|
||||||
VStack(alignment: .leading, spacing: 6) {
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
ForEach(self.discovery.gateways.prefix(6)) { gateway in
|
ForEach(self.discovery.gateways.prefix(6)) { gateway in
|
||||||
let target = self.suggestedSSHTarget(gateway)
|
let display = self.displayInfo(for: gateway)
|
||||||
let selected = (target != nil && self.currentTarget?
|
let selected = display.selected
|
||||||
.trimmingCharacters(in: .whitespacesAndNewlines) == target)
|
|
||||||
|
|
||||||
Button {
|
Button {
|
||||||
withAnimation(.spring(response: 0.25, dampingFraction: 0.9)) {
|
withAnimation(.spring(response: 0.25, dampingFraction: 0.9)) {
|
||||||
@ -40,7 +41,7 @@ struct GatewayDiscoveryInlineList: View {
|
|||||||
.font(.callout.weight(.semibold))
|
.font(.callout.weight(.semibold))
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
.truncationMode(.tail)
|
.truncationMode(.tail)
|
||||||
Text(target ?? "Gateway pairing only")
|
Text(display.label)
|
||||||
.font(.caption.monospaced())
|
.font(.caption.monospaced())
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
@ -83,27 +84,26 @@ struct GatewayDiscoveryInlineList: View {
|
|||||||
.fill(Color(NSColor.controlBackgroundColor)))
|
.fill(Color(NSColor.controlBackgroundColor)))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.help("Click a discovered gateway to fill the SSH target.")
|
.help(self.transport == .direct
|
||||||
|
? "Click a discovered gateway to fill the gateway URL."
|
||||||
|
: "Click a discovered gateway to fill the SSH target.")
|
||||||
}
|
}
|
||||||
|
|
||||||
private func suggestedSSHTarget(_ gateway: GatewayDiscoveryModel.DiscoveredGateway) -> String? {
|
private func displayInfo(
|
||||||
let host = self.sanitizedTailnetHost(gateway.tailnetDns) ?? gateway.lanHost
|
for gateway: GatewayDiscoveryModel.DiscoveredGateway) -> (label: String, selected: Bool)
|
||||||
guard let host else { return nil }
|
{
|
||||||
let user = NSUserName()
|
switch self.transport {
|
||||||
return GatewayDiscoveryModel.buildSSHTarget(
|
case .direct:
|
||||||
user: user,
|
let url = GatewayDiscoveryHelpers.directUrl(for: gateway)
|
||||||
host: host,
|
let label = url ?? "Gateway pairing only"
|
||||||
port: gateway.sshPort)
|
let selected = url != nil && self.trimmed(self.currentUrl) == url
|
||||||
|
return (label, selected)
|
||||||
|
case .ssh:
|
||||||
|
let target = GatewayDiscoveryHelpers.sshTarget(for: gateway)
|
||||||
|
let label = target ?? "Gateway pairing only"
|
||||||
|
let selected = target != nil && self.trimmed(self.currentTarget) == target
|
||||||
|
return (label, selected)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func sanitizedTailnetHost(_ host: String?) -> String? {
|
|
||||||
guard let host else { return nil }
|
|
||||||
let trimmed = host.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
||||||
if trimmed.isEmpty { return nil }
|
|
||||||
if trimmed.hasSuffix(".internal.") || trimmed.hasSuffix(".internal") {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return trimmed
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func rowBackground(selected: Bool, hovered: Bool) -> Color {
|
private func rowBackground(selected: Bool, hovered: Bool) -> Color {
|
||||||
@ -111,6 +111,10 @@ struct GatewayDiscoveryInlineList: View {
|
|||||||
if hovered { return Color.secondary.opacity(0.08) }
|
if hovered { return Color.secondary.opacity(0.08) }
|
||||||
return Color.clear
|
return Color.clear
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func trimmed(_ value: String?) -> String {
|
||||||
|
value?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
struct GatewayDiscoveryMenu: View {
|
struct GatewayDiscoveryMenu: View {
|
||||||
|
|||||||
@ -311,6 +311,19 @@ actor GatewayEndpointStore {
|
|||||||
token: token,
|
token: token,
|
||||||
password: password))
|
password: password))
|
||||||
case .remote:
|
case .remote:
|
||||||
|
let root = ClawdbotConfigFile.loadDict()
|
||||||
|
if GatewayRemoteConfig.resolveTransport(root: root) == .direct {
|
||||||
|
guard let url = GatewayRemoteConfig.resolveGatewayUrl(root: root) else {
|
||||||
|
self.cancelRemoteEnsure()
|
||||||
|
self.setState(.unavailable(
|
||||||
|
mode: .remote,
|
||||||
|
reason: "gateway.remote.url missing or invalid for direct transport"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
self.cancelRemoteEnsure()
|
||||||
|
self.setState(.ready(mode: .remote, url: url, token: token, password: password))
|
||||||
|
return
|
||||||
|
}
|
||||||
let port = await self.deps.remotePortIfRunning()
|
let port = await self.deps.remotePortIfRunning()
|
||||||
guard let port else {
|
guard let port else {
|
||||||
self.setState(.connecting(mode: .remote, detail: Self.remoteConnectingDetail))
|
self.setState(.connecting(mode: .remote, detail: Self.remoteConnectingDetail))
|
||||||
@ -341,6 +354,25 @@ actor GatewayEndpointStore {
|
|||||||
code: 1,
|
code: 1,
|
||||||
userInfo: [NSLocalizedDescriptionKey: "Remote mode is not enabled"])
|
userInfo: [NSLocalizedDescriptionKey: "Remote mode is not enabled"])
|
||||||
}
|
}
|
||||||
|
let root = ClawdbotConfigFile.loadDict()
|
||||||
|
if GatewayRemoteConfig.resolveTransport(root: root) == .direct {
|
||||||
|
guard let url = GatewayRemoteConfig.resolveGatewayUrl(root: root) else {
|
||||||
|
throw NSError(
|
||||||
|
domain: "GatewayEndpoint",
|
||||||
|
code: 1,
|
||||||
|
userInfo: [NSLocalizedDescriptionKey: "gateway.remote.url missing or invalid"])
|
||||||
|
}
|
||||||
|
guard let port = GatewayRemoteConfig.defaultPort(for: url),
|
||||||
|
let portInt = UInt16(exactly: port)
|
||||||
|
else {
|
||||||
|
throw NSError(
|
||||||
|
domain: "GatewayEndpoint",
|
||||||
|
code: 1,
|
||||||
|
userInfo: [NSLocalizedDescriptionKey: "Invalid gateway.remote.url port"])
|
||||||
|
}
|
||||||
|
self.logger.info("remote transport direct; skipping SSH tunnel")
|
||||||
|
return portInt
|
||||||
|
}
|
||||||
let config = try await self.ensureRemoteConfig(detail: Self.remoteConnectingDetail)
|
let config = try await self.ensureRemoteConfig(detail: Self.remoteConnectingDetail)
|
||||||
guard let portInt = config.0.port, let port = UInt16(exactly: portInt) else {
|
guard let portInt = config.0.port, let port = UInt16(exactly: portInt) else {
|
||||||
throw NSError(
|
throw NSError(
|
||||||
@ -401,6 +433,21 @@ actor GatewayEndpointStore {
|
|||||||
userInfo: [NSLocalizedDescriptionKey: "Remote mode is not enabled"])
|
userInfo: [NSLocalizedDescriptionKey: "Remote mode is not enabled"])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let root = ClawdbotConfigFile.loadDict()
|
||||||
|
if GatewayRemoteConfig.resolveTransport(root: root) == .direct {
|
||||||
|
guard let url = GatewayRemoteConfig.resolveGatewayUrl(root: root) else {
|
||||||
|
throw NSError(
|
||||||
|
domain: "GatewayEndpoint",
|
||||||
|
code: 1,
|
||||||
|
userInfo: [NSLocalizedDescriptionKey: "gateway.remote.url missing or invalid"])
|
||||||
|
}
|
||||||
|
let token = self.deps.token()
|
||||||
|
let password = self.deps.password()
|
||||||
|
self.cancelRemoteEnsure()
|
||||||
|
self.setState(.ready(mode: .remote, url: url, token: token, password: password))
|
||||||
|
return (url, token, password)
|
||||||
|
}
|
||||||
|
|
||||||
self.kickRemoteEnsureIfNeeded(detail: detail)
|
self.kickRemoteEnsureIfNeeded(detail: detail)
|
||||||
guard let ensure = self.remoteEnsure else {
|
guard let ensure = self.remoteEnsure else {
|
||||||
throw NSError(domain: "GatewayEndpoint", code: 1, userInfo: [NSLocalizedDescriptionKey: "Connecting…"])
|
throw NSError(domain: "GatewayEndpoint", code: 1, userInfo: [NSLocalizedDescriptionKey: "Connecting…"])
|
||||||
|
|||||||
64
apps/macos/Sources/Clawdbot/GatewayRemoteConfig.swift
Normal file
64
apps/macos/Sources/Clawdbot/GatewayRemoteConfig.swift
Normal file
@ -0,0 +1,64 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
enum GatewayRemoteConfig {
|
||||||
|
static func resolveTransport(root: [String: Any]) -> AppState.RemoteTransport {
|
||||||
|
guard let gateway = root["gateway"] as? [String: Any],
|
||||||
|
let remote = gateway["remote"] as? [String: Any],
|
||||||
|
let raw = remote["transport"] as? String
|
||||||
|
else {
|
||||||
|
return .ssh
|
||||||
|
}
|
||||||
|
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
||||||
|
return trimmed == AppState.RemoteTransport.direct.rawValue ? .direct : .ssh
|
||||||
|
}
|
||||||
|
|
||||||
|
static func resolveUrlString(root: [String: Any]) -> String? {
|
||||||
|
guard let gateway = root["gateway"] as? [String: Any],
|
||||||
|
let remote = gateway["remote"] as? [String: Any],
|
||||||
|
let urlRaw = remote["url"] as? String
|
||||||
|
else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
let trimmed = urlRaw.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
return trimmed.isEmpty ? nil : trimmed
|
||||||
|
}
|
||||||
|
|
||||||
|
static func resolveGatewayUrl(root: [String: Any]) -> URL? {
|
||||||
|
guard let raw = self.resolveUrlString(root: root) else { return nil }
|
||||||
|
return self.normalizeGatewayUrl(raw)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func normalizeGatewayUrlString(_ raw: String) -> String? {
|
||||||
|
self.normalizeGatewayUrl(raw)?.absoluteString
|
||||||
|
}
|
||||||
|
|
||||||
|
static func normalizeGatewayUrl(_ raw: String) -> URL? {
|
||||||
|
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
guard !trimmed.isEmpty, let url = URL(string: trimmed) else { return nil }
|
||||||
|
let scheme = url.scheme?.lowercased() ?? ""
|
||||||
|
guard scheme == "ws" || scheme == "wss" else { return nil }
|
||||||
|
let host = url.host?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||||
|
guard !host.isEmpty else { return nil }
|
||||||
|
if scheme == "ws", url.port == nil {
|
||||||
|
guard var components = URLComponents(url: url, resolvingAgainstBaseURL: false) else {
|
||||||
|
return url
|
||||||
|
}
|
||||||
|
components.port = 18789
|
||||||
|
return components.url
|
||||||
|
}
|
||||||
|
return url
|
||||||
|
}
|
||||||
|
|
||||||
|
static func defaultPort(for url: URL) -> Int? {
|
||||||
|
if let port = url.port { return port }
|
||||||
|
let scheme = url.scheme?.lowercased() ?? ""
|
||||||
|
switch scheme {
|
||||||
|
case "wss":
|
||||||
|
return 443
|
||||||
|
case "ws":
|
||||||
|
return 18789
|
||||||
|
default:
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -17,6 +17,7 @@ struct GeneralSettings: View {
|
|||||||
@State private var showRemoteAdvanced = false
|
@State private var showRemoteAdvanced = false
|
||||||
private let isPreview = ProcessInfo.processInfo.isPreview
|
private let isPreview = ProcessInfo.processInfo.isPreview
|
||||||
private var isNixMode: Bool { ProcessInfo.processInfo.isNixMode }
|
private var isNixMode: Bool { ProcessInfo.processInfo.isNixMode }
|
||||||
|
private var remoteLabelWidth: CGFloat { 88 }
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ScrollView(.vertical) {
|
ScrollView(.vertical) {
|
||||||
@ -104,7 +105,7 @@ struct GeneralSettings: View {
|
|||||||
Picker("Mode", selection: self.$state.connectionMode) {
|
Picker("Mode", selection: self.$state.connectionMode) {
|
||||||
Text("Not configured").tag(AppState.ConnectionMode.unconfigured)
|
Text("Not configured").tag(AppState.ConnectionMode.unconfigured)
|
||||||
Text("Local (this Mac)").tag(AppState.ConnectionMode.local)
|
Text("Local (this Mac)").tag(AppState.ConnectionMode.local)
|
||||||
Text("Remote over SSH").tag(AppState.ConnectionMode.remote)
|
Text("Remote (another host)").tag(AppState.ConnectionMode.remote)
|
||||||
}
|
}
|
||||||
.pickerStyle(.menu)
|
.pickerStyle(.menu)
|
||||||
.labelsHidden()
|
.labelsHidden()
|
||||||
@ -136,38 +137,28 @@ struct GeneralSettings: View {
|
|||||||
|
|
||||||
private var remoteCard: some View {
|
private var remoteCard: some View {
|
||||||
VStack(alignment: .leading, spacing: 10) {
|
VStack(alignment: .leading, spacing: 10) {
|
||||||
HStack(alignment: .center, spacing: 10) {
|
self.remoteTransportRow
|
||||||
Text("SSH")
|
|
||||||
.font(.callout.weight(.semibold))
|
if self.state.remoteTransport == .ssh {
|
||||||
.frame(width: 48, alignment: .leading)
|
self.remoteSshRow
|
||||||
TextField("user@host[:22]", text: self.$state.remoteTarget)
|
|
||||||
.textFieldStyle(.roundedBorder)
|
|
||||||
.frame(maxWidth: .infinity)
|
|
||||||
Button {
|
|
||||||
Task { await self.testRemote() }
|
|
||||||
} label: {
|
|
||||||
if self.remoteStatus == .checking {
|
|
||||||
ProgressView().controlSize(.small)
|
|
||||||
} else {
|
} else {
|
||||||
Text("Test remote")
|
self.remoteDirectRow
|
||||||
}
|
|
||||||
}
|
|
||||||
.buttonStyle(.borderedProminent)
|
|
||||||
.disabled(self.remoteStatus == .checking || self.state.remoteTarget
|
|
||||||
.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
GatewayDiscoveryInlineList(
|
GatewayDiscoveryInlineList(
|
||||||
discovery: self.gatewayDiscovery,
|
discovery: self.gatewayDiscovery,
|
||||||
currentTarget: self.state.remoteTarget)
|
currentTarget: self.state.remoteTarget,
|
||||||
|
currentUrl: self.state.remoteUrl,
|
||||||
|
transport: self.state.remoteTransport)
|
||||||
{ gateway in
|
{ gateway in
|
||||||
self.applyDiscoveredGateway(gateway)
|
self.applyDiscoveredGateway(gateway)
|
||||||
}
|
}
|
||||||
.padding(.leading, 58)
|
.padding(.leading, self.remoteLabelWidth + 10)
|
||||||
|
|
||||||
self.remoteStatusView
|
self.remoteStatusView
|
||||||
.padding(.leading, 58)
|
.padding(.leading, self.remoteLabelWidth + 10)
|
||||||
|
|
||||||
|
if self.state.remoteTransport == .ssh {
|
||||||
DisclosureGroup(isExpanded: self.$showRemoteAdvanced) {
|
DisclosureGroup(isExpanded: self.$showRemoteAdvanced) {
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
LabeledContent("Identity file") {
|
LabeledContent("Identity file") {
|
||||||
@ -191,6 +182,7 @@ struct GeneralSettings: View {
|
|||||||
Text("Advanced")
|
Text("Advanced")
|
||||||
.font(.callout.weight(.semibold))
|
.font(.callout.weight(.semibold))
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Diagnostics
|
// Diagnostics
|
||||||
VStack(alignment: .leading, spacing: 4) {
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
@ -219,16 +211,100 @@ struct GeneralSettings: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if self.state.remoteTransport == .ssh {
|
||||||
Text("Tip: enable Tailscale for stable remote access.")
|
Text("Tip: enable Tailscale for stable remote access.")
|
||||||
.font(.footnote)
|
.font(.footnote)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
|
} else {
|
||||||
|
Text("Tip: use Tailscale Serve so the gateway has a valid HTTPS cert.")
|
||||||
|
.font(.footnote)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.lineLimit(2)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.transition(.opacity)
|
.transition(.opacity)
|
||||||
.onAppear { self.gatewayDiscovery.start() }
|
.onAppear { self.gatewayDiscovery.start() }
|
||||||
.onDisappear { self.gatewayDiscovery.stop() }
|
.onDisappear { self.gatewayDiscovery.stop() }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private var remoteTransportRow: some View {
|
||||||
|
HStack(alignment: .center, spacing: 10) {
|
||||||
|
Text("Transport")
|
||||||
|
.font(.callout.weight(.semibold))
|
||||||
|
.frame(width: self.remoteLabelWidth, alignment: .leading)
|
||||||
|
Picker("Transport", selection: self.$state.remoteTransport) {
|
||||||
|
Text("SSH tunnel").tag(AppState.RemoteTransport.ssh)
|
||||||
|
Text("Direct (ws/wss)").tag(AppState.RemoteTransport.direct)
|
||||||
|
}
|
||||||
|
.pickerStyle(.segmented)
|
||||||
|
.frame(maxWidth: 320)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var remoteSshRow: some View {
|
||||||
|
let trimmedTarget = self.state.remoteTarget.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
let validationMessage = CommandResolver.sshTargetValidationMessage(trimmedTarget)
|
||||||
|
let canTest = !trimmedTarget.isEmpty && validationMessage == nil
|
||||||
|
|
||||||
|
return VStack(alignment: .leading, spacing: 4) {
|
||||||
|
HStack(alignment: .center, spacing: 10) {
|
||||||
|
Text("SSH target")
|
||||||
|
.font(.callout.weight(.semibold))
|
||||||
|
.frame(width: self.remoteLabelWidth, alignment: .leading)
|
||||||
|
TextField("user@host[:22]", text: self.$state.remoteTarget)
|
||||||
|
.textFieldStyle(.roundedBorder)
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
Button {
|
||||||
|
Task { await self.testRemote() }
|
||||||
|
} label: {
|
||||||
|
if self.remoteStatus == .checking {
|
||||||
|
ProgressView().controlSize(.small)
|
||||||
|
} else {
|
||||||
|
Text("Test remote")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.buttonStyle(.borderedProminent)
|
||||||
|
.disabled(self.remoteStatus == .checking || !canTest)
|
||||||
|
}
|
||||||
|
if let validationMessage {
|
||||||
|
Text(validationMessage)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.red)
|
||||||
|
.padding(.leading, self.remoteLabelWidth + 10)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var remoteDirectRow: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
|
HStack(alignment: .center, spacing: 10) {
|
||||||
|
Text("Gateway")
|
||||||
|
.font(.callout.weight(.semibold))
|
||||||
|
.frame(width: self.remoteLabelWidth, alignment: .leading)
|
||||||
|
TextField("wss://gateway.example.ts.net", text: self.$state.remoteUrl)
|
||||||
|
.textFieldStyle(.roundedBorder)
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
Button {
|
||||||
|
Task { await self.testRemote() }
|
||||||
|
} label: {
|
||||||
|
if self.remoteStatus == .checking {
|
||||||
|
ProgressView().controlSize(.small)
|
||||||
|
} else {
|
||||||
|
Text("Test remote")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.buttonStyle(.borderedProminent)
|
||||||
|
.disabled(self.remoteStatus == .checking || self.state.remoteUrl
|
||||||
|
.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
|
||||||
|
}
|
||||||
|
Text("Direct mode requires a ws:// or wss:// URL (Tailscale Serve uses wss://<magicdns>).")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.padding(.leading, self.remoteLabelWidth + 10)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private var controlStatusLine: String {
|
private var controlStatusLine: String {
|
||||||
switch ControlChannel.shared.state {
|
switch ControlChannel.shared.state {
|
||||||
case .connected: "Connected"
|
case .connected: "Connected"
|
||||||
@ -458,14 +534,32 @@ extension GeneralSettings {
|
|||||||
func testRemote() async {
|
func testRemote() async {
|
||||||
self.remoteStatus = .checking
|
self.remoteStatus = .checking
|
||||||
let settings = CommandResolver.connectionSettings()
|
let settings = CommandResolver.connectionSettings()
|
||||||
|
if self.state.remoteTransport == .direct {
|
||||||
|
let trimmedUrl = self.state.remoteUrl.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
guard !trimmedUrl.isEmpty else {
|
||||||
|
self.remoteStatus = .failed("Set a gateway URL first")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
guard Self.isValidWsUrl(trimmedUrl) else {
|
||||||
|
self.remoteStatus = .failed("Gateway URL must start with ws:// or wss://")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} else {
|
||||||
guard !settings.target.isEmpty else {
|
guard !settings.target.isEmpty else {
|
||||||
self.remoteStatus = .failed("Set an SSH target first")
|
self.remoteStatus = .failed("Set an SSH target first")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Step 1: basic SSH reachability check
|
// Step 1: basic SSH reachability check
|
||||||
|
guard let sshCommand = Self.sshCheckCommand(
|
||||||
|
target: settings.target,
|
||||||
|
identity: settings.identity)
|
||||||
|
else {
|
||||||
|
self.remoteStatus = .failed("SSH target is invalid")
|
||||||
|
return
|
||||||
|
}
|
||||||
let sshResult = await ShellExecutor.run(
|
let sshResult = await ShellExecutor.run(
|
||||||
command: Self.sshCheckCommand(target: settings.target, identity: settings.identity),
|
command: sshCommand,
|
||||||
cwd: nil,
|
cwd: nil,
|
||||||
env: nil,
|
env: nil,
|
||||||
timeout: 8)
|
timeout: 8)
|
||||||
@ -474,8 +568,9 @@ extension GeneralSettings {
|
|||||||
self.remoteStatus = .failed(self.formatSSHFailure(sshResult, target: settings.target))
|
self.remoteStatus = .failed(self.formatSSHFailure(sshResult, target: settings.target))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Step 2: control channel health over tunnel
|
// Step 2: control channel health check
|
||||||
let originalMode = AppStateStore.shared.connectionMode
|
let originalMode = AppStateStore.shared.connectionMode
|
||||||
do {
|
do {
|
||||||
try await ControlChannel.shared.configure(mode: .remote(
|
try await ControlChannel.shared.configure(mode: .remote(
|
||||||
@ -502,20 +597,28 @@ extension GeneralSettings {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static func sshCheckCommand(target: String, identity: String) -> [String] {
|
private static func isValidWsUrl(_ raw: String) -> Bool {
|
||||||
var args: [String] = [
|
guard let url = URL(string: raw.trimmingCharacters(in: .whitespacesAndNewlines)) else { return false }
|
||||||
"/usr/bin/ssh",
|
let scheme = url.scheme?.lowercased() ?? ""
|
||||||
|
guard scheme == "ws" || scheme == "wss" else { return false }
|
||||||
|
let host = url.host?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||||
|
return !host.isEmpty
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func sshCheckCommand(target: String, identity: String) -> [String]? {
|
||||||
|
guard let parsed = CommandResolver.parseSSHTarget(target) else { return nil }
|
||||||
|
let options = [
|
||||||
"-o", "BatchMode=yes",
|
"-o", "BatchMode=yes",
|
||||||
"-o", "ConnectTimeout=5",
|
"-o", "ConnectTimeout=5",
|
||||||
"-o", "StrictHostKeyChecking=accept-new",
|
"-o", "StrictHostKeyChecking=accept-new",
|
||||||
"-o", "UpdateHostKeys=yes",
|
"-o", "UpdateHostKeys=yes",
|
||||||
]
|
]
|
||||||
if !identity.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
let args = CommandResolver.sshArguments(
|
||||||
args.append(contentsOf: ["-i", identity])
|
target: parsed,
|
||||||
}
|
identity: identity,
|
||||||
args.append(target)
|
options: options,
|
||||||
args.append("echo ok")
|
remoteCommand: ["echo", "ok"])
|
||||||
return args
|
return ["/usr/bin/ssh"] + args
|
||||||
}
|
}
|
||||||
|
|
||||||
private func formatSSHFailure(_ response: Response, target: String) -> String {
|
private func formatSSHFailure(_ response: Response, target: String) -> String {
|
||||||
@ -570,6 +673,11 @@ extension GeneralSettings {
|
|||||||
let host = gateway.tailnetDns ?? gateway.lanHost
|
let host = gateway.tailnetDns ?? gateway.lanHost
|
||||||
guard let host else { return }
|
guard let host else { return }
|
||||||
let user = NSUserName()
|
let user = NSUserName()
|
||||||
|
if self.state.remoteTransport == .direct {
|
||||||
|
if let url = GatewayDiscoveryHelpers.directUrl(for: gateway) {
|
||||||
|
self.state.remoteUrl = url
|
||||||
|
}
|
||||||
|
} else {
|
||||||
self.state.remoteTarget = GatewayDiscoveryModel.buildSSHTarget(
|
self.state.remoteTarget = GatewayDiscoveryModel.buildSSHTarget(
|
||||||
user: user,
|
user: user,
|
||||||
host: host,
|
host: host,
|
||||||
@ -578,6 +686,7 @@ extension GeneralSettings {
|
|||||||
ClawdbotConfigFile.setRemoteGatewayUrl(host: host, port: gateway.gatewayPort)
|
ClawdbotConfigFile.setRemoteGatewayUrl(host: host, port: gateway.gatewayPort)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private func healthAgeString(_ ms: Double?) -> String {
|
private func healthAgeString(_ ms: Double?) -> String {
|
||||||
guard let ms else { return "unknown" }
|
guard let ms else { return "unknown" }
|
||||||
@ -598,7 +707,9 @@ extension GeneralSettings {
|
|||||||
static func exerciseForTesting() {
|
static func exerciseForTesting() {
|
||||||
let state = AppState(preview: true)
|
let state = AppState(preview: true)
|
||||||
state.connectionMode = .remote
|
state.connectionMode = .remote
|
||||||
|
state.remoteTransport = .ssh
|
||||||
state.remoteTarget = "user@host:2222"
|
state.remoteTarget = "user@host:2222"
|
||||||
|
state.remoteUrl = "wss://gateway.example.ts.net"
|
||||||
state.remoteIdentity = "/tmp/id_ed25519"
|
state.remoteIdentity = "/tmp/id_ed25519"
|
||||||
state.remoteProjectRoot = "/tmp/clawdbot"
|
state.remoteProjectRoot = "/tmp/clawdbot"
|
||||||
state.remoteCliPath = "/tmp/clawdbot"
|
state.remoteCliPath = "/tmp/clawdbot"
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
import AppKit
|
import AppKit
|
||||||
|
import Foundation
|
||||||
import Observation
|
import Observation
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
@ -517,12 +518,26 @@ extension MenuSessionsInjector {
|
|||||||
switch mode {
|
switch mode {
|
||||||
case .remote:
|
case .remote:
|
||||||
platform = "remote"
|
platform = "remote"
|
||||||
|
if AppStateStore.shared.remoteTransport == .direct {
|
||||||
|
let trimmedUrl = AppStateStore.shared.remoteUrl
|
||||||
|
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
if let url = URL(string: trimmedUrl), let urlHost = url.host, !urlHost.isEmpty {
|
||||||
|
if let port = url.port {
|
||||||
|
host = "\(urlHost):\(port)"
|
||||||
|
} else {
|
||||||
|
host = urlHost
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
host = trimmedUrl.nonEmpty
|
||||||
|
}
|
||||||
|
} else {
|
||||||
let target = AppStateStore.shared.remoteTarget
|
let target = AppStateStore.shared.remoteTarget
|
||||||
if let parsed = CommandResolver.parseSSHTarget(target) {
|
if let parsed = CommandResolver.parseSSHTarget(target) {
|
||||||
host = parsed.port == 22 ? parsed.host : "\(parsed.host):\(parsed.port)"
|
host = parsed.port == 22 ? parsed.host : "\(parsed.host):\(parsed.port)"
|
||||||
} else {
|
} else {
|
||||||
host = target.nonEmpty
|
host = target.nonEmpty
|
||||||
}
|
}
|
||||||
|
}
|
||||||
case .local:
|
case .local:
|
||||||
platform = "local"
|
platform = "local"
|
||||||
host = GatewayConnectivityCoordinator.shared.localEndpointHostLabel ?? "127.0.0.1:\(port)"
|
host = GatewayConnectivityCoordinator.shared.localEndpointHostLabel ?? "127.0.0.1:\(port)"
|
||||||
|
|||||||
@ -559,22 +559,21 @@ final class NodePairingApprovalPrompter {
|
|||||||
let process = Process()
|
let process = Process()
|
||||||
process.executableURL = URL(fileURLWithPath: "/usr/bin/ssh")
|
process.executableURL = URL(fileURLWithPath: "/usr/bin/ssh")
|
||||||
|
|
||||||
var args = [
|
let options = [
|
||||||
"-o",
|
"-o", "BatchMode=yes",
|
||||||
"BatchMode=yes",
|
"-o", "ConnectTimeout=5",
|
||||||
"-o",
|
"-o", "NumberOfPasswordPrompts=0",
|
||||||
"ConnectTimeout=5",
|
"-o", "PreferredAuthentications=publickey",
|
||||||
"-o",
|
"-o", "StrictHostKeyChecking=accept-new",
|
||||||
"NumberOfPasswordPrompts=0",
|
|
||||||
"-o",
|
|
||||||
"PreferredAuthentications=publickey",
|
|
||||||
"-o",
|
|
||||||
"StrictHostKeyChecking=accept-new",
|
|
||||||
]
|
]
|
||||||
if port > 0, port != 22 {
|
guard let target = CommandResolver.makeSSHTarget(user: user, host: host, port: port) else {
|
||||||
args.append(contentsOf: ["-p", String(port)])
|
return false
|
||||||
}
|
}
|
||||||
args.append(contentsOf: ["-l", user, host, "/usr/bin/true"])
|
let args = CommandResolver.sshArguments(
|
||||||
|
target: target,
|
||||||
|
identity: "",
|
||||||
|
options: options,
|
||||||
|
remoteCommand: ["/usr/bin/true"])
|
||||||
process.arguments = args
|
process.arguments = args
|
||||||
let pipe = Pipe()
|
let pipe = Pipe()
|
||||||
process.standardOutput = pipe
|
process.standardOutput = pipe
|
||||||
|
|||||||
@ -25,7 +25,11 @@ extension OnboardingView {
|
|||||||
self.preferredGatewayID = gateway.stableID
|
self.preferredGatewayID = gateway.stableID
|
||||||
GatewayDiscoveryPreferences.setPreferredStableID(gateway.stableID)
|
GatewayDiscoveryPreferences.setPreferredStableID(gateway.stableID)
|
||||||
|
|
||||||
if let host = gateway.tailnetDns ?? gateway.lanHost {
|
if self.state.remoteTransport == .direct {
|
||||||
|
if let url = GatewayDiscoveryHelpers.directUrl(for: gateway) {
|
||||||
|
self.state.remoteUrl = url
|
||||||
|
}
|
||||||
|
} else if let host = GatewayDiscoveryHelpers.sanitizedTailnetHost(gateway.tailnetDns) ?? gateway.lanHost {
|
||||||
let user = NSUserName()
|
let user = NSUserName()
|
||||||
self.state.remoteTarget = GatewayDiscoveryModel.buildSSHTarget(
|
self.state.remoteTarget = GatewayDiscoveryModel.buildSSHTarget(
|
||||||
user: user,
|
user: user,
|
||||||
|
|||||||
@ -176,6 +176,28 @@ extension OnboardingView {
|
|||||||
|
|
||||||
VStack(alignment: .leading, spacing: 10) {
|
VStack(alignment: .leading, spacing: 10) {
|
||||||
Grid(alignment: .leading, horizontalSpacing: 12, verticalSpacing: 8) {
|
Grid(alignment: .leading, horizontalSpacing: 12, verticalSpacing: 8) {
|
||||||
|
GridRow {
|
||||||
|
Text("Transport")
|
||||||
|
.font(.callout.weight(.semibold))
|
||||||
|
.frame(width: labelWidth, alignment: .leading)
|
||||||
|
Picker("Transport", selection: self.$state.remoteTransport) {
|
||||||
|
Text("SSH tunnel").tag(AppState.RemoteTransport.ssh)
|
||||||
|
Text("Direct (ws/wss)").tag(AppState.RemoteTransport.direct)
|
||||||
|
}
|
||||||
|
.pickerStyle(.segmented)
|
||||||
|
.frame(width: fieldWidth)
|
||||||
|
}
|
||||||
|
if self.state.remoteTransport == .direct {
|
||||||
|
GridRow {
|
||||||
|
Text("Gateway URL")
|
||||||
|
.font(.callout.weight(.semibold))
|
||||||
|
.frame(width: labelWidth, alignment: .leading)
|
||||||
|
TextField("wss://gateway.example.ts.net", text: self.$state.remoteUrl)
|
||||||
|
.textFieldStyle(.roundedBorder)
|
||||||
|
.frame(width: fieldWidth)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if self.state.remoteTransport == .ssh {
|
||||||
GridRow {
|
GridRow {
|
||||||
Text("SSH target")
|
Text("SSH target")
|
||||||
.font(.callout.weight(.semibold))
|
.font(.callout.weight(.semibold))
|
||||||
@ -184,6 +206,16 @@ extension OnboardingView {
|
|||||||
.textFieldStyle(.roundedBorder)
|
.textFieldStyle(.roundedBorder)
|
||||||
.frame(width: fieldWidth)
|
.frame(width: fieldWidth)
|
||||||
}
|
}
|
||||||
|
if let message = CommandResolver.sshTargetValidationMessage(self.state.remoteTarget) {
|
||||||
|
GridRow {
|
||||||
|
Text("")
|
||||||
|
.frame(width: labelWidth, alignment: .leading)
|
||||||
|
Text(message)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.red)
|
||||||
|
.frame(width: fieldWidth, alignment: .leading)
|
||||||
|
}
|
||||||
|
}
|
||||||
GridRow {
|
GridRow {
|
||||||
Text("Identity file")
|
Text("Identity file")
|
||||||
.font(.callout.weight(.semibold))
|
.font(.callout.weight(.semibold))
|
||||||
@ -211,8 +243,11 @@ extension OnboardingView {
|
|||||||
.frame(width: fieldWidth)
|
.frame(width: fieldWidth)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Text("Tip: keep Tailscale enabled so your gateway stays reachable.")
|
Text(self.state.remoteTransport == .direct
|
||||||
|
? "Tip: use Tailscale Serve so the gateway has a valid HTTPS cert."
|
||||||
|
: "Tip: keep Tailscale enabled so your gateway stays reachable.")
|
||||||
.font(.footnote)
|
.font(.footnote)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
@ -225,7 +260,10 @@ extension OnboardingView {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func gatewaySubtitle(for gateway: GatewayDiscoveryModel.DiscoveredGateway) -> String? {
|
func gatewaySubtitle(for gateway: GatewayDiscoveryModel.DiscoveredGateway) -> String? {
|
||||||
if let host = gateway.tailnetDns ?? gateway.lanHost {
|
if self.state.remoteTransport == .direct {
|
||||||
|
return GatewayDiscoveryHelpers.directUrl(for: gateway) ?? "Gateway pairing only"
|
||||||
|
}
|
||||||
|
if let host = GatewayDiscoveryHelpers.sanitizedTailnetHost(gateway.tailnetDns) ?? gateway.lanHost {
|
||||||
let portSuffix = gateway.sshPort != 22 ? " · ssh \(gateway.sshPort)" : ""
|
let portSuffix = gateway.sshPort != 22 ? " · ssh \(gateway.sshPort)" : ""
|
||||||
return "\(host)\(portSuffix)"
|
return "\(host)\(portSuffix)"
|
||||||
}
|
}
|
||||||
|
|||||||
@ -70,7 +70,7 @@ final class RemotePortTunnel {
|
|||||||
"ssh tunnel using default remote port " +
|
"ssh tunnel using default remote port " +
|
||||||
"host=\(sshHost, privacy: .public) port=\(remotePort, privacy: .public)")
|
"host=\(sshHost, privacy: .public) port=\(remotePort, privacy: .public)")
|
||||||
}
|
}
|
||||||
var args: [String] = [
|
let options: [String] = [
|
||||||
"-o", "BatchMode=yes",
|
"-o", "BatchMode=yes",
|
||||||
"-o", "ExitOnForwardFailure=yes",
|
"-o", "ExitOnForwardFailure=yes",
|
||||||
"-o", "StrictHostKeyChecking=accept-new",
|
"-o", "StrictHostKeyChecking=accept-new",
|
||||||
@ -81,16 +81,11 @@ final class RemotePortTunnel {
|
|||||||
"-N",
|
"-N",
|
||||||
"-L", "\(localPort):127.0.0.1:\(resolvedRemotePort)",
|
"-L", "\(localPort):127.0.0.1:\(resolvedRemotePort)",
|
||||||
]
|
]
|
||||||
if parsed.port > 0 { args.append(contentsOf: ["-p", String(parsed.port)]) }
|
|
||||||
let identity = settings.identity.trimmingCharacters(in: .whitespacesAndNewlines)
|
let identity = settings.identity.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
if !identity.isEmpty {
|
let args = CommandResolver.sshArguments(
|
||||||
// Only use IdentitiesOnly when an explicit identity file is provided.
|
target: parsed,
|
||||||
// This allows 1Password SSH agent and other SSH agents to provide keys.
|
identity: identity,
|
||||||
args.append(contentsOf: ["-o", "IdentitiesOnly=yes"])
|
options: options)
|
||||||
args.append(contentsOf: ["-i", identity])
|
|
||||||
}
|
|
||||||
let userHost = parsed.user.map { "\($0)@\(parsed.host)" } ?? parsed.host
|
|
||||||
args.append(userHost)
|
|
||||||
|
|
||||||
let process = Process()
|
let process = Process()
|
||||||
process.executableURL = URL(fileURLWithPath: "/usr/bin/ssh")
|
process.executableURL = URL(fileURLWithPath: "/usr/bin/ssh")
|
||||||
|
|||||||
@ -15,9 +15,9 @@
|
|||||||
<key>CFBundlePackageType</key>
|
<key>CFBundlePackageType</key>
|
||||||
<string>APPL</string>
|
<string>APPL</string>
|
||||||
<key>CFBundleShortVersionString</key>
|
<key>CFBundleShortVersionString</key>
|
||||||
<string>2026.1.21</string>
|
<string>2026.1.25</string>
|
||||||
<key>CFBundleVersion</key>
|
<key>CFBundleVersion</key>
|
||||||
<string>202601210</string>
|
<string>202601250</string>
|
||||||
<key>CFBundleIconFile</key>
|
<key>CFBundleIconFile</key>
|
||||||
<string>Clawdbot</string>
|
<string>Clawdbot</string>
|
||||||
<key>CFBundleURLTypes</key>
|
<key>CFBundleURLTypes</key>
|
||||||
|
|||||||
@ -123,12 +123,12 @@ struct SystemRunSettingsView: View {
|
|||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
} else {
|
} else {
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
ForEach(Array(self.model.entries.enumerated()), id: \.offset) { index, _ in
|
ForEach(self.model.entries, id: \.id) { entry in
|
||||||
ExecAllowlistRow(
|
ExecAllowlistRow(
|
||||||
entry: Binding(
|
entry: Binding(
|
||||||
get: { self.model.entries[index] },
|
get: { self.model.entry(for: entry.id) ?? entry },
|
||||||
set: { self.model.updateEntry($0, at: index) }),
|
set: { self.model.updateEntry($0, id: entry.id) }),
|
||||||
onRemove: { self.model.removeEntry(at: index) })
|
onRemove: { self.model.removeEntry(id: entry.id) })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -373,20 +373,24 @@ final class ExecApprovalsSettingsModel {
|
|||||||
ExecApprovalsStore.updateAllowlist(agentId: self.selectedAgentId, allowlist: self.entries)
|
ExecApprovalsStore.updateAllowlist(agentId: self.selectedAgentId, allowlist: self.entries)
|
||||||
}
|
}
|
||||||
|
|
||||||
func updateEntry(_ entry: ExecAllowlistEntry, at index: Int) {
|
func updateEntry(_ entry: ExecAllowlistEntry, id: UUID) {
|
||||||
guard !self.isDefaultsScope else { return }
|
guard !self.isDefaultsScope else { return }
|
||||||
guard self.entries.indices.contains(index) else { return }
|
guard let index = self.entries.firstIndex(where: { $0.id == id }) else { return }
|
||||||
self.entries[index] = entry
|
self.entries[index] = entry
|
||||||
ExecApprovalsStore.updateAllowlist(agentId: self.selectedAgentId, allowlist: self.entries)
|
ExecApprovalsStore.updateAllowlist(agentId: self.selectedAgentId, allowlist: self.entries)
|
||||||
}
|
}
|
||||||
|
|
||||||
func removeEntry(at index: Int) {
|
func removeEntry(id: UUID) {
|
||||||
guard !self.isDefaultsScope else { return }
|
guard !self.isDefaultsScope else { return }
|
||||||
guard self.entries.indices.contains(index) else { return }
|
guard let index = self.entries.firstIndex(where: { $0.id == id }) else { return }
|
||||||
self.entries.remove(at: index)
|
self.entries.remove(at: index)
|
||||||
ExecApprovalsStore.updateAllowlist(agentId: self.selectedAgentId, allowlist: self.entries)
|
ExecApprovalsStore.updateAllowlist(agentId: self.selectedAgentId, allowlist: self.entries)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func entry(for id: UUID) -> ExecAllowlistEntry? {
|
||||||
|
self.entries.first(where: { $0.id == id })
|
||||||
|
}
|
||||||
|
|
||||||
func refreshSkillBins(force: Bool = false) async {
|
func refreshSkillBins(force: Bool = false) async {
|
||||||
guard self.autoAllowSkills else {
|
guard self.autoAllowSkills else {
|
||||||
self.skillBins = []
|
self.skillBins = []
|
||||||
|
|||||||
@ -4,6 +4,8 @@ func sanitizeVoiceWakeTriggers(_ words: [String]) -> [String] {
|
|||||||
let cleaned = words
|
let cleaned = words
|
||||||
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
|
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
|
||||||
.filter { !$0.isEmpty }
|
.filter { !$0.isEmpty }
|
||||||
|
.prefix(voiceWakeMaxWords)
|
||||||
|
.map { String($0.prefix(voiceWakeMaxWordLength)) }
|
||||||
return cleaned.isEmpty ? defaultVoiceWakeTriggers : cleaned
|
return cleaned.isEmpty ? defaultVoiceWakeTriggers : cleaned
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -21,6 +21,7 @@ struct VoiceWakeSettings: View {
|
|||||||
@State private var micObserver = AudioInputDeviceObserver()
|
@State private var micObserver = AudioInputDeviceObserver()
|
||||||
@State private var micRefreshTask: Task<Void, Never>?
|
@State private var micRefreshTask: Task<Void, Never>?
|
||||||
@State private var availableLocales: [Locale] = []
|
@State private var availableLocales: [Locale] = []
|
||||||
|
@State private var triggerEntries: [TriggerEntry] = []
|
||||||
private let fieldLabelWidth: CGFloat = 140
|
private let fieldLabelWidth: CGFloat = 140
|
||||||
private let controlWidth: CGFloat = 240
|
private let controlWidth: CGFloat = 240
|
||||||
private let isPreview = ProcessInfo.processInfo.isPreview
|
private let isPreview = ProcessInfo.processInfo.isPreview
|
||||||
@ -31,9 +32,9 @@ struct VoiceWakeSettings: View {
|
|||||||
var id: String { self.uid }
|
var id: String { self.uid }
|
||||||
}
|
}
|
||||||
|
|
||||||
private struct IndexedWord: Identifiable {
|
private struct TriggerEntry: Identifiable {
|
||||||
let id: Int
|
let id: UUID
|
||||||
let value: String
|
var value: String
|
||||||
}
|
}
|
||||||
|
|
||||||
private var voiceWakeBinding: Binding<Bool> {
|
private var voiceWakeBinding: Binding<Bool> {
|
||||||
@ -105,6 +106,7 @@ struct VoiceWakeSettings: View {
|
|||||||
.onAppear {
|
.onAppear {
|
||||||
guard !self.isPreview else { return }
|
guard !self.isPreview else { return }
|
||||||
self.startMicObserver()
|
self.startMicObserver()
|
||||||
|
self.loadTriggerEntries()
|
||||||
}
|
}
|
||||||
.onChange(of: self.state.voiceWakeMicID) { _, _ in
|
.onChange(of: self.state.voiceWakeMicID) { _, _ in
|
||||||
guard !self.isPreview else { return }
|
guard !self.isPreview else { return }
|
||||||
@ -122,8 +124,10 @@ struct VoiceWakeSettings: View {
|
|||||||
self.micRefreshTask = nil
|
self.micRefreshTask = nil
|
||||||
Task { await self.meter.stop() }
|
Task { await self.meter.stop() }
|
||||||
self.micObserver.stop()
|
self.micObserver.stop()
|
||||||
|
self.syncTriggerEntriesToState()
|
||||||
} else {
|
} else {
|
||||||
self.startMicObserver()
|
self.startMicObserver()
|
||||||
|
self.loadTriggerEntries()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.onDisappear {
|
.onDisappear {
|
||||||
@ -136,11 +140,16 @@ struct VoiceWakeSettings: View {
|
|||||||
self.micRefreshTask = nil
|
self.micRefreshTask = nil
|
||||||
self.micObserver.stop()
|
self.micObserver.stop()
|
||||||
Task { await self.meter.stop() }
|
Task { await self.meter.stop() }
|
||||||
|
self.syncTriggerEntriesToState()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private var indexedWords: [IndexedWord] {
|
private func loadTriggerEntries() {
|
||||||
self.state.swabbleTriggerWords.enumerated().map { IndexedWord(id: $0.offset, value: $0.element) }
|
self.triggerEntries = self.state.swabbleTriggerWords.map { TriggerEntry(id: UUID(), value: $0) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private func syncTriggerEntriesToState() {
|
||||||
|
self.state.swabbleTriggerWords = self.triggerEntries.map(\.value)
|
||||||
}
|
}
|
||||||
|
|
||||||
private var triggerTable: some View {
|
private var triggerTable: some View {
|
||||||
@ -154,29 +163,42 @@ struct VoiceWakeSettings: View {
|
|||||||
} label: {
|
} label: {
|
||||||
Label("Add word", systemImage: "plus")
|
Label("Add word", systemImage: "plus")
|
||||||
}
|
}
|
||||||
.disabled(self.state.swabbleTriggerWords
|
.disabled(self.triggerEntries
|
||||||
.contains(where: { $0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }))
|
.contains(where: { $0.value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }))
|
||||||
|
|
||||||
Button("Reset defaults") { self.state.swabbleTriggerWords = defaultVoiceWakeTriggers }
|
Button("Reset defaults") {
|
||||||
|
self.triggerEntries = defaultVoiceWakeTriggers.map { TriggerEntry(id: UUID(), value: $0) }
|
||||||
|
self.syncTriggerEntriesToState()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Table(self.indexedWords) {
|
VStack(spacing: 0) {
|
||||||
TableColumn("Word") { row in
|
ForEach(self.$triggerEntries) { $entry in
|
||||||
TextField("Wake word", text: self.binding(for: row.id))
|
HStack(spacing: 8) {
|
||||||
|
TextField("Wake word", text: $entry.value)
|
||||||
.textFieldStyle(.roundedBorder)
|
.textFieldStyle(.roundedBorder)
|
||||||
|
.onSubmit {
|
||||||
|
self.syncTriggerEntriesToState()
|
||||||
}
|
}
|
||||||
TableColumn("") { row in
|
|
||||||
Button {
|
Button {
|
||||||
self.removeWord(at: row.id)
|
self.removeWord(id: entry.id)
|
||||||
} label: {
|
} label: {
|
||||||
Image(systemName: "trash")
|
Image(systemName: "trash")
|
||||||
}
|
}
|
||||||
.buttonStyle(.borderless)
|
.buttonStyle(.borderless)
|
||||||
.help("Remove trigger word")
|
.help("Remove trigger word")
|
||||||
|
.frame(width: 24)
|
||||||
}
|
}
|
||||||
.width(36)
|
.padding(8)
|
||||||
|
|
||||||
|
if entry.id != self.triggerEntries.last?.id {
|
||||||
|
Divider()
|
||||||
}
|
}
|
||||||
.frame(minHeight: 180)
|
}
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, minHeight: 180, alignment: .topLeading)
|
||||||
|
.background(Color(nsColor: .textBackgroundColor))
|
||||||
.clipShape(RoundedRectangle(cornerRadius: 6))
|
.clipShape(RoundedRectangle(cornerRadius: 6))
|
||||||
.overlay(
|
.overlay(
|
||||||
RoundedRectangle(cornerRadius: 6)
|
RoundedRectangle(cornerRadius: 6)
|
||||||
@ -211,24 +233,12 @@ struct VoiceWakeSettings: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func addWord() {
|
private func addWord() {
|
||||||
self.state.swabbleTriggerWords.append("")
|
self.triggerEntries.append(TriggerEntry(id: UUID(), value: ""))
|
||||||
}
|
}
|
||||||
|
|
||||||
private func removeWord(at index: Int) {
|
private func removeWord(id: UUID) {
|
||||||
guard self.state.swabbleTriggerWords.indices.contains(index) else { return }
|
self.triggerEntries.removeAll { $0.id == id }
|
||||||
self.state.swabbleTriggerWords.remove(at: index)
|
self.syncTriggerEntriesToState()
|
||||||
}
|
|
||||||
|
|
||||||
private func binding(for index: Int) -> Binding<String> {
|
|
||||||
Binding(
|
|
||||||
get: {
|
|
||||||
guard self.state.swabbleTriggerWords.indices.contains(index) else { return "" }
|
|
||||||
return self.state.swabbleTriggerWords[index]
|
|
||||||
},
|
|
||||||
set: { newValue in
|
|
||||||
guard self.state.swabbleTriggerWords.indices.contains(index) else { return }
|
|
||||||
self.state.swabbleTriggerWords[index] = newValue
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func toggleTest() {
|
private func toggleTest() {
|
||||||
@ -638,13 +648,14 @@ extension VoiceWakeSettings {
|
|||||||
state.voicePushToTalkEnabled = true
|
state.voicePushToTalkEnabled = true
|
||||||
state.swabbleTriggerWords = ["Claude", "Hey"]
|
state.swabbleTriggerWords = ["Claude", "Hey"]
|
||||||
|
|
||||||
let view = VoiceWakeSettings(state: state, isActive: true)
|
var view = VoiceWakeSettings(state: state, isActive: true)
|
||||||
view.availableMics = [AudioInputDevice(uid: "mic-1", name: "Built-in")]
|
view.availableMics = [AudioInputDevice(uid: "mic-1", name: "Built-in")]
|
||||||
view.availableLocales = [Locale(identifier: "en_US")]
|
view.availableLocales = [Locale(identifier: "en_US")]
|
||||||
view.meterLevel = 0.42
|
view.meterLevel = 0.42
|
||||||
view.meterError = "No input"
|
view.meterError = "No input"
|
||||||
view.testState = .detected("ok")
|
view.testState = .detected("ok")
|
||||||
view.isTesting = true
|
view.isTesting = true
|
||||||
|
view.triggerEntries = [TriggerEntry(id: UUID(), value: "Claude")]
|
||||||
|
|
||||||
_ = view.body
|
_ = view.body
|
||||||
_ = view.localePicker
|
_ = view.localePicker
|
||||||
@ -654,8 +665,9 @@ extension VoiceWakeSettings {
|
|||||||
_ = view.chimeSection
|
_ = view.chimeSection
|
||||||
|
|
||||||
view.addWord()
|
view.addWord()
|
||||||
_ = view.binding(for: 0).wrappedValue
|
if let entryId = view.triggerEntries.first?.id {
|
||||||
view.removeWord(at: 0)
|
view.removeWord(id: entryId)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
|
|||||||
@ -385,6 +385,7 @@ public struct SendParams: Codable, Sendable {
|
|||||||
public let to: String
|
public let to: String
|
||||||
public let message: String
|
public let message: String
|
||||||
public let mediaurl: String?
|
public let mediaurl: String?
|
||||||
|
public let mediaurls: [String]?
|
||||||
public let gifplayback: Bool?
|
public let gifplayback: Bool?
|
||||||
public let channel: String?
|
public let channel: String?
|
||||||
public let accountid: String?
|
public let accountid: String?
|
||||||
@ -395,6 +396,7 @@ public struct SendParams: Codable, Sendable {
|
|||||||
to: String,
|
to: String,
|
||||||
message: String,
|
message: String,
|
||||||
mediaurl: String?,
|
mediaurl: String?,
|
||||||
|
mediaurls: [String]?,
|
||||||
gifplayback: Bool?,
|
gifplayback: Bool?,
|
||||||
channel: String?,
|
channel: String?,
|
||||||
accountid: String?,
|
accountid: String?,
|
||||||
@ -404,6 +406,7 @@ public struct SendParams: Codable, Sendable {
|
|||||||
self.to = to
|
self.to = to
|
||||||
self.message = message
|
self.message = message
|
||||||
self.mediaurl = mediaurl
|
self.mediaurl = mediaurl
|
||||||
|
self.mediaurls = mediaurls
|
||||||
self.gifplayback = gifplayback
|
self.gifplayback = gifplayback
|
||||||
self.channel = channel
|
self.channel = channel
|
||||||
self.accountid = accountid
|
self.accountid = accountid
|
||||||
@ -414,6 +417,7 @@ public struct SendParams: Codable, Sendable {
|
|||||||
case to
|
case to
|
||||||
case message
|
case message
|
||||||
case mediaurl = "mediaUrl"
|
case mediaurl = "mediaUrl"
|
||||||
|
case mediaurls = "mediaUrls"
|
||||||
case gifplayback = "gifPlayback"
|
case gifplayback = "gifPlayback"
|
||||||
case channel
|
case channel
|
||||||
case accountid = "accountId"
|
case accountid = "accountId"
|
||||||
@ -478,6 +482,9 @@ public struct AgentParams: Codable, Sendable {
|
|||||||
public let accountid: String?
|
public let accountid: String?
|
||||||
public let replyaccountid: String?
|
public let replyaccountid: String?
|
||||||
public let threadid: String?
|
public let threadid: String?
|
||||||
|
public let groupid: String?
|
||||||
|
public let groupchannel: String?
|
||||||
|
public let groupspace: String?
|
||||||
public let timeout: Int?
|
public let timeout: Int?
|
||||||
public let lane: String?
|
public let lane: String?
|
||||||
public let extrasystemprompt: String?
|
public let extrasystemprompt: String?
|
||||||
@ -500,6 +507,9 @@ public struct AgentParams: Codable, Sendable {
|
|||||||
accountid: String?,
|
accountid: String?,
|
||||||
replyaccountid: String?,
|
replyaccountid: String?,
|
||||||
threadid: String?,
|
threadid: String?,
|
||||||
|
groupid: String?,
|
||||||
|
groupchannel: String?,
|
||||||
|
groupspace: String?,
|
||||||
timeout: Int?,
|
timeout: Int?,
|
||||||
lane: String?,
|
lane: String?,
|
||||||
extrasystemprompt: String?,
|
extrasystemprompt: String?,
|
||||||
@ -521,6 +531,9 @@ public struct AgentParams: Codable, Sendable {
|
|||||||
self.accountid = accountid
|
self.accountid = accountid
|
||||||
self.replyaccountid = replyaccountid
|
self.replyaccountid = replyaccountid
|
||||||
self.threadid = threadid
|
self.threadid = threadid
|
||||||
|
self.groupid = groupid
|
||||||
|
self.groupchannel = groupchannel
|
||||||
|
self.groupspace = groupspace
|
||||||
self.timeout = timeout
|
self.timeout = timeout
|
||||||
self.lane = lane
|
self.lane = lane
|
||||||
self.extrasystemprompt = extrasystemprompt
|
self.extrasystemprompt = extrasystemprompt
|
||||||
@ -543,6 +556,9 @@ public struct AgentParams: Codable, Sendable {
|
|||||||
case accountid = "accountId"
|
case accountid = "accountId"
|
||||||
case replyaccountid = "replyAccountId"
|
case replyaccountid = "replyAccountId"
|
||||||
case threadid = "threadId"
|
case threadid = "threadId"
|
||||||
|
case groupid = "groupId"
|
||||||
|
case groupchannel = "groupChannel"
|
||||||
|
case groupspace = "groupSpace"
|
||||||
case timeout
|
case timeout
|
||||||
case lane
|
case lane
|
||||||
case extrasystemprompt = "extraSystemPrompt"
|
case extrasystemprompt = "extraSystemPrompt"
|
||||||
@ -948,6 +964,7 @@ public struct SessionsPreviewParams: Codable, Sendable {
|
|||||||
|
|
||||||
public struct SessionsResolveParams: Codable, Sendable {
|
public struct SessionsResolveParams: Codable, Sendable {
|
||||||
public let key: String?
|
public let key: String?
|
||||||
|
public let sessionid: String?
|
||||||
public let label: String?
|
public let label: String?
|
||||||
public let agentid: String?
|
public let agentid: String?
|
||||||
public let spawnedby: String?
|
public let spawnedby: String?
|
||||||
@ -956,6 +973,7 @@ public struct SessionsResolveParams: Codable, Sendable {
|
|||||||
|
|
||||||
public init(
|
public init(
|
||||||
key: String?,
|
key: String?,
|
||||||
|
sessionid: String?,
|
||||||
label: String?,
|
label: String?,
|
||||||
agentid: String?,
|
agentid: String?,
|
||||||
spawnedby: String?,
|
spawnedby: String?,
|
||||||
@ -963,6 +981,7 @@ public struct SessionsResolveParams: Codable, Sendable {
|
|||||||
includeunknown: Bool?
|
includeunknown: Bool?
|
||||||
) {
|
) {
|
||||||
self.key = key
|
self.key = key
|
||||||
|
self.sessionid = sessionid
|
||||||
self.label = label
|
self.label = label
|
||||||
self.agentid = agentid
|
self.agentid = agentid
|
||||||
self.spawnedby = spawnedby
|
self.spawnedby = spawnedby
|
||||||
@ -971,6 +990,7 @@ public struct SessionsResolveParams: Codable, Sendable {
|
|||||||
}
|
}
|
||||||
private enum CodingKeys: String, CodingKey {
|
private enum CodingKeys: String, CodingKey {
|
||||||
case key
|
case key
|
||||||
|
case sessionid = "sessionId"
|
||||||
case label
|
case label
|
||||||
case agentid = "agentId"
|
case agentid = "agentId"
|
||||||
case spawnedby = "spawnedBy"
|
case spawnedby = "spawnedBy"
|
||||||
@ -1147,17 +1167,29 @@ public struct ConfigApplyParams: Codable, Sendable {
|
|||||||
public struct ConfigPatchParams: Codable, Sendable {
|
public struct ConfigPatchParams: Codable, Sendable {
|
||||||
public let raw: String
|
public let raw: String
|
||||||
public let basehash: String?
|
public let basehash: String?
|
||||||
|
public let sessionkey: String?
|
||||||
|
public let note: String?
|
||||||
|
public let restartdelayms: Int?
|
||||||
|
|
||||||
public init(
|
public init(
|
||||||
raw: String,
|
raw: String,
|
||||||
basehash: String?
|
basehash: String?,
|
||||||
|
sessionkey: String?,
|
||||||
|
note: String?,
|
||||||
|
restartdelayms: Int?
|
||||||
) {
|
) {
|
||||||
self.raw = raw
|
self.raw = raw
|
||||||
self.basehash = basehash
|
self.basehash = basehash
|
||||||
|
self.sessionkey = sessionkey
|
||||||
|
self.note = note
|
||||||
|
self.restartdelayms = restartdelayms
|
||||||
}
|
}
|
||||||
private enum CodingKeys: String, CodingKey {
|
private enum CodingKeys: String, CodingKey {
|
||||||
case raw
|
case raw
|
||||||
case basehash = "baseHash"
|
case basehash = "baseHash"
|
||||||
|
case sessionkey = "sessionKey"
|
||||||
|
case note
|
||||||
|
case restartdelayms = "restartDelayMs"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1973,25 +2005,25 @@ public struct ExecApprovalsSnapshot: Codable, Sendable {
|
|||||||
public struct ExecApprovalRequestParams: Codable, Sendable {
|
public struct ExecApprovalRequestParams: Codable, Sendable {
|
||||||
public let id: String?
|
public let id: String?
|
||||||
public let command: String
|
public let command: String
|
||||||
public let cwd: String?
|
public let cwd: AnyCodable?
|
||||||
public let host: String?
|
public let host: AnyCodable?
|
||||||
public let security: String?
|
public let security: AnyCodable?
|
||||||
public let ask: String?
|
public let ask: AnyCodable?
|
||||||
public let agentid: String?
|
public let agentid: AnyCodable?
|
||||||
public let resolvedpath: String?
|
public let resolvedpath: AnyCodable?
|
||||||
public let sessionkey: String?
|
public let sessionkey: AnyCodable?
|
||||||
public let timeoutms: Int?
|
public let timeoutms: Int?
|
||||||
|
|
||||||
public init(
|
public init(
|
||||||
id: String?,
|
id: String?,
|
||||||
command: String,
|
command: String,
|
||||||
cwd: String?,
|
cwd: AnyCodable?,
|
||||||
host: String?,
|
host: AnyCodable?,
|
||||||
security: String?,
|
security: AnyCodable?,
|
||||||
ask: String?,
|
ask: AnyCodable?,
|
||||||
agentid: String?,
|
agentid: AnyCodable?,
|
||||||
resolvedpath: String?,
|
resolvedpath: AnyCodable?,
|
||||||
sessionkey: String?,
|
sessionkey: AnyCodable?,
|
||||||
timeoutms: Int?
|
timeoutms: Int?
|
||||||
) {
|
) {
|
||||||
self.id = id
|
self.id = id
|
||||||
|
|||||||
@ -123,11 +123,16 @@ import Testing
|
|||||||
configRoot: [:])
|
configRoot: [:])
|
||||||
|
|
||||||
#expect(cmd.first == "/usr/bin/ssh")
|
#expect(cmd.first == "/usr/bin/ssh")
|
||||||
#expect(cmd.contains("clawd@example.com"))
|
if let marker = cmd.firstIndex(of: "--") {
|
||||||
|
#expect(cmd[marker + 1] == "clawd@example.com")
|
||||||
|
} else {
|
||||||
|
#expect(Bool(false))
|
||||||
|
}
|
||||||
#expect(cmd.contains("-i"))
|
#expect(cmd.contains("-i"))
|
||||||
#expect(cmd.contains("/tmp/id_ed25519"))
|
#expect(cmd.contains("/tmp/id_ed25519"))
|
||||||
if let script = cmd.last {
|
if let script = cmd.last {
|
||||||
#expect(script.contains("cd '/srv/clawdbot'"))
|
#expect(script.contains("PRJ='/srv/clawdbot'"))
|
||||||
|
#expect(script.contains("cd \"$PRJ\""))
|
||||||
#expect(script.contains("clawdbot"))
|
#expect(script.contains("clawdbot"))
|
||||||
#expect(script.contains("status"))
|
#expect(script.contains("status"))
|
||||||
#expect(script.contains("--json"))
|
#expect(script.contains("--json"))
|
||||||
@ -135,6 +140,12 @@ import Testing
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test func rejectsUnsafeSSHTargets() async throws {
|
||||||
|
#expect(CommandResolver.parseSSHTarget("-oProxyCommand=calc") == nil)
|
||||||
|
#expect(CommandResolver.parseSSHTarget("host:-oProxyCommand=calc") == nil)
|
||||||
|
#expect(CommandResolver.parseSSHTarget("user@host:2222")?.port == 2222)
|
||||||
|
}
|
||||||
|
|
||||||
@Test func configRootLocalOverridesRemoteDefaults() async throws {
|
@Test func configRootLocalOverridesRemoteDefaults() async throws {
|
||||||
let defaults = self.makeDefaults()
|
let defaults = self.makeDefaults()
|
||||||
defaults.set(AppState.ConnectionMode.remote.rawValue, forKey: connectionModeKey)
|
defaults.set(AppState.ConnectionMode.remote.rawValue, forKey: connectionModeKey)
|
||||||
|
|||||||
@ -11,6 +11,7 @@ import Testing
|
|||||||
#expect(GatewayAgentChannel.last.shouldDeliver(true) == true)
|
#expect(GatewayAgentChannel.last.shouldDeliver(true) == true)
|
||||||
#expect(GatewayAgentChannel.whatsapp.shouldDeliver(true) == true)
|
#expect(GatewayAgentChannel.whatsapp.shouldDeliver(true) == true)
|
||||||
#expect(GatewayAgentChannel.telegram.shouldDeliver(true) == true)
|
#expect(GatewayAgentChannel.telegram.shouldDeliver(true) == true)
|
||||||
|
#expect(GatewayAgentChannel.googlechat.shouldDeliver(true) == true)
|
||||||
#expect(GatewayAgentChannel.bluebubbles.shouldDeliver(true) == true)
|
#expect(GatewayAgentChannel.bluebubbles.shouldDeliver(true) == true)
|
||||||
#expect(GatewayAgentChannel.last.shouldDeliver(false) == false)
|
#expect(GatewayAgentChannel.last.shouldDeliver(false) == false)
|
||||||
}
|
}
|
||||||
@ -19,6 +20,7 @@ import Testing
|
|||||||
#expect(GatewayAgentChannel(raw: nil) == .last)
|
#expect(GatewayAgentChannel(raw: nil) == .last)
|
||||||
#expect(GatewayAgentChannel(raw: " ") == .last)
|
#expect(GatewayAgentChannel(raw: " ") == .last)
|
||||||
#expect(GatewayAgentChannel(raw: "WEBCHAT") == .webchat)
|
#expect(GatewayAgentChannel(raw: "WEBCHAT") == .webchat)
|
||||||
|
#expect(GatewayAgentChannel(raw: "googlechat") == .googlechat)
|
||||||
#expect(GatewayAgentChannel(raw: "BLUEBUBBLES") == .bluebubbles)
|
#expect(GatewayAgentChannel(raw: "BLUEBUBBLES") == .bluebubbles)
|
||||||
#expect(GatewayAgentChannel(raw: "unknown") == .last)
|
#expect(GatewayAgentChannel(raw: "unknown") == .last)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -175,4 +175,10 @@ import Testing
|
|||||||
customBindHost: "192.168.1.10")
|
customBindHost: "192.168.1.10")
|
||||||
#expect(host == "192.168.1.10")
|
#expect(host == "192.168.1.10")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test func normalizeGatewayUrlAddsDefaultPortForWs() {
|
||||||
|
let url = GatewayRemoteConfig.normalizeGatewayUrl("ws://gateway")
|
||||||
|
#expect(url?.port == 18789)
|
||||||
|
#expect(url?.absoluteString == "ws://gateway:18789")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -11,7 +11,12 @@ struct MasterDiscoveryMenuSmokeTests {
|
|||||||
discovery.statusText = "Searching…"
|
discovery.statusText = "Searching…"
|
||||||
discovery.gateways = []
|
discovery.gateways = []
|
||||||
|
|
||||||
let view = GatewayDiscoveryInlineList(discovery: discovery, currentTarget: nil, onSelect: { _ in })
|
let view = GatewayDiscoveryInlineList(
|
||||||
|
discovery: discovery,
|
||||||
|
currentTarget: nil,
|
||||||
|
currentUrl: nil,
|
||||||
|
transport: .ssh,
|
||||||
|
onSelect: { _ in })
|
||||||
_ = view.body
|
_ = view.body
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -32,7 +37,12 @@ struct MasterDiscoveryMenuSmokeTests {
|
|||||||
]
|
]
|
||||||
|
|
||||||
let currentTarget = "\(NSUserName())@office.tailnet-123.ts.net:2222"
|
let currentTarget = "\(NSUserName())@office.tailnet-123.ts.net:2222"
|
||||||
let view = GatewayDiscoveryInlineList(discovery: discovery, currentTarget: currentTarget, onSelect: { _ in })
|
let view = GatewayDiscoveryInlineList(
|
||||||
|
discovery: discovery,
|
||||||
|
currentTarget: currentTarget,
|
||||||
|
currentUrl: nil,
|
||||||
|
transport: .ssh,
|
||||||
|
onSelect: { _ in })
|
||||||
_ = view.body
|
_ = view.body
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -12,6 +12,18 @@ struct VoiceWakeHelpersTests {
|
|||||||
#expect(cleaned == defaultVoiceWakeTriggers)
|
#expect(cleaned == defaultVoiceWakeTriggers)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test func sanitizeTriggersLimitsWordLength() {
|
||||||
|
let long = String(repeating: "x", count: voiceWakeMaxWordLength + 5)
|
||||||
|
let cleaned = sanitizeVoiceWakeTriggers(["ok", long])
|
||||||
|
#expect(cleaned[1].count == voiceWakeMaxWordLength)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func sanitizeTriggersLimitsWordCount() {
|
||||||
|
let words = (1...voiceWakeMaxWords + 3).map { "w\($0)" }
|
||||||
|
let cleaned = sanitizeVoiceWakeTriggers(words)
|
||||||
|
#expect(cleaned.count == voiceWakeMaxWords)
|
||||||
|
}
|
||||||
|
|
||||||
@Test func normalizeLocaleStripsCollation() {
|
@Test func normalizeLocaleStripsCollation() {
|
||||||
#expect(normalizeLocaleIdentifier("en_US@collation=phonebook") == "en_US")
|
#expect(normalizeLocaleIdentifier("en_US@collation=phonebook") == "en_US")
|
||||||
}
|
}
|
||||||
|
|||||||
@ -15,7 +15,7 @@ let package = Package(
|
|||||||
],
|
],
|
||||||
dependencies: [
|
dependencies: [
|
||||||
.package(url: "https://github.com/steipete/ElevenLabsKit", exact: "0.1.0"),
|
.package(url: "https://github.com/steipete/ElevenLabsKit", exact: "0.1.0"),
|
||||||
.package(url: "https://github.com/gonzalezreal/textual", exact: "0.2.0"),
|
.package(url: "https://github.com/gonzalezreal/textual", exact: "0.3.1"),
|
||||||
],
|
],
|
||||||
targets: [
|
targets: [
|
||||||
.target(
|
.target(
|
||||||
|
|||||||
@ -574,46 +574,22 @@ public actor GatewayChannelActor {
|
|||||||
params: [String: AnyCodable]?,
|
params: [String: AnyCodable]?,
|
||||||
timeoutMs: Double? = nil) async throws -> Data
|
timeoutMs: Double? = nil) async throws -> Data
|
||||||
{
|
{
|
||||||
do {
|
try await self.connectOrThrow(context: "gateway connect")
|
||||||
try await self.connect()
|
|
||||||
} catch {
|
|
||||||
throw self.wrap(error, context: "gateway connect")
|
|
||||||
}
|
|
||||||
let id = UUID().uuidString
|
|
||||||
let effectiveTimeout = timeoutMs ?? self.defaultRequestTimeoutMs
|
let effectiveTimeout = timeoutMs ?? self.defaultRequestTimeoutMs
|
||||||
// Encode request using the generated models to avoid JSONSerialization/ObjC bridging pitfalls.
|
let payload = try self.encodeRequest(method: method, params: params, kind: "request")
|
||||||
let paramsObject: ProtoAnyCodable? = params.map { entries in
|
|
||||||
let dict = entries.reduce(into: [String: ProtoAnyCodable]()) { dict, entry in
|
|
||||||
dict[entry.key] = ProtoAnyCodable(entry.value.value)
|
|
||||||
}
|
|
||||||
return ProtoAnyCodable(dict)
|
|
||||||
}
|
|
||||||
let frame = RequestFrame(
|
|
||||||
type: "req",
|
|
||||||
id: id,
|
|
||||||
method: method,
|
|
||||||
params: paramsObject)
|
|
||||||
let data: Data
|
|
||||||
do {
|
|
||||||
data = try self.encoder.encode(frame)
|
|
||||||
} catch {
|
|
||||||
self.logger.error(
|
|
||||||
"gateway request encode failed \(method, privacy: .public) error=\(error.localizedDescription, privacy: .public)")
|
|
||||||
throw error
|
|
||||||
}
|
|
||||||
let response = try await withCheckedThrowingContinuation { (cont: CheckedContinuation<GatewayFrame, Error>) in
|
let response = try await withCheckedThrowingContinuation { (cont: CheckedContinuation<GatewayFrame, Error>) in
|
||||||
self.pending[id] = cont
|
self.pending[payload.id] = cont
|
||||||
Task { [weak self] in
|
Task { [weak self] in
|
||||||
guard let self else { return }
|
guard let self else { return }
|
||||||
try? await Task.sleep(nanoseconds: UInt64(effectiveTimeout * 1_000_000))
|
try? await Task.sleep(nanoseconds: UInt64(effectiveTimeout * 1_000_000))
|
||||||
await self.timeoutRequest(id: id, timeoutMs: effectiveTimeout)
|
await self.timeoutRequest(id: payload.id, timeoutMs: effectiveTimeout)
|
||||||
}
|
}
|
||||||
Task {
|
Task {
|
||||||
do {
|
do {
|
||||||
try await self.task?.send(.data(data))
|
try await self.task?.send(.data(payload.data))
|
||||||
} catch {
|
} catch {
|
||||||
let wrapped = self.wrap(error, context: "gateway send \(method)")
|
let wrapped = self.wrap(error, context: "gateway send \(method)")
|
||||||
let waiter = self.pending.removeValue(forKey: id)
|
let waiter = self.pending.removeValue(forKey: payload.id)
|
||||||
// Treat send failures as a broken socket: mark disconnected and trigger reconnect.
|
// Treat send failures as a broken socket: mark disconnected and trigger reconnect.
|
||||||
self.connected = false
|
self.connected = false
|
||||||
self.task?.cancel(with: .goingAway, reason: nil)
|
self.task?.cancel(with: .goingAway, reason: nil)
|
||||||
@ -643,6 +619,29 @@ public actor GatewayChannelActor {
|
|||||||
return Data() // Should not happen, but tolerate empty payloads.
|
return Data() // Should not happen, but tolerate empty payloads.
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public func send(method: String, params: [String: AnyCodable]?) async throws {
|
||||||
|
try await self.connectOrThrow(context: "gateway connect")
|
||||||
|
let payload = try self.encodeRequest(method: method, params: params, kind: "send")
|
||||||
|
guard let task = self.task else {
|
||||||
|
throw NSError(
|
||||||
|
domain: "Gateway",
|
||||||
|
code: 5,
|
||||||
|
userInfo: [NSLocalizedDescriptionKey: "gateway socket unavailable"])
|
||||||
|
}
|
||||||
|
do {
|
||||||
|
try await task.send(.data(payload.data))
|
||||||
|
} catch {
|
||||||
|
let wrapped = self.wrap(error, context: "gateway send \(method)")
|
||||||
|
self.connected = false
|
||||||
|
self.task?.cancel(with: .goingAway, reason: nil)
|
||||||
|
Task { [weak self] in
|
||||||
|
guard let self else { return }
|
||||||
|
await self.scheduleReconnect()
|
||||||
|
}
|
||||||
|
throw wrapped
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Wrap low-level URLSession/WebSocket errors with context so UI can surface them.
|
// Wrap low-level URLSession/WebSocket errors with context so UI can surface them.
|
||||||
private func wrap(_ error: Error, context: String) -> Error {
|
private func wrap(_ error: Error, context: String) -> Error {
|
||||||
if let urlError = error as? URLError {
|
if let urlError = error as? URLError {
|
||||||
@ -657,6 +656,42 @@ public actor GatewayChannelActor {
|
|||||||
return NSError(domain: ns.domain, code: ns.code, userInfo: [NSLocalizedDescriptionKey: "\(context): \(desc)"])
|
return NSError(domain: ns.domain, code: ns.code, userInfo: [NSLocalizedDescriptionKey: "\(context): \(desc)"])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func connectOrThrow(context: String) async throws {
|
||||||
|
do {
|
||||||
|
try await self.connect()
|
||||||
|
} catch {
|
||||||
|
throw self.wrap(error, context: context)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func encodeRequest(
|
||||||
|
method: String,
|
||||||
|
params: [String: AnyCodable]?,
|
||||||
|
kind: String) throws -> (id: String, data: Data)
|
||||||
|
{
|
||||||
|
let id = UUID().uuidString
|
||||||
|
// Encode request using the generated models to avoid JSONSerialization/ObjC bridging pitfalls.
|
||||||
|
let paramsObject: ProtoAnyCodable? = params.map { entries in
|
||||||
|
let dict = entries.reduce(into: [String: ProtoAnyCodable]()) { dict, entry in
|
||||||
|
dict[entry.key] = ProtoAnyCodable(entry.value.value)
|
||||||
|
}
|
||||||
|
return ProtoAnyCodable(dict)
|
||||||
|
}
|
||||||
|
let frame = RequestFrame(
|
||||||
|
type: "req",
|
||||||
|
id: id,
|
||||||
|
method: method,
|
||||||
|
params: paramsObject)
|
||||||
|
do {
|
||||||
|
let data = try self.encoder.encode(frame)
|
||||||
|
return (id: id, data: data)
|
||||||
|
} catch {
|
||||||
|
self.logger.error(
|
||||||
|
"gateway \(kind) encode failed \(method, privacy: .public) error=\(error.localizedDescription, privacy: .public)")
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private func failPending(_ error: Error) async {
|
private func failPending(_ error: Error) async {
|
||||||
let waiters = self.pending
|
let waiters = self.pending
|
||||||
self.pending.removeAll()
|
self.pending.removeAll()
|
||||||
|
|||||||
@ -143,7 +143,7 @@ public actor GatewayNodeSession {
|
|||||||
"payloadJSON": AnyCodable(payloadJSON ?? NSNull()),
|
"payloadJSON": AnyCodable(payloadJSON ?? NSNull()),
|
||||||
]
|
]
|
||||||
do {
|
do {
|
||||||
_ = try await channel.request(method: "node.event", params: params, timeoutMs: 8000)
|
try await channel.send(method: "node.event", params: params)
|
||||||
} catch {
|
} catch {
|
||||||
self.logger.error("node event failed: \(error.localizedDescription, privacy: .public)")
|
self.logger.error("node event failed: \(error.localizedDescription, privacy: .public)")
|
||||||
}
|
}
|
||||||
@ -224,7 +224,7 @@ public actor GatewayNodeSession {
|
|||||||
])
|
])
|
||||||
}
|
}
|
||||||
do {
|
do {
|
||||||
_ = try await channel.request(method: "node.invoke.result", params: params, timeoutMs: 15000)
|
try await channel.send(method: "node.invoke.result", params: params)
|
||||||
} catch {
|
} catch {
|
||||||
self.logger.error("node invoke result failed: \(error.localizedDescription, privacy: .public)")
|
self.logger.error("node invoke result failed: \(error.localizedDescription, privacy: .public)")
|
||||||
}
|
}
|
||||||
|
|||||||
@ -385,6 +385,7 @@ public struct SendParams: Codable, Sendable {
|
|||||||
public let to: String
|
public let to: String
|
||||||
public let message: String
|
public let message: String
|
||||||
public let mediaurl: String?
|
public let mediaurl: String?
|
||||||
|
public let mediaurls: [String]?
|
||||||
public let gifplayback: Bool?
|
public let gifplayback: Bool?
|
||||||
public let channel: String?
|
public let channel: String?
|
||||||
public let accountid: String?
|
public let accountid: String?
|
||||||
@ -395,6 +396,7 @@ public struct SendParams: Codable, Sendable {
|
|||||||
to: String,
|
to: String,
|
||||||
message: String,
|
message: String,
|
||||||
mediaurl: String?,
|
mediaurl: String?,
|
||||||
|
mediaurls: [String]?,
|
||||||
gifplayback: Bool?,
|
gifplayback: Bool?,
|
||||||
channel: String?,
|
channel: String?,
|
||||||
accountid: String?,
|
accountid: String?,
|
||||||
@ -404,6 +406,7 @@ public struct SendParams: Codable, Sendable {
|
|||||||
self.to = to
|
self.to = to
|
||||||
self.message = message
|
self.message = message
|
||||||
self.mediaurl = mediaurl
|
self.mediaurl = mediaurl
|
||||||
|
self.mediaurls = mediaurls
|
||||||
self.gifplayback = gifplayback
|
self.gifplayback = gifplayback
|
||||||
self.channel = channel
|
self.channel = channel
|
||||||
self.accountid = accountid
|
self.accountid = accountid
|
||||||
@ -414,6 +417,7 @@ public struct SendParams: Codable, Sendable {
|
|||||||
case to
|
case to
|
||||||
case message
|
case message
|
||||||
case mediaurl = "mediaUrl"
|
case mediaurl = "mediaUrl"
|
||||||
|
case mediaurls = "mediaUrls"
|
||||||
case gifplayback = "gifPlayback"
|
case gifplayback = "gifPlayback"
|
||||||
case channel
|
case channel
|
||||||
case accountid = "accountId"
|
case accountid = "accountId"
|
||||||
@ -478,6 +482,9 @@ public struct AgentParams: Codable, Sendable {
|
|||||||
public let accountid: String?
|
public let accountid: String?
|
||||||
public let replyaccountid: String?
|
public let replyaccountid: String?
|
||||||
public let threadid: String?
|
public let threadid: String?
|
||||||
|
public let groupid: String?
|
||||||
|
public let groupchannel: String?
|
||||||
|
public let groupspace: String?
|
||||||
public let timeout: Int?
|
public let timeout: Int?
|
||||||
public let lane: String?
|
public let lane: String?
|
||||||
public let extrasystemprompt: String?
|
public let extrasystemprompt: String?
|
||||||
@ -500,6 +507,9 @@ public struct AgentParams: Codable, Sendable {
|
|||||||
accountid: String?,
|
accountid: String?,
|
||||||
replyaccountid: String?,
|
replyaccountid: String?,
|
||||||
threadid: String?,
|
threadid: String?,
|
||||||
|
groupid: String?,
|
||||||
|
groupchannel: String?,
|
||||||
|
groupspace: String?,
|
||||||
timeout: Int?,
|
timeout: Int?,
|
||||||
lane: String?,
|
lane: String?,
|
||||||
extrasystemprompt: String?,
|
extrasystemprompt: String?,
|
||||||
@ -521,6 +531,9 @@ public struct AgentParams: Codable, Sendable {
|
|||||||
self.accountid = accountid
|
self.accountid = accountid
|
||||||
self.replyaccountid = replyaccountid
|
self.replyaccountid = replyaccountid
|
||||||
self.threadid = threadid
|
self.threadid = threadid
|
||||||
|
self.groupid = groupid
|
||||||
|
self.groupchannel = groupchannel
|
||||||
|
self.groupspace = groupspace
|
||||||
self.timeout = timeout
|
self.timeout = timeout
|
||||||
self.lane = lane
|
self.lane = lane
|
||||||
self.extrasystemprompt = extrasystemprompt
|
self.extrasystemprompt = extrasystemprompt
|
||||||
@ -543,6 +556,9 @@ public struct AgentParams: Codable, Sendable {
|
|||||||
case accountid = "accountId"
|
case accountid = "accountId"
|
||||||
case replyaccountid = "replyAccountId"
|
case replyaccountid = "replyAccountId"
|
||||||
case threadid = "threadId"
|
case threadid = "threadId"
|
||||||
|
case groupid = "groupId"
|
||||||
|
case groupchannel = "groupChannel"
|
||||||
|
case groupspace = "groupSpace"
|
||||||
case timeout
|
case timeout
|
||||||
case lane
|
case lane
|
||||||
case extrasystemprompt = "extraSystemPrompt"
|
case extrasystemprompt = "extraSystemPrompt"
|
||||||
@ -948,6 +964,7 @@ public struct SessionsPreviewParams: Codable, Sendable {
|
|||||||
|
|
||||||
public struct SessionsResolveParams: Codable, Sendable {
|
public struct SessionsResolveParams: Codable, Sendable {
|
||||||
public let key: String?
|
public let key: String?
|
||||||
|
public let sessionid: String?
|
||||||
public let label: String?
|
public let label: String?
|
||||||
public let agentid: String?
|
public let agentid: String?
|
||||||
public let spawnedby: String?
|
public let spawnedby: String?
|
||||||
@ -956,6 +973,7 @@ public struct SessionsResolveParams: Codable, Sendable {
|
|||||||
|
|
||||||
public init(
|
public init(
|
||||||
key: String?,
|
key: String?,
|
||||||
|
sessionid: String?,
|
||||||
label: String?,
|
label: String?,
|
||||||
agentid: String?,
|
agentid: String?,
|
||||||
spawnedby: String?,
|
spawnedby: String?,
|
||||||
@ -963,6 +981,7 @@ public struct SessionsResolveParams: Codable, Sendable {
|
|||||||
includeunknown: Bool?
|
includeunknown: Bool?
|
||||||
) {
|
) {
|
||||||
self.key = key
|
self.key = key
|
||||||
|
self.sessionid = sessionid
|
||||||
self.label = label
|
self.label = label
|
||||||
self.agentid = agentid
|
self.agentid = agentid
|
||||||
self.spawnedby = spawnedby
|
self.spawnedby = spawnedby
|
||||||
@ -971,6 +990,7 @@ public struct SessionsResolveParams: Codable, Sendable {
|
|||||||
}
|
}
|
||||||
private enum CodingKeys: String, CodingKey {
|
private enum CodingKeys: String, CodingKey {
|
||||||
case key
|
case key
|
||||||
|
case sessionid = "sessionId"
|
||||||
case label
|
case label
|
||||||
case agentid = "agentId"
|
case agentid = "agentId"
|
||||||
case spawnedby = "spawnedBy"
|
case spawnedby = "spawnedBy"
|
||||||
@ -1147,17 +1167,29 @@ public struct ConfigApplyParams: Codable, Sendable {
|
|||||||
public struct ConfigPatchParams: Codable, Sendable {
|
public struct ConfigPatchParams: Codable, Sendable {
|
||||||
public let raw: String
|
public let raw: String
|
||||||
public let basehash: String?
|
public let basehash: String?
|
||||||
|
public let sessionkey: String?
|
||||||
|
public let note: String?
|
||||||
|
public let restartdelayms: Int?
|
||||||
|
|
||||||
public init(
|
public init(
|
||||||
raw: String,
|
raw: String,
|
||||||
basehash: String?
|
basehash: String?,
|
||||||
|
sessionkey: String?,
|
||||||
|
note: String?,
|
||||||
|
restartdelayms: Int?
|
||||||
) {
|
) {
|
||||||
self.raw = raw
|
self.raw = raw
|
||||||
self.basehash = basehash
|
self.basehash = basehash
|
||||||
|
self.sessionkey = sessionkey
|
||||||
|
self.note = note
|
||||||
|
self.restartdelayms = restartdelayms
|
||||||
}
|
}
|
||||||
private enum CodingKeys: String, CodingKey {
|
private enum CodingKeys: String, CodingKey {
|
||||||
case raw
|
case raw
|
||||||
case basehash = "baseHash"
|
case basehash = "baseHash"
|
||||||
|
case sessionkey = "sessionKey"
|
||||||
|
case note
|
||||||
|
case restartdelayms = "restartDelayMs"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1973,25 +2005,25 @@ public struct ExecApprovalsSnapshot: Codable, Sendable {
|
|||||||
public struct ExecApprovalRequestParams: Codable, Sendable {
|
public struct ExecApprovalRequestParams: Codable, Sendable {
|
||||||
public let id: String?
|
public let id: String?
|
||||||
public let command: String
|
public let command: String
|
||||||
public let cwd: String?
|
public let cwd: AnyCodable?
|
||||||
public let host: String?
|
public let host: AnyCodable?
|
||||||
public let security: String?
|
public let security: AnyCodable?
|
||||||
public let ask: String?
|
public let ask: AnyCodable?
|
||||||
public let agentid: String?
|
public let agentid: AnyCodable?
|
||||||
public let resolvedpath: String?
|
public let resolvedpath: AnyCodable?
|
||||||
public let sessionkey: String?
|
public let sessionkey: AnyCodable?
|
||||||
public let timeoutms: Int?
|
public let timeoutms: Int?
|
||||||
|
|
||||||
public init(
|
public init(
|
||||||
id: String?,
|
id: String?,
|
||||||
command: String,
|
command: String,
|
||||||
cwd: String?,
|
cwd: AnyCodable?,
|
||||||
host: String?,
|
host: AnyCodable?,
|
||||||
security: String?,
|
security: AnyCodable?,
|
||||||
ask: String?,
|
ask: AnyCodable?,
|
||||||
agentid: String?,
|
agentid: AnyCodable?,
|
||||||
resolvedpath: String?,
|
resolvedpath: AnyCodable?,
|
||||||
sessionkey: String?,
|
sessionkey: AnyCodable?,
|
||||||
timeoutms: Int?
|
timeoutms: Int?
|
||||||
) {
|
) {
|
||||||
self.id = id
|
self.id = id
|
||||||
|
|||||||
1
dist/control-ui/assets/index-08nzABV3.css
vendored
Normal file
1
dist/control-ui/assets/index-08nzABV3.css
vendored
Normal file
File diff suppressed because one or more lines are too long
3119
dist/control-ui/assets/index-DQcOTEYz.js
vendored
Normal file
3119
dist/control-ui/assets/index-DQcOTEYz.js
vendored
Normal file
File diff suppressed because one or more lines are too long
1
dist/control-ui/assets/index-DQcOTEYz.js.map
vendored
Normal file
1
dist/control-ui/assets/index-DQcOTEYz.js.map
vendored
Normal file
File diff suppressed because one or more lines are too long
@ -20,7 +20,7 @@ services:
|
|||||||
[
|
[
|
||||||
"node",
|
"node",
|
||||||
"dist/index.js",
|
"dist/index.js",
|
||||||
"gateway-daemon",
|
"gateway",
|
||||||
"--bind",
|
"--bind",
|
||||||
"${CLAWDBOT_GATEWAY_BIND:-lan}",
|
"${CLAWDBOT_GATEWAY_BIND:-lan}",
|
||||||
"--port",
|
"--port",
|
||||||
|
|||||||
@ -3,9 +3,12 @@ summary: "Cron jobs + wakeups for the Gateway scheduler"
|
|||||||
read_when:
|
read_when:
|
||||||
- Scheduling background jobs or wakeups
|
- Scheduling background jobs or wakeups
|
||||||
- Wiring automation that should run with or alongside heartbeats
|
- Wiring automation that should run with or alongside heartbeats
|
||||||
|
- Deciding between heartbeat and cron for scheduled tasks
|
||||||
---
|
---
|
||||||
# Cron jobs (Gateway scheduler)
|
# Cron jobs (Gateway scheduler)
|
||||||
|
|
||||||
|
> **Cron vs Heartbeat?** See [Cron vs Heartbeat](/automation/cron-vs-heartbeat) for guidance on when to use each.
|
||||||
|
|
||||||
Cron is the Gateway’s built-in scheduler. It persists jobs, wakes the agent at
|
Cron is the Gateway’s built-in scheduler. It persists jobs, wakes the agent at
|
||||||
the right time, and can optionally deliver output back to a chat.
|
the right time, and can optionally deliver output back to a chat.
|
||||||
|
|
||||||
@ -260,15 +263,15 @@ Run history:
|
|||||||
clawdbot cron runs --id <jobId> --limit 50
|
clawdbot cron runs --id <jobId> --limit 50
|
||||||
```
|
```
|
||||||
|
|
||||||
Immediate wake without creating a job:
|
Immediate system event without creating a job:
|
||||||
```bash
|
```bash
|
||||||
clawdbot wake --mode now --text "Next heartbeat: check battery."
|
clawdbot system event --mode now --text "Next heartbeat: check battery."
|
||||||
```
|
```
|
||||||
|
|
||||||
## Gateway API surface
|
## Gateway API surface
|
||||||
- `cron.list`, `cron.status`, `cron.add`, `cron.update`, `cron.remove`
|
- `cron.list`, `cron.status`, `cron.add`, `cron.update`, `cron.remove`
|
||||||
- `cron.run` (force or due), `cron.runs`
|
- `cron.run` (force or due), `cron.runs`
|
||||||
- `wake` (enqueue system event + optional heartbeat)
|
For immediate system events without a job, use [`clawdbot system event`](/cli/system).
|
||||||
|
|
||||||
## Troubleshooting
|
## Troubleshooting
|
||||||
|
|
||||||
|
|||||||
274
docs/automation/cron-vs-heartbeat.md
Normal file
274
docs/automation/cron-vs-heartbeat.md
Normal file
@ -0,0 +1,274 @@
|
|||||||
|
---
|
||||||
|
summary: "Guidance for choosing between heartbeat and cron jobs for automation"
|
||||||
|
read_when:
|
||||||
|
- Deciding how to schedule recurring tasks
|
||||||
|
- Setting up background monitoring or notifications
|
||||||
|
- Optimizing token usage for periodic checks
|
||||||
|
---
|
||||||
|
# Cron vs Heartbeat: When to Use Each
|
||||||
|
|
||||||
|
Both heartbeats and cron jobs let you run tasks on a schedule. This guide helps you choose the right mechanism for your use case.
|
||||||
|
|
||||||
|
## Quick Decision Guide
|
||||||
|
|
||||||
|
| Use Case | Recommended | Why |
|
||||||
|
|----------|-------------|-----|
|
||||||
|
| Check inbox every 30 min | Heartbeat | Batches with other checks, context-aware |
|
||||||
|
| Send daily report at 9am sharp | Cron (isolated) | Exact timing needed |
|
||||||
|
| Monitor calendar for upcoming events | Heartbeat | Natural fit for periodic awareness |
|
||||||
|
| Run weekly deep analysis | Cron (isolated) | Standalone task, can use different model |
|
||||||
|
| Remind me in 20 minutes | Cron (main, `--at`) | One-shot with precise timing |
|
||||||
|
| Background project health check | Heartbeat | Piggybacks on existing cycle |
|
||||||
|
|
||||||
|
## Heartbeat: Periodic Awareness
|
||||||
|
|
||||||
|
Heartbeats run in the **main session** at a regular interval (default: 30 min). They're designed for the agent to check on things and surface anything important.
|
||||||
|
|
||||||
|
### When to use heartbeat
|
||||||
|
|
||||||
|
- **Multiple periodic checks**: Instead of 5 separate cron jobs checking inbox, calendar, weather, notifications, and project status, a single heartbeat can batch all of these.
|
||||||
|
- **Context-aware decisions**: The agent has full main-session context, so it can make smart decisions about what's urgent vs. what can wait.
|
||||||
|
- **Conversational continuity**: Heartbeat runs share the same session, so the agent remembers recent conversations and can follow up naturally.
|
||||||
|
- **Low-overhead monitoring**: One heartbeat replaces many small polling tasks.
|
||||||
|
|
||||||
|
### Heartbeat advantages
|
||||||
|
|
||||||
|
- **Batches multiple checks**: One agent turn can review inbox, calendar, and notifications together.
|
||||||
|
- **Reduces API calls**: A single heartbeat is cheaper than 5 isolated cron jobs.
|
||||||
|
- **Context-aware**: The agent knows what you've been working on and can prioritize accordingly.
|
||||||
|
- **Smart suppression**: If nothing needs attention, the agent replies `HEARTBEAT_OK` and no message is delivered.
|
||||||
|
- **Natural timing**: Drifts slightly based on queue load, which is fine for most monitoring.
|
||||||
|
|
||||||
|
### Heartbeat example: HEARTBEAT.md checklist
|
||||||
|
|
||||||
|
```md
|
||||||
|
# Heartbeat checklist
|
||||||
|
|
||||||
|
- Check email for urgent messages
|
||||||
|
- Review calendar for events in next 2 hours
|
||||||
|
- If a background task finished, summarize results
|
||||||
|
- If idle for 8+ hours, send a brief check-in
|
||||||
|
```
|
||||||
|
|
||||||
|
The agent reads this on each heartbeat and handles all items in one turn.
|
||||||
|
|
||||||
|
### Configuring heartbeat
|
||||||
|
|
||||||
|
```json5
|
||||||
|
{
|
||||||
|
agents: {
|
||||||
|
defaults: {
|
||||||
|
heartbeat: {
|
||||||
|
every: "30m", // interval
|
||||||
|
target: "last", // where to deliver alerts
|
||||||
|
activeHours: { start: "08:00", end: "22:00" } // optional
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
See [Heartbeat](/gateway/heartbeat) for full configuration.
|
||||||
|
|
||||||
|
## Cron: Precise Scheduling
|
||||||
|
|
||||||
|
Cron jobs run at **exact times** and can run in isolated sessions without affecting main context.
|
||||||
|
|
||||||
|
### When to use cron
|
||||||
|
|
||||||
|
- **Exact timing required**: "Send this at 9:00 AM every Monday" (not "sometime around 9").
|
||||||
|
- **Standalone tasks**: Tasks that don't need conversational context.
|
||||||
|
- **Different model/thinking**: Heavy analysis that warrants a more powerful model.
|
||||||
|
- **One-shot reminders**: "Remind me in 20 minutes" with `--at`.
|
||||||
|
- **Noisy/frequent tasks**: Tasks that would clutter main session history.
|
||||||
|
- **External triggers**: Tasks that should run independently of whether the agent is otherwise active.
|
||||||
|
|
||||||
|
### Cron advantages
|
||||||
|
|
||||||
|
- **Exact timing**: 5-field cron expressions with timezone support.
|
||||||
|
- **Session isolation**: Runs in `cron:<jobId>` without polluting main history.
|
||||||
|
- **Model overrides**: Use a cheaper or more powerful model per job.
|
||||||
|
- **Delivery control**: Can deliver directly to a channel; still posts a summary to main by default (configurable).
|
||||||
|
- **No agent context needed**: Runs even if main session is idle or compacted.
|
||||||
|
- **One-shot support**: `--at` for precise future timestamps.
|
||||||
|
|
||||||
|
### Cron example: Daily morning briefing
|
||||||
|
|
||||||
|
```bash
|
||||||
|
clawdbot cron add \
|
||||||
|
--name "Morning briefing" \
|
||||||
|
--cron "0 7 * * *" \
|
||||||
|
--tz "America/New_York" \
|
||||||
|
--session isolated \
|
||||||
|
--message "Generate today's briefing: weather, calendar, top emails, news summary." \
|
||||||
|
--model opus \
|
||||||
|
--deliver \
|
||||||
|
--channel whatsapp \
|
||||||
|
--to "+15551234567"
|
||||||
|
```
|
||||||
|
|
||||||
|
This runs at exactly 7:00 AM New York time, uses Opus for quality, and delivers directly to WhatsApp.
|
||||||
|
|
||||||
|
### Cron example: One-shot reminder
|
||||||
|
|
||||||
|
```bash
|
||||||
|
clawdbot cron add \
|
||||||
|
--name "Meeting reminder" \
|
||||||
|
--at "20m" \
|
||||||
|
--session main \
|
||||||
|
--system-event "Reminder: standup meeting starts in 10 minutes." \
|
||||||
|
--wake now \
|
||||||
|
--delete-after-run
|
||||||
|
```
|
||||||
|
|
||||||
|
See [Cron jobs](/automation/cron-jobs) for full CLI reference.
|
||||||
|
|
||||||
|
## Decision Flowchart
|
||||||
|
|
||||||
|
```
|
||||||
|
Does the task need to run at an EXACT time?
|
||||||
|
YES -> Use cron
|
||||||
|
NO -> Continue...
|
||||||
|
|
||||||
|
Does the task need isolation from main session?
|
||||||
|
YES -> Use cron (isolated)
|
||||||
|
NO -> Continue...
|
||||||
|
|
||||||
|
Can this task be batched with other periodic checks?
|
||||||
|
YES -> Use heartbeat (add to HEARTBEAT.md)
|
||||||
|
NO -> Use cron
|
||||||
|
|
||||||
|
Is this a one-shot reminder?
|
||||||
|
YES -> Use cron with --at
|
||||||
|
NO -> Continue...
|
||||||
|
|
||||||
|
Does it need a different model or thinking level?
|
||||||
|
YES -> Use cron (isolated) with --model/--thinking
|
||||||
|
NO -> Use heartbeat
|
||||||
|
```
|
||||||
|
|
||||||
|
## Combining Both
|
||||||
|
|
||||||
|
The most efficient setup uses **both**:
|
||||||
|
|
||||||
|
1. **Heartbeat** handles routine monitoring (inbox, calendar, notifications) in one batched turn every 30 minutes.
|
||||||
|
2. **Cron** handles precise schedules (daily reports, weekly reviews) and one-shot reminders.
|
||||||
|
|
||||||
|
### Example: Efficient automation setup
|
||||||
|
|
||||||
|
**HEARTBEAT.md** (checked every 30 min):
|
||||||
|
```md
|
||||||
|
# Heartbeat checklist
|
||||||
|
- Scan inbox for urgent emails
|
||||||
|
- Check calendar for events in next 2h
|
||||||
|
- Review any pending tasks
|
||||||
|
- Light check-in if quiet for 8+ hours
|
||||||
|
```
|
||||||
|
|
||||||
|
**Cron jobs** (precise timing):
|
||||||
|
```bash
|
||||||
|
# Daily morning briefing at 7am
|
||||||
|
clawdbot cron add --name "Morning brief" --cron "0 7 * * *" --session isolated --message "..." --deliver
|
||||||
|
|
||||||
|
# Weekly project review on Mondays at 9am
|
||||||
|
clawdbot cron add --name "Weekly review" --cron "0 9 * * 1" --session isolated --message "..." --model opus
|
||||||
|
|
||||||
|
# One-shot reminder
|
||||||
|
clawdbot cron add --name "Call back" --at "2h" --session main --system-event "Call back the client" --wake now
|
||||||
|
```
|
||||||
|
|
||||||
|
|
||||||
|
## Lobster: Deterministic workflows with approvals
|
||||||
|
|
||||||
|
Lobster is the workflow runtime for **multi-step tool pipelines** that need deterministic execution and explicit approvals.
|
||||||
|
Use it when the task is more than a single agent turn, and you want a resumable workflow with human checkpoints.
|
||||||
|
|
||||||
|
### When Lobster fits
|
||||||
|
|
||||||
|
- **Multi-step automation**: You need a fixed pipeline of tool calls, not a one-off prompt.
|
||||||
|
- **Approval gates**: Side effects should pause until you approve, then resume.
|
||||||
|
- **Resumable runs**: Continue a paused workflow without re-running earlier steps.
|
||||||
|
|
||||||
|
### How it pairs with heartbeat and cron
|
||||||
|
|
||||||
|
- **Heartbeat/cron** decide *when* a run happens.
|
||||||
|
- **Lobster** defines *what steps* happen once the run starts.
|
||||||
|
|
||||||
|
For scheduled workflows, use cron or heartbeat to trigger an agent turn that calls Lobster.
|
||||||
|
For ad-hoc workflows, call Lobster directly.
|
||||||
|
|
||||||
|
### Operational notes (from the code)
|
||||||
|
|
||||||
|
- Lobster runs as a **local subprocess** (`lobster` CLI) in tool mode and returns a **JSON envelope**.
|
||||||
|
- If the tool returns `needs_approval`, you resume with a `resumeToken` and `approve` flag.
|
||||||
|
- The tool is an **optional plugin**; enable it additively via `tools.alsoAllow: ["lobster"]` (recommended).
|
||||||
|
- If you pass `lobsterPath`, it must be an **absolute path**.
|
||||||
|
|
||||||
|
See [Lobster](/tools/lobster) for full usage and examples.
|
||||||
|
|
||||||
|
## Main Session vs Isolated Session
|
||||||
|
|
||||||
|
Both heartbeat and cron can interact with the main session, but differently:
|
||||||
|
|
||||||
|
| | Heartbeat | Cron (main) | Cron (isolated) |
|
||||||
|
|---|---|---|---|
|
||||||
|
| Session | Main | Main (via system event) | `cron:<jobId>` |
|
||||||
|
| History | Shared | Shared | Fresh each run |
|
||||||
|
| Context | Full | Full | None (starts clean) |
|
||||||
|
| Model | Main session model | Main session model | Can override |
|
||||||
|
| Output | Delivered if not `HEARTBEAT_OK` | Heartbeat prompt + event | Summary posted to main |
|
||||||
|
|
||||||
|
### When to use main session cron
|
||||||
|
|
||||||
|
Use `--session main` with `--system-event` when you want:
|
||||||
|
- The reminder/event to appear in main session context
|
||||||
|
- The agent to handle it during the next heartbeat with full context
|
||||||
|
- No separate isolated run
|
||||||
|
|
||||||
|
```bash
|
||||||
|
clawdbot cron add \
|
||||||
|
--name "Check project" \
|
||||||
|
--every "4h" \
|
||||||
|
--session main \
|
||||||
|
--system-event "Time for a project health check" \
|
||||||
|
--wake now
|
||||||
|
```
|
||||||
|
|
||||||
|
### When to use isolated cron
|
||||||
|
|
||||||
|
Use `--session isolated` when you want:
|
||||||
|
- A clean slate without prior context
|
||||||
|
- Different model or thinking settings
|
||||||
|
- Output delivered directly to a channel (summary still posts to main by default)
|
||||||
|
- History that doesn't clutter main session
|
||||||
|
|
||||||
|
```bash
|
||||||
|
clawdbot cron add \
|
||||||
|
--name "Deep analysis" \
|
||||||
|
--cron "0 6 * * 0" \
|
||||||
|
--session isolated \
|
||||||
|
--message "Weekly codebase analysis..." \
|
||||||
|
--model opus \
|
||||||
|
--thinking high \
|
||||||
|
--deliver
|
||||||
|
```
|
||||||
|
|
||||||
|
## Cost Considerations
|
||||||
|
|
||||||
|
| Mechanism | Cost Profile |
|
||||||
|
|-----------|--------------|
|
||||||
|
| Heartbeat | One turn every N minutes; scales with HEARTBEAT.md size |
|
||||||
|
| Cron (main) | Adds event to next heartbeat (no isolated turn) |
|
||||||
|
| Cron (isolated) | Full agent turn per job; can use cheaper model |
|
||||||
|
|
||||||
|
**Tips**:
|
||||||
|
- Keep `HEARTBEAT.md` small to minimize token overhead.
|
||||||
|
- Batch similar checks into heartbeat instead of multiple cron jobs.
|
||||||
|
- Use `target: "none"` on heartbeat if you only want internal processing.
|
||||||
|
- Use isolated cron with a cheaper model for routine tasks.
|
||||||
|
|
||||||
|
## Related
|
||||||
|
|
||||||
|
- [Heartbeat](/gateway/heartbeat) - full heartbeat configuration
|
||||||
|
- [Cron jobs](/automation/cron-jobs) - full cron CLI and API reference
|
||||||
|
- [System](/cli/system) - system events + heartbeat controls
|
||||||
@ -83,6 +83,8 @@ Notes:
|
|||||||
- Per-hook `model`/`thinking` in the mapping still overrides these defaults.
|
- Per-hook `model`/`thinking` in the mapping still overrides these defaults.
|
||||||
- Fallback order: `hooks.gmail.model` → `agents.defaults.model.fallbacks` → primary (auth/rate-limit/timeouts).
|
- Fallback order: `hooks.gmail.model` → `agents.defaults.model.fallbacks` → primary (auth/rate-limit/timeouts).
|
||||||
- If `agents.defaults.models` is set, the Gmail model must be in the allowlist.
|
- If `agents.defaults.models` is set, the Gmail model must be in the allowlist.
|
||||||
|
- Gmail hook content is wrapped with external-content safety boundaries by default.
|
||||||
|
To disable (dangerous), set `hooks.gmail.allowUnsafeExternalContent: true`.
|
||||||
|
|
||||||
To customize payload handling further, add `hooks.mappings` or a JS/TS transform module
|
To customize payload handling further, add `hooks.mappings` or a JS/TS transform module
|
||||||
under `hooks.transformsDir` (see [Webhooks](/automation/webhook)).
|
under `hooks.transformsDir` (see [Webhooks](/automation/webhook)).
|
||||||
|
|||||||
@ -27,10 +27,10 @@ Notes:
|
|||||||
|
|
||||||
## Auth
|
## Auth
|
||||||
|
|
||||||
Every request must include the hook token:
|
Every request must include the hook token. Prefer headers:
|
||||||
- `Authorization: Bearer <token>`
|
- `Authorization: Bearer <token>` (recommended)
|
||||||
- or `x-clawdbot-token: <token>`
|
- `x-clawdbot-token: <token>`
|
||||||
- or `?token=<token>`
|
- `?token=<token>` (deprecated; logs a warning and will be removed in a future major release)
|
||||||
|
|
||||||
## Endpoints
|
## Endpoints
|
||||||
|
|
||||||
@ -96,6 +96,8 @@ Mapping options (summary):
|
|||||||
- TS transforms require a TS loader (e.g. `bun` or `tsx`) or precompiled `.js` at runtime.
|
- TS transforms require a TS loader (e.g. `bun` or `tsx`) or precompiled `.js` at runtime.
|
||||||
- Set `deliver: true` + `channel`/`to` on mappings to route replies to a chat surface
|
- Set `deliver: true` + `channel`/`to` on mappings to route replies to a chat surface
|
||||||
(`channel` defaults to `last` and falls back to WhatsApp).
|
(`channel` defaults to `last` and falls back to WhatsApp).
|
||||||
|
- `allowUnsafeExternalContent: true` disables the external content safety wrapper for that hook
|
||||||
|
(dangerous; only for trusted internal sources).
|
||||||
- `clawdbot webhooks gmail setup` writes `hooks.gmail` config for `clawdbot webhooks gmail run`.
|
- `clawdbot webhooks gmail setup` writes `hooks.gmail` config for `clawdbot webhooks gmail run`.
|
||||||
See [Gmail Pub/Sub](/automation/gmail-pubsub) for the full Gmail watch flow.
|
See [Gmail Pub/Sub](/automation/gmail-pubsub) for the full Gmail watch flow.
|
||||||
|
|
||||||
@ -148,3 +150,6 @@ curl -X POST http://127.0.0.1:18789/hooks/gmail \
|
|||||||
- Keep hook endpoints behind loopback, tailnet, or trusted reverse proxy.
|
- Keep hook endpoints behind loopback, tailnet, or trusted reverse proxy.
|
||||||
- Use a dedicated hook token; do not reuse gateway auth tokens.
|
- Use a dedicated hook token; do not reuse gateway auth tokens.
|
||||||
- Avoid including sensitive raw payloads in webhook logs.
|
- Avoid including sensitive raw payloads in webhook logs.
|
||||||
|
- Hook payloads are treated as untrusted and wrapped with safety boundaries by default.
|
||||||
|
If you must disable this for a specific hook, set `allowUnsafeExternalContent: true`
|
||||||
|
in that hook's mapping (dangerous).
|
||||||
|
|||||||
103
docs/bedrock.md
103
docs/bedrock.md
@ -17,6 +17,37 @@ not an API key.
|
|||||||
- Auth: AWS credentials (env vars, shared config, or instance role)
|
- Auth: AWS credentials (env vars, shared config, or instance role)
|
||||||
- Region: `AWS_REGION` or `AWS_DEFAULT_REGION` (default: `us-east-1`)
|
- Region: `AWS_REGION` or `AWS_DEFAULT_REGION` (default: `us-east-1`)
|
||||||
|
|
||||||
|
## Automatic model discovery
|
||||||
|
|
||||||
|
If AWS credentials are detected, Clawdbot can automatically discover Bedrock
|
||||||
|
models that support **streaming** and **text output**. Discovery uses
|
||||||
|
`bedrock:ListFoundationModels` and is cached (default: 1 hour).
|
||||||
|
|
||||||
|
Config options live under `models.bedrockDiscovery`:
|
||||||
|
|
||||||
|
```json5
|
||||||
|
{
|
||||||
|
models: {
|
||||||
|
bedrockDiscovery: {
|
||||||
|
enabled: true,
|
||||||
|
region: "us-east-1",
|
||||||
|
providerFilter: ["anthropic", "amazon"],
|
||||||
|
refreshInterval: 3600,
|
||||||
|
defaultContextWindow: 32000,
|
||||||
|
defaultMaxTokens: 4096
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Notes:
|
||||||
|
- `enabled` defaults to `true` when AWS credentials are present.
|
||||||
|
- `region` defaults to `AWS_REGION` or `AWS_DEFAULT_REGION`, then `us-east-1`.
|
||||||
|
- `providerFilter` matches Bedrock provider names (for example `anthropic`).
|
||||||
|
- `refreshInterval` is seconds; set to `0` to disable caching.
|
||||||
|
- `defaultContextWindow` (default: `32000`) and `defaultMaxTokens` (default: `4096`)
|
||||||
|
are used for discovered models (override if you know your model limits).
|
||||||
|
|
||||||
## Setup (manual)
|
## Setup (manual)
|
||||||
|
|
||||||
1) Ensure AWS credentials are available on the **gateway host**:
|
1) Ensure AWS credentials are available on the **gateway host**:
|
||||||
@ -44,10 +75,10 @@ export AWS_BEARER_TOKEN_BEDROCK="..."
|
|||||||
auth: "aws-sdk",
|
auth: "aws-sdk",
|
||||||
models: [
|
models: [
|
||||||
{
|
{
|
||||||
id: "anthropic.claude-3-7-sonnet-20250219-v1:0",
|
id: "anthropic.claude-opus-4-5-20251101-v1:0",
|
||||||
name: "Claude 3.7 Sonnet (Bedrock)",
|
name: "Claude Opus 4.5 (Bedrock)",
|
||||||
reasoning: true,
|
reasoning: true,
|
||||||
input: ["text"],
|
input: ["text", "image"],
|
||||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||||
contextWindow: 200000,
|
contextWindow: 200000,
|
||||||
maxTokens: 8192
|
maxTokens: 8192
|
||||||
@ -58,15 +89,79 @@ export AWS_BEARER_TOKEN_BEDROCK="..."
|
|||||||
},
|
},
|
||||||
agents: {
|
agents: {
|
||||||
defaults: {
|
defaults: {
|
||||||
model: { primary: "amazon-bedrock/anthropic.claude-3-7-sonnet-20250219-v1:0" }
|
model: { primary: "amazon-bedrock/anthropic.claude-opus-4-5-20251101-v1:0" }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## EC2 Instance Roles
|
||||||
|
|
||||||
|
When running Clawdbot on an EC2 instance with an IAM role attached, the AWS SDK
|
||||||
|
will automatically use the instance metadata service (IMDS) for authentication.
|
||||||
|
However, Clawdbot's credential detection currently only checks for environment
|
||||||
|
variables, not IMDS credentials.
|
||||||
|
|
||||||
|
**Workaround:** Set `AWS_PROFILE=default` to signal that AWS credentials are
|
||||||
|
available. The actual authentication still uses the instance role via IMDS.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Add to ~/.bashrc or your shell profile
|
||||||
|
export AWS_PROFILE=default
|
||||||
|
export AWS_REGION=us-east-1
|
||||||
|
```
|
||||||
|
|
||||||
|
**Required IAM permissions** for the EC2 instance role:
|
||||||
|
- `bedrock:InvokeModel`
|
||||||
|
- `bedrock:InvokeModelWithResponseStream`
|
||||||
|
- `bedrock:ListFoundationModels` (for automatic discovery)
|
||||||
|
|
||||||
|
Or attach the managed policy `AmazonBedrockFullAccess`.
|
||||||
|
|
||||||
|
**Quick setup:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Create IAM role and instance profile
|
||||||
|
aws iam create-role --role-name EC2-Bedrock-Access \
|
||||||
|
--assume-role-policy-document '{
|
||||||
|
"Version": "2012-10-17",
|
||||||
|
"Statement": [{
|
||||||
|
"Effect": "Allow",
|
||||||
|
"Principal": {"Service": "ec2.amazonaws.com"},
|
||||||
|
"Action": "sts:AssumeRole"
|
||||||
|
}]
|
||||||
|
}'
|
||||||
|
|
||||||
|
aws iam attach-role-policy --role-name EC2-Bedrock-Access \
|
||||||
|
--policy-arn arn:aws:iam::aws:policy/AmazonBedrockFullAccess
|
||||||
|
|
||||||
|
aws iam create-instance-profile --instance-profile-name EC2-Bedrock-Access
|
||||||
|
aws iam add-role-to-instance-profile \
|
||||||
|
--instance-profile-name EC2-Bedrock-Access \
|
||||||
|
--role-name EC2-Bedrock-Access
|
||||||
|
|
||||||
|
# 2. Attach to your EC2 instance
|
||||||
|
aws ec2 associate-iam-instance-profile \
|
||||||
|
--instance-id i-xxxxx \
|
||||||
|
--iam-instance-profile Name=EC2-Bedrock-Access
|
||||||
|
|
||||||
|
# 3. On the EC2 instance, enable discovery
|
||||||
|
clawdbot config set models.bedrockDiscovery.enabled true
|
||||||
|
clawdbot config set models.bedrockDiscovery.region us-east-1
|
||||||
|
|
||||||
|
# 4. Set the workaround env vars
|
||||||
|
echo 'export AWS_PROFILE=default' >> ~/.bashrc
|
||||||
|
echo 'export AWS_REGION=us-east-1' >> ~/.bashrc
|
||||||
|
source ~/.bashrc
|
||||||
|
|
||||||
|
# 5. Verify models are discovered
|
||||||
|
clawdbot models list
|
||||||
|
```
|
||||||
|
|
||||||
## Notes
|
## Notes
|
||||||
|
|
||||||
- Bedrock requires **model access** enabled in your AWS account/region.
|
- Bedrock requires **model access** enabled in your AWS account/region.
|
||||||
|
- Automatic discovery needs the `bedrock:ListFoundationModels` permission.
|
||||||
- If you use profiles, set `AWS_PROFILE` on the gateway host.
|
- If you use profiles, set `AWS_PROFILE` on the gateway host.
|
||||||
- Clawdbot surfaces the credential source in this order: `AWS_BEARER_TOKEN_BEDROCK`,
|
- Clawdbot surfaces the credential source in this order: `AWS_BEARER_TOKEN_BEDROCK`,
|
||||||
then `AWS_ACCESS_KEY_ID` + `AWS_SECRET_ACCESS_KEY`, then `AWS_PROFILE`, then the
|
then `AWS_ACCESS_KEY_ID` + `AWS_SECRET_ACCESS_KEY`, then `AWS_PROFILE`, then the
|
||||||
|
|||||||
@ -196,6 +196,7 @@ Provider options:
|
|||||||
- `channels.bluebubbles.sendReadReceipts`: Send read receipts (default: `true`).
|
- `channels.bluebubbles.sendReadReceipts`: Send read receipts (default: `true`).
|
||||||
- `channels.bluebubbles.blockStreaming`: Enable block streaming (default: `true`).
|
- `channels.bluebubbles.blockStreaming`: Enable block streaming (default: `true`).
|
||||||
- `channels.bluebubbles.textChunkLimit`: Outbound chunk size in chars (default: 4000).
|
- `channels.bluebubbles.textChunkLimit`: Outbound chunk size in chars (default: 4000).
|
||||||
|
- `channels.bluebubbles.chunkMode`: `length` (default) splits only when exceeding `textChunkLimit`; `newline` splits on blank lines (paragraph boundaries) before length chunking.
|
||||||
- `channels.bluebubbles.mediaMaxMb`: Inbound media cap in MB (default: 8).
|
- `channels.bluebubbles.mediaMaxMb`: Inbound media cap in MB (default: 8).
|
||||||
- `channels.bluebubbles.historyLimit`: Max group messages for context (0 disables).
|
- `channels.bluebubbles.historyLimit`: Max group messages for context (0 disables).
|
||||||
- `channels.bluebubbles.dmHistoryLimit`: DM history limit.
|
- `channels.bluebubbles.dmHistoryLimit`: DM history limit.
|
||||||
@ -212,6 +213,7 @@ Prefer `chat_guid` for stable routing:
|
|||||||
- `chat_id:123`
|
- `chat_id:123`
|
||||||
- `chat_identifier:...`
|
- `chat_identifier:...`
|
||||||
- Direct handles: `+15555550123`, `user@example.com`
|
- Direct handles: `+15555550123`, `user@example.com`
|
||||||
|
- If a direct handle does not have an existing DM chat, Clawdbot will create one via `POST /api/v1/chat/new`. This requires the BlueBubbles Private API to be enabled.
|
||||||
|
|
||||||
## Security
|
## Security
|
||||||
- Webhook requests are authenticated by comparing `guid`/`password` query params or headers against `channels.bluebubbles.password`. Requests from `localhost` are also accepted.
|
- Webhook requests are authenticated by comparing `guid`/`password` query params or headers against `channels.bluebubbles.password`. Requests from `localhost` are also accepted.
|
||||||
|
|||||||
@ -10,13 +10,14 @@ Status: ready for DM and guild text channels via the official Discord bot gatewa
|
|||||||
|
|
||||||
## Quick setup (beginner)
|
## Quick setup (beginner)
|
||||||
1) Create a Discord bot and copy the bot token.
|
1) Create a Discord bot and copy the bot token.
|
||||||
2) Set the token for Clawdbot:
|
2) In the Discord app settings, enable **Message Content Intent** (and **Server Members Intent** if you plan to use allowlists or name lookups).
|
||||||
|
3) Set the token for Clawdbot:
|
||||||
- Env: `DISCORD_BOT_TOKEN=...`
|
- Env: `DISCORD_BOT_TOKEN=...`
|
||||||
- Or config: `channels.discord.token: "..."`.
|
- Or config: `channels.discord.token: "..."`.
|
||||||
- If both are set, config takes precedence (env fallback is default-account only).
|
- If both are set, config takes precedence (env fallback is default-account only).
|
||||||
3) Invite the bot to your server with message permissions.
|
4) Invite the bot to your server with message permissions (create a private server if you just want DMs).
|
||||||
4) Start the gateway.
|
5) Start the gateway.
|
||||||
5) DM access is pairing by default; approve the pairing code on first contact.
|
6) DM access is pairing by default; approve the pairing code on first contact.
|
||||||
|
|
||||||
Minimal config:
|
Minimal config:
|
||||||
```json5
|
```json5
|
||||||
@ -205,6 +206,7 @@ Notes:
|
|||||||
## Capabilities & limits
|
## Capabilities & limits
|
||||||
- DMs and guild text channels (threads are treated as separate channels; voice not supported).
|
- DMs and guild text channels (threads are treated as separate channels; voice not supported).
|
||||||
- Typing indicators sent best-effort; message chunking uses `channels.discord.textChunkLimit` (default 2000) and splits tall replies by line count (`channels.discord.maxLinesPerMessage`, default 17).
|
- Typing indicators sent best-effort; message chunking uses `channels.discord.textChunkLimit` (default 2000) and splits tall replies by line count (`channels.discord.maxLinesPerMessage`, default 17).
|
||||||
|
- Optional newline chunking: set `channels.discord.chunkMode="newline"` to split on blank lines (paragraph boundaries) before length chunking.
|
||||||
- File uploads supported up to the configured `channels.discord.mediaMaxMb` (default 8 MB).
|
- File uploads supported up to the configured `channels.discord.mediaMaxMb` (default 8 MB).
|
||||||
- Mention-gated guild replies by default to avoid noisy bots.
|
- Mention-gated guild replies by default to avoid noisy bots.
|
||||||
- Reply context is injected when a message references another message (quoted content + ids).
|
- Reply context is injected when a message references another message (quoted content + ids).
|
||||||
@ -306,6 +308,7 @@ ack reaction after the bot replies.
|
|||||||
- `guilds.<id>.requireMention`: per-guild mention requirement (overridable per channel).
|
- `guilds.<id>.requireMention`: per-guild mention requirement (overridable per channel).
|
||||||
- `guilds.<id>.reactionNotifications`: reaction system event mode (`off`, `own`, `all`, `allowlist`).
|
- `guilds.<id>.reactionNotifications`: reaction system event mode (`off`, `own`, `all`, `allowlist`).
|
||||||
- `textChunkLimit`: outbound text chunk size (chars). Default: 2000.
|
- `textChunkLimit`: outbound text chunk size (chars). Default: 2000.
|
||||||
|
- `chunkMode`: `length` (default) splits only when exceeding `textChunkLimit`; `newline` splits on blank lines (paragraph boundaries) before length chunking.
|
||||||
- `maxLinesPerMessage`: soft max line count per message. Default: 17.
|
- `maxLinesPerMessage`: soft max line count per message. Default: 17.
|
||||||
- `mediaMaxMb`: clamp inbound media saved to disk.
|
- `mediaMaxMb`: clamp inbound media saved to disk.
|
||||||
- `historyLimit`: number of recent guild messages to include as context when replying to a mention (default 20; falls back to `messages.groupChat.historyLimit`; `0` disables).
|
- `historyLimit`: number of recent guild messages to include as context when replying to a mention (default 20; falls back to `messages.groupChat.historyLimit`; `0` disables).
|
||||||
|
|||||||
220
docs/channels/googlechat.md
Normal file
220
docs/channels/googlechat.md
Normal file
@ -0,0 +1,220 @@
|
|||||||
|
---
|
||||||
|
summary: "Google Chat app support status, capabilities, and configuration"
|
||||||
|
read_when:
|
||||||
|
- Working on Google Chat channel features
|
||||||
|
---
|
||||||
|
# Google Chat (Chat API)
|
||||||
|
|
||||||
|
Status: ready for DMs + spaces via Google Chat API webhooks (HTTP only).
|
||||||
|
|
||||||
|
## Quick setup (beginner)
|
||||||
|
1) Create a Google Cloud project and enable the **Google Chat API**.
|
||||||
|
- Go to: [Google Chat API Credentials](https://console.cloud.google.com/apis/api/chat.googleapis.com/credentials)
|
||||||
|
- Enable the API if it is not already enabled.
|
||||||
|
2) Create a **Service Account**:
|
||||||
|
- Press **Create Credentials** > **Service Account**.
|
||||||
|
- Name it whatever you want (e.g., `clawdbot-chat`).
|
||||||
|
- Leave permissions blank (press **Continue**).
|
||||||
|
- Leave principals with access blank (press **Done**).
|
||||||
|
3) Create and download the **JSON Key**:
|
||||||
|
- In the list of service accounts, click on the one you just created.
|
||||||
|
- Go to the **Keys** tab.
|
||||||
|
- Click **Add Key** > **Create new key**.
|
||||||
|
- Select **JSON** and press **Create**.
|
||||||
|
4) Store the downloaded JSON file on your gateway host (e.g., `~/.clawdbot/googlechat-service-account.json`).
|
||||||
|
5) Create a Google Chat app in the [Google Cloud Console Chat Configuration](https://console.cloud.google.com/apis/api/chat.googleapis.com/hangouts-chat):
|
||||||
|
- Fill in the **Application info**:
|
||||||
|
- **App name**: (e.g. `Clawdbot`)
|
||||||
|
- **Avatar URL**: (e.g. `https://clawd.bot/logo.png`)
|
||||||
|
- **Description**: (e.g. `Personal AI Assistant`)
|
||||||
|
- Enable **Interactive features**.
|
||||||
|
- Under **Functionality**, check **Join spaces and group conversations**.
|
||||||
|
- Under **Connection settings**, select **HTTP endpoint URL**.
|
||||||
|
- Under **Triggers**, select **Use a common HTTP endpoint URL for all triggers** and set it to your gateway's public URL followed by `/googlechat`.
|
||||||
|
- *Tip: Run `clawdbot status` to find your gateway's public URL.*
|
||||||
|
- Under **Visibility**, check **Make this Chat app available to specific people and groups in <Your Domain>**.
|
||||||
|
- Enter your email address (e.g. `user@example.com`) in the text box.
|
||||||
|
- Click **Save** at the bottom.
|
||||||
|
6) **Enable the app status**:
|
||||||
|
- After saving, **refresh the page**.
|
||||||
|
- Look for the **App status** section (usually near the top or bottom after saving).
|
||||||
|
- Change the status to **Live - available to users**.
|
||||||
|
- Click **Save** again.
|
||||||
|
7) Configure Clawdbot with the service account path + webhook audience:
|
||||||
|
- Env: `GOOGLE_CHAT_SERVICE_ACCOUNT_FILE=/path/to/service-account.json`
|
||||||
|
- Or config: `channels.googlechat.serviceAccountFile: "/path/to/service-account.json"`.
|
||||||
|
8) Set the webhook audience type + value (matches your Chat app config).
|
||||||
|
9) Start the gateway. Google Chat will POST to your webhook path.
|
||||||
|
|
||||||
|
## Add to Google Chat
|
||||||
|
Once the gateway is running and your email is added to the visibility list:
|
||||||
|
1) Go to [Google Chat](https://chat.google.com/).
|
||||||
|
2) Click the **+** (plus) icon next to **Direct Messages**.
|
||||||
|
3) In the search bar (where you usually add people), type the **App name** you configured in the Google Cloud Console.
|
||||||
|
- **Note**: The bot will *not* appear in the "Marketplace" browse list because it is a private app. You must search for it by name.
|
||||||
|
4) Select your bot from the results.
|
||||||
|
5) Click **Add** or **Chat** to start a 1:1 conversation.
|
||||||
|
6) Send "Hello" to trigger the assistant!
|
||||||
|
|
||||||
|
## Public URL (Webhook-only)
|
||||||
|
Google Chat webhooks require a public HTTPS endpoint. For security, **only expose the `/googlechat` path** to the internet. Keep the Clawdbot dashboard and other sensitive endpoints on your private network.
|
||||||
|
|
||||||
|
### Option A: Tailscale Funnel (Recommended)
|
||||||
|
Use Tailscale Serve for the private dashboard and Funnel for the public webhook path. This keeps `/` private while exposing only `/googlechat`.
|
||||||
|
|
||||||
|
1. **Check what address your gateway is bound to:**
|
||||||
|
```bash
|
||||||
|
ss -tlnp | grep 18789
|
||||||
|
```
|
||||||
|
Note the IP address (e.g., `127.0.0.1`, `0.0.0.0`, or your Tailscale IP like `100.x.x.x`).
|
||||||
|
|
||||||
|
2. **Expose the dashboard to the tailnet only (port 8443):**
|
||||||
|
```bash
|
||||||
|
# If bound to localhost (127.0.0.1 or 0.0.0.0):
|
||||||
|
tailscale serve --bg --https 8443 http://127.0.0.1:18789
|
||||||
|
|
||||||
|
# If bound to Tailscale IP only (e.g., 100.106.161.80):
|
||||||
|
tailscale serve --bg --https 8443 http://100.106.161.80:18789
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Expose only the webhook path publicly:**
|
||||||
|
```bash
|
||||||
|
# If bound to localhost (127.0.0.1 or 0.0.0.0):
|
||||||
|
tailscale funnel --bg --set-path /googlechat http://127.0.0.1:18789/googlechat
|
||||||
|
|
||||||
|
# If bound to Tailscale IP only (e.g., 100.106.161.80):
|
||||||
|
tailscale funnel --bg --set-path /googlechat http://100.106.161.80:18789/googlechat
|
||||||
|
```
|
||||||
|
|
||||||
|
4. **Authorize the node for Funnel access:**
|
||||||
|
If prompted, visit the authorization URL shown in the output to enable Funnel for this node in your tailnet policy.
|
||||||
|
|
||||||
|
5. **Verify the configuration:**
|
||||||
|
```bash
|
||||||
|
tailscale serve status
|
||||||
|
tailscale funnel status
|
||||||
|
```
|
||||||
|
|
||||||
|
Your public webhook URL will be:
|
||||||
|
`https://<node-name>.<tailnet>.ts.net/googlechat`
|
||||||
|
|
||||||
|
Your private dashboard stays tailnet-only:
|
||||||
|
`https://<node-name>.<tailnet>.ts.net:8443/`
|
||||||
|
|
||||||
|
Use the public URL (without `:8443`) in the Google Chat app config.
|
||||||
|
|
||||||
|
> Note: This configuration persists across reboots. To remove it later, run `tailscale funnel reset` and `tailscale serve reset`.
|
||||||
|
|
||||||
|
### Option B: Reverse Proxy (Caddy)
|
||||||
|
If you use a reverse proxy like Caddy, only proxy the specific path:
|
||||||
|
```caddy
|
||||||
|
your-domain.com {
|
||||||
|
reverse_proxy /googlechat* localhost:18789
|
||||||
|
}
|
||||||
|
```
|
||||||
|
With this config, any request to `your-domain.com/` will be ignored or returned as 404, while `your-domain.com/googlechat` is safely routed to Clawdbot.
|
||||||
|
|
||||||
|
### Option C: Cloudflare Tunnel
|
||||||
|
Configure your tunnel's ingress rules to only route the webhook path:
|
||||||
|
- **Path**: `/googlechat` -> `http://localhost:18789/googlechat`
|
||||||
|
- **Default Rule**: HTTP 404 (Not Found)
|
||||||
|
|
||||||
|
## How it works
|
||||||
|
|
||||||
|
1. Google Chat sends webhook POSTs to the gateway. Each request includes an `Authorization: Bearer <token>` header.
|
||||||
|
2. Clawdbot verifies the token against the configured `audienceType` + `audience`:
|
||||||
|
- `audienceType: "app-url"` → audience is your HTTPS webhook URL.
|
||||||
|
- `audienceType: "project-number"` → audience is the Cloud project number.
|
||||||
|
3. Messages are routed by space:
|
||||||
|
- DMs use session key `agent:<agentId>:googlechat:dm:<spaceId>`.
|
||||||
|
- Spaces use session key `agent:<agentId>:googlechat:group:<spaceId>`.
|
||||||
|
4. DM access is pairing by default. Unknown senders receive a pairing code; approve with:
|
||||||
|
- `clawdbot pairing approve googlechat <code>`
|
||||||
|
5. Group spaces require @-mention by default. Use `botUser` if mention detection needs the app’s user name.
|
||||||
|
|
||||||
|
## Targets
|
||||||
|
Use these identifiers for delivery and allowlists:
|
||||||
|
- Direct messages: `users/<userId>` or `users/<email>` (email addresses are accepted).
|
||||||
|
- Spaces: `spaces/<spaceId>`.
|
||||||
|
|
||||||
|
## Config highlights
|
||||||
|
```json5
|
||||||
|
{
|
||||||
|
channels: {
|
||||||
|
"googlechat": {
|
||||||
|
enabled: true,
|
||||||
|
serviceAccountFile: "/path/to/service-account.json",
|
||||||
|
audienceType: "app-url",
|
||||||
|
audience: "https://gateway.example.com/googlechat",
|
||||||
|
webhookPath: "/googlechat",
|
||||||
|
botUser: "users/1234567890", // optional; helps mention detection
|
||||||
|
dm: {
|
||||||
|
policy: "pairing",
|
||||||
|
allowFrom: ["users/1234567890", "name@example.com"]
|
||||||
|
},
|
||||||
|
groupPolicy: "allowlist",
|
||||||
|
groups: {
|
||||||
|
"spaces/AAAA": {
|
||||||
|
allow: true,
|
||||||
|
requireMention: true,
|
||||||
|
users: ["users/1234567890"],
|
||||||
|
systemPrompt: "Short answers only."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
actions: { reactions: true },
|
||||||
|
typingIndicator: "message",
|
||||||
|
mediaMaxMb: 20
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Notes:
|
||||||
|
- Service account credentials can also be passed inline with `serviceAccount` (JSON string).
|
||||||
|
- Default webhook path is `/googlechat` if `webhookPath` isn’t set.
|
||||||
|
- Reactions are available via the `reactions` tool and `channels action` when `actions.reactions` is enabled.
|
||||||
|
- `typingIndicator` supports `none`, `message` (default), and `reaction` (reaction requires user OAuth).
|
||||||
|
- Attachments are downloaded through the Chat API and stored in the media pipeline (size capped by `mediaMaxMb`).
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### 405 Method Not Allowed
|
||||||
|
If Google Cloud Logs Explorer shows errors like:
|
||||||
|
```
|
||||||
|
status code: 405, reason phrase: HTTP error response: HTTP/1.1 405 Method Not Allowed
|
||||||
|
```
|
||||||
|
|
||||||
|
This means the webhook handler isn't registered. Common causes:
|
||||||
|
1. **Channel not configured**: The `channels.googlechat` section is missing from your config. Verify with:
|
||||||
|
```bash
|
||||||
|
clawdbot config get channels.googlechat
|
||||||
|
```
|
||||||
|
If it returns "Config path not found", add the configuration (see [Config highlights](#config-highlights)).
|
||||||
|
|
||||||
|
2. **Plugin not enabled**: Check plugin status:
|
||||||
|
```bash
|
||||||
|
clawdbot plugins list | grep googlechat
|
||||||
|
```
|
||||||
|
If it shows "disabled", add `plugins.entries.googlechat.enabled: true` to your config.
|
||||||
|
|
||||||
|
3. **Gateway not restarted**: After adding config, restart the gateway:
|
||||||
|
```bash
|
||||||
|
clawdbot gateway restart
|
||||||
|
```
|
||||||
|
|
||||||
|
Verify the channel is running:
|
||||||
|
```bash
|
||||||
|
clawdbot channels status
|
||||||
|
# Should show: Google Chat default: enabled, configured, ...
|
||||||
|
```
|
||||||
|
|
||||||
|
### Other issues
|
||||||
|
- Check `clawdbot channels status --probe` for auth errors or missing audience config.
|
||||||
|
- If no messages arrive, confirm the Chat app's webhook URL + event subscriptions.
|
||||||
|
- If mention gating blocks replies, set `botUser` to the app's user resource name and verify `requireMention`.
|
||||||
|
- Use `clawdbot logs --follow` while sending a test message to see if requests reach the gateway.
|
||||||
|
|
||||||
|
Related docs:
|
||||||
|
- [Gateway configuration](/gateway/configuration)
|
||||||
|
- [Security](/gateway/security)
|
||||||
|
- [Reactions](/tools/reactions)
|
||||||
@ -17,7 +17,7 @@ read_when:
|
|||||||
- **Proxy:** optional `channels.telegram.proxy` uses `undici.ProxyAgent` through grammY’s `client.baseFetch`.
|
- **Proxy:** optional `channels.telegram.proxy` uses `undici.ProxyAgent` through grammY’s `client.baseFetch`.
|
||||||
- **Webhook support:** `webhook-set.ts` wraps `setWebhook/deleteWebhook`; `webhook.ts` hosts the callback with health + graceful shutdown. Gateway enables webhook mode when `channels.telegram.webhookUrl` is set (otherwise it long-polls).
|
- **Webhook support:** `webhook-set.ts` wraps `setWebhook/deleteWebhook`; `webhook.ts` hosts the callback with health + graceful shutdown. Gateway enables webhook mode when `channels.telegram.webhookUrl` is set (otherwise it long-polls).
|
||||||
- **Sessions:** direct chats collapse into the agent main session (`agent:<agentId>:<mainKey>`); groups use `agent:<agentId>:telegram:group:<chatId>`; replies route back to the same channel.
|
- **Sessions:** direct chats collapse into the agent main session (`agent:<agentId>:<mainKey>`); groups use `agent:<agentId>:telegram:group:<chatId>`; replies route back to the same channel.
|
||||||
- **Config knobs:** `channels.telegram.botToken`, `channels.telegram.dmPolicy`, `channels.telegram.groups` (allowlist + mention defaults), `channels.telegram.allowFrom`, `channels.telegram.groupAllowFrom`, `channels.telegram.groupPolicy`, `channels.telegram.mediaMaxMb`, `channels.telegram.proxy`, `channels.telegram.webhookSecret`, `channels.telegram.webhookUrl`.
|
- **Config knobs:** `channels.telegram.botToken`, `channels.telegram.dmPolicy`, `channels.telegram.groups` (allowlist + mention defaults), `channels.telegram.allowFrom`, `channels.telegram.groupAllowFrom`, `channels.telegram.groupPolicy`, `channels.telegram.mediaMaxMb`, `channels.telegram.linkPreview`, `channels.telegram.proxy`, `channels.telegram.webhookSecret`, `channels.telegram.webhookUrl`.
|
||||||
- **Draft streaming:** optional `channels.telegram.streamMode` uses `sendMessageDraft` in private topic chats (Bot API 9.3+). This is separate from channel block streaming.
|
- **Draft streaming:** optional `channels.telegram.streamMode` uses `sendMessageDraft` in private topic chats (Bot API 9.3+). This is separate from channel block streaming.
|
||||||
- **Tests:** grammy mocks cover DM + group mention gating and outbound send; more media/webhook fixtures still welcome.
|
- **Tests:** grammy mocks cover DM + group mention gating and outbound send; more media/webhook fixtures still welcome.
|
||||||
|
|
||||||
|
|||||||
@ -219,6 +219,7 @@ This is useful when you want an isolated personality/model for a specific thread
|
|||||||
|
|
||||||
## Limits
|
## Limits
|
||||||
- Outbound text is chunked to `channels.imessage.textChunkLimit` (default 4000).
|
- Outbound text is chunked to `channels.imessage.textChunkLimit` (default 4000).
|
||||||
|
- Optional newline chunking: set `channels.imessage.chunkMode="newline"` to split on blank lines (paragraph boundaries) before length chunking.
|
||||||
- Media uploads are capped by `channels.imessage.mediaMaxMb` (default 16).
|
- Media uploads are capped by `channels.imessage.mediaMaxMb` (default 16).
|
||||||
|
|
||||||
## Addressing / delivery targets
|
## Addressing / delivery targets
|
||||||
@ -253,6 +254,7 @@ Provider options:
|
|||||||
- `channels.imessage.includeAttachments`: ingest attachments into context.
|
- `channels.imessage.includeAttachments`: ingest attachments into context.
|
||||||
- `channels.imessage.mediaMaxMb`: inbound/outbound media cap (MB).
|
- `channels.imessage.mediaMaxMb`: inbound/outbound media cap (MB).
|
||||||
- `channels.imessage.textChunkLimit`: outbound chunk size (chars).
|
- `channels.imessage.textChunkLimit`: outbound chunk size (chars).
|
||||||
|
- `channels.imessage.chunkMode`: `length` (default) or `newline` to split on blank lines (paragraph boundaries) before length chunking.
|
||||||
|
|
||||||
Related global options:
|
Related global options:
|
||||||
- `agents.list[].groupChat.mentionPatterns` (or `messages.groupChat.mentionPatterns`).
|
- `agents.list[].groupChat.mentionPatterns` (or `messages.groupChat.mentionPatterns`).
|
||||||
|
|||||||
@ -15,14 +15,18 @@ Text is supported everywhere; media and reactions vary by channel.
|
|||||||
- [Telegram](/channels/telegram) — Bot API via grammY; supports groups.
|
- [Telegram](/channels/telegram) — Bot API via grammY; supports groups.
|
||||||
- [Discord](/channels/discord) — Discord Bot API + Gateway; supports servers, channels, and DMs.
|
- [Discord](/channels/discord) — Discord Bot API + Gateway; supports servers, channels, and DMs.
|
||||||
- [Slack](/channels/slack) — Bolt SDK; workspace apps.
|
- [Slack](/channels/slack) — Bolt SDK; workspace apps.
|
||||||
|
- [Google Chat](/channels/googlechat) — Google Chat API app via HTTP webhook.
|
||||||
- [Mattermost](/channels/mattermost) — Bot API + WebSocket; channels, groups, DMs (plugin, installed separately).
|
- [Mattermost](/channels/mattermost) — Bot API + WebSocket; channels, groups, DMs (plugin, installed separately).
|
||||||
- [Signal](/channels/signal) — signal-cli; privacy-focused.
|
- [Signal](/channels/signal) — signal-cli; privacy-focused.
|
||||||
- [BlueBubbles](/channels/bluebubbles) — **Recommended for iMessage**; uses the BlueBubbles macOS server REST API with full feature support (edit, unsend, effects, reactions, group management — edit currently broken on macOS 26 Tahoe).
|
- [BlueBubbles](/channels/bluebubbles) — **Recommended for iMessage**; uses the BlueBubbles macOS server REST API with full feature support (edit, unsend, effects, reactions, group management — edit currently broken on macOS 26 Tahoe).
|
||||||
- [iMessage](/channels/imessage) — macOS only; native integration via imsg (legacy, consider BlueBubbles for new setups).
|
- [iMessage](/channels/imessage) — macOS only; native integration via imsg (legacy, consider BlueBubbles for new setups).
|
||||||
- [Microsoft Teams](/channels/msteams) — Bot Framework; enterprise support (plugin, installed separately).
|
- [Microsoft Teams](/channels/msteams) — Bot Framework; enterprise support (plugin, installed separately).
|
||||||
|
- [LINE](/channels/line) — LINE Messaging API bot (plugin, installed separately).
|
||||||
- [Nextcloud Talk](/channels/nextcloud-talk) — Self-hosted chat via Nextcloud Talk (plugin, installed separately).
|
- [Nextcloud Talk](/channels/nextcloud-talk) — Self-hosted chat via Nextcloud Talk (plugin, installed separately).
|
||||||
- [Matrix](/channels/matrix) — Matrix protocol (plugin, installed separately).
|
- [Matrix](/channels/matrix) — Matrix protocol (plugin, installed separately).
|
||||||
- [Nostr](/channels/nostr) — Decentralized DMs via NIP-04 (plugin, installed separately).
|
- [Nostr](/channels/nostr) — Decentralized DMs via NIP-04 (plugin, installed separately).
|
||||||
|
- [Tlon](/channels/tlon) — Urbit-based messenger (plugin, installed separately).
|
||||||
|
- [Twitch](/channels/twitch) — Twitch chat via IRC connection (plugin, installed separately).
|
||||||
- [Zalo](/channels/zalo) — Zalo Bot API; Vietnam's popular messenger (plugin, installed separately).
|
- [Zalo](/channels/zalo) — Zalo Bot API; Vietnam's popular messenger (plugin, installed separately).
|
||||||
- [Zalo Personal](/channels/zalouser) — Zalo personal account via QR login (plugin, installed separately).
|
- [Zalo Personal](/channels/zalouser) — Zalo personal account via QR login (plugin, installed separately).
|
||||||
- [WebChat](/web/webchat) — Gateway WebChat UI over WebSocket.
|
- [WebChat](/web/webchat) — Gateway WebChat UI over WebSocket.
|
||||||
@ -30,6 +34,8 @@ Text is supported everywhere; media and reactions vary by channel.
|
|||||||
## Notes
|
## Notes
|
||||||
|
|
||||||
- Channels can run simultaneously; configure multiple and Clawdbot will route per chat.
|
- Channels can run simultaneously; configure multiple and Clawdbot will route per chat.
|
||||||
|
- Fastest setup is usually **Telegram** (simple bot token). WhatsApp requires QR pairing and
|
||||||
|
stores more state on disk.
|
||||||
- Group behavior varies by channel; see [Groups](/concepts/groups).
|
- Group behavior varies by channel; see [Groups](/concepts/groups).
|
||||||
- DM pairing and allowlists are enforced for safety; see [Security](/gateway/security).
|
- DM pairing and allowlists are enforced for safety; see [Security](/gateway/security).
|
||||||
- Telegram internals: [grammY notes](/channels/grammy).
|
- Telegram internals: [grammY notes](/channels/grammy).
|
||||||
|
|||||||
183
docs/channels/line.md
Normal file
183
docs/channels/line.md
Normal file
@ -0,0 +1,183 @@
|
|||||||
|
---
|
||||||
|
summary: "LINE Messaging API plugin setup, config, and usage"
|
||||||
|
read_when:
|
||||||
|
- You want to connect Clawdbot to LINE
|
||||||
|
- You need LINE webhook + credential setup
|
||||||
|
- You want LINE-specific message options
|
||||||
|
---
|
||||||
|
|
||||||
|
# LINE (plugin)
|
||||||
|
|
||||||
|
LINE connects to Clawdbot via the LINE Messaging API. The plugin runs as a webhook
|
||||||
|
receiver on the gateway and uses your channel access token + channel secret for
|
||||||
|
authentication.
|
||||||
|
|
||||||
|
Status: supported via plugin. Direct messages, group chats, media, locations, Flex
|
||||||
|
messages, template messages, and quick replies are supported. Reactions and threads
|
||||||
|
are not supported.
|
||||||
|
|
||||||
|
## Plugin required
|
||||||
|
|
||||||
|
Install the LINE plugin:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
clawdbot plugins install @clawdbot/line
|
||||||
|
```
|
||||||
|
|
||||||
|
Local checkout (when running from a git repo):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
clawdbot plugins install ./extensions/line
|
||||||
|
```
|
||||||
|
|
||||||
|
## Setup
|
||||||
|
|
||||||
|
1) Create a LINE Developers account and open the Console:
|
||||||
|
https://developers.line.biz/console/
|
||||||
|
2) Create (or pick) a Provider and add a **Messaging API** channel.
|
||||||
|
3) Copy the **Channel access token** and **Channel secret** from the channel settings.
|
||||||
|
4) Enable **Use webhook** in the Messaging API settings.
|
||||||
|
5) Set the webhook URL to your gateway endpoint (HTTPS required):
|
||||||
|
|
||||||
|
```
|
||||||
|
https://gateway-host/line/webhook
|
||||||
|
```
|
||||||
|
|
||||||
|
The gateway responds to LINE’s webhook verification (GET) and inbound events (POST).
|
||||||
|
If you need a custom path, set `channels.line.webhookPath` or
|
||||||
|
`channels.line.accounts.<id>.webhookPath` and update the URL accordingly.
|
||||||
|
|
||||||
|
## Configure
|
||||||
|
|
||||||
|
Minimal config:
|
||||||
|
|
||||||
|
```json5
|
||||||
|
{
|
||||||
|
channels: {
|
||||||
|
line: {
|
||||||
|
enabled: true,
|
||||||
|
channelAccessToken: "LINE_CHANNEL_ACCESS_TOKEN",
|
||||||
|
channelSecret: "LINE_CHANNEL_SECRET",
|
||||||
|
dmPolicy: "pairing"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Env vars (default account only):
|
||||||
|
|
||||||
|
- `LINE_CHANNEL_ACCESS_TOKEN`
|
||||||
|
- `LINE_CHANNEL_SECRET`
|
||||||
|
|
||||||
|
Token/secret files:
|
||||||
|
|
||||||
|
```json5
|
||||||
|
{
|
||||||
|
channels: {
|
||||||
|
line: {
|
||||||
|
tokenFile: "/path/to/line-token.txt",
|
||||||
|
secretFile: "/path/to/line-secret.txt"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Multiple accounts:
|
||||||
|
|
||||||
|
```json5
|
||||||
|
{
|
||||||
|
channels: {
|
||||||
|
line: {
|
||||||
|
accounts: {
|
||||||
|
marketing: {
|
||||||
|
channelAccessToken: "...",
|
||||||
|
channelSecret: "...",
|
||||||
|
webhookPath: "/line/marketing"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Access control
|
||||||
|
|
||||||
|
Direct messages default to pairing. Unknown senders get a pairing code and their
|
||||||
|
messages are ignored until approved.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
clawdbot pairing list line
|
||||||
|
clawdbot pairing approve line <CODE>
|
||||||
|
```
|
||||||
|
|
||||||
|
Allowlists and policies:
|
||||||
|
|
||||||
|
- `channels.line.dmPolicy`: `pairing | allowlist | open | disabled`
|
||||||
|
- `channels.line.allowFrom`: allowlisted LINE user IDs for DMs
|
||||||
|
- `channels.line.groupPolicy`: `allowlist | open | disabled`
|
||||||
|
- `channels.line.groupAllowFrom`: allowlisted LINE user IDs for groups
|
||||||
|
- Per-group overrides: `channels.line.groups.<groupId>.allowFrom`
|
||||||
|
|
||||||
|
LINE IDs are case-sensitive. Valid IDs look like:
|
||||||
|
|
||||||
|
- User: `U` + 32 hex chars
|
||||||
|
- Group: `C` + 32 hex chars
|
||||||
|
- Room: `R` + 32 hex chars
|
||||||
|
|
||||||
|
## Message behavior
|
||||||
|
|
||||||
|
- Text is chunked at 5000 characters.
|
||||||
|
- Markdown formatting is stripped; code blocks and tables are converted into Flex
|
||||||
|
cards when possible.
|
||||||
|
- Streaming responses are buffered; LINE receives full chunks with a loading
|
||||||
|
animation while the agent works.
|
||||||
|
- Media downloads are capped by `channels.line.mediaMaxMb` (default 10).
|
||||||
|
|
||||||
|
## Channel data (rich messages)
|
||||||
|
|
||||||
|
Use `channelData.line` to send quick replies, locations, Flex cards, or template
|
||||||
|
messages.
|
||||||
|
|
||||||
|
```json5
|
||||||
|
{
|
||||||
|
text: "Here you go",
|
||||||
|
channelData: {
|
||||||
|
line: {
|
||||||
|
quickReplies: ["Status", "Help"],
|
||||||
|
location: {
|
||||||
|
title: "Office",
|
||||||
|
address: "123 Main St",
|
||||||
|
latitude: 35.681236,
|
||||||
|
longitude: 139.767125
|
||||||
|
},
|
||||||
|
flexMessage: {
|
||||||
|
altText: "Status card",
|
||||||
|
contents: { /* Flex payload */ }
|
||||||
|
},
|
||||||
|
templateMessage: {
|
||||||
|
type: "confirm",
|
||||||
|
text: "Proceed?",
|
||||||
|
confirmLabel: "Yes",
|
||||||
|
confirmData: "yes",
|
||||||
|
cancelLabel: "No",
|
||||||
|
cancelData: "no"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The LINE plugin also ships a `/card` command for Flex message presets:
|
||||||
|
|
||||||
|
```
|
||||||
|
/card info "Welcome" "Thanks for joining!"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
- **Webhook verification fails:** ensure the webhook URL is HTTPS and the
|
||||||
|
`channelSecret` matches the LINE console.
|
||||||
|
- **No inbound events:** confirm the webhook path matches `channels.line.webhookPath`
|
||||||
|
and that the gateway is reachable from LINE.
|
||||||
|
- **Media download errors:** raise `channels.line.mediaMaxMb` if media exceeds the
|
||||||
|
default limit.
|
||||||
@ -215,6 +215,7 @@ Provider options:
|
|||||||
- `channels.matrix.initialSyncLimit`: initial sync limit.
|
- `channels.matrix.initialSyncLimit`: initial sync limit.
|
||||||
- `channels.matrix.threadReplies`: `off | inbound | always` (default: inbound).
|
- `channels.matrix.threadReplies`: `off | inbound | always` (default: inbound).
|
||||||
- `channels.matrix.textChunkLimit`: outbound text chunk size (chars).
|
- `channels.matrix.textChunkLimit`: outbound text chunk size (chars).
|
||||||
|
- `channels.matrix.chunkMode`: `length` (default) or `newline` to split on blank lines (paragraph boundaries) before length chunking.
|
||||||
- `channels.matrix.dm.policy`: `pairing | allowlist | open | disabled` (default: pairing).
|
- `channels.matrix.dm.policy`: `pairing | allowlist | open | disabled` (default: pairing).
|
||||||
- `channels.matrix.dm.allowFrom`: DM allowlist (user IDs or display names). `open` requires `"*"`. The wizard resolves names to IDs when possible.
|
- `channels.matrix.dm.allowFrom`: DM allowlist (user IDs or display names). `open` requires `"*"`. The wizard resolves names to IDs when possible.
|
||||||
- `channels.matrix.groupPolicy`: `allowlist | open | disabled` (default: allowlist).
|
- `channels.matrix.groupPolicy`: `allowlist | open | disabled` (default: allowlist).
|
||||||
|
|||||||
@ -415,6 +415,7 @@ Key settings (see `/gateway/configuration` for shared channel patterns):
|
|||||||
- `channels.msteams.dmPolicy`: `pairing | allowlist | open | disabled` (default: pairing)
|
- `channels.msteams.dmPolicy`: `pairing | allowlist | open | disabled` (default: pairing)
|
||||||
- `channels.msteams.allowFrom`: allowlist for DMs (AAD object IDs, UPNs, or display names). The wizard resolves names to IDs during setup when Graph access is available.
|
- `channels.msteams.allowFrom`: allowlist for DMs (AAD object IDs, UPNs, or display names). The wizard resolves names to IDs during setup when Graph access is available.
|
||||||
- `channels.msteams.textChunkLimit`: outbound text chunk size.
|
- `channels.msteams.textChunkLimit`: outbound text chunk size.
|
||||||
|
- `channels.msteams.chunkMode`: `length` (default) or `newline` to split on blank lines (paragraph boundaries) before length chunking.
|
||||||
- `channels.msteams.mediaAllowHosts`: allowlist for inbound attachment hosts (defaults to Microsoft/Teams domains).
|
- `channels.msteams.mediaAllowHosts`: allowlist for inbound attachment hosts (defaults to Microsoft/Teams domains).
|
||||||
- `channels.msteams.requireMention`: require @mention in channels/groups (default true).
|
- `channels.msteams.requireMention`: require @mention in channels/groups (default true).
|
||||||
- `channels.msteams.replyStyle`: `thread | top-level` (see [Reply Style](#reply-style-threads-vs-posts)).
|
- `channels.msteams.replyStyle`: `thread | top-level` (see [Reply Style](#reply-style-threads-vs-posts)).
|
||||||
|
|||||||
@ -114,6 +114,7 @@ Provider options:
|
|||||||
- `channels.nextcloud-talk.dmHistoryLimit`: DM history limit (0 disables).
|
- `channels.nextcloud-talk.dmHistoryLimit`: DM history limit (0 disables).
|
||||||
- `channels.nextcloud-talk.dms`: per-DM overrides (historyLimit).
|
- `channels.nextcloud-talk.dms`: per-DM overrides (historyLimit).
|
||||||
- `channels.nextcloud-talk.textChunkLimit`: outbound text chunk size (chars).
|
- `channels.nextcloud-talk.textChunkLimit`: outbound text chunk size (chars).
|
||||||
|
- `channels.nextcloud-talk.chunkMode`: `length` (default) or `newline` to split on blank lines (paragraph boundaries) before length chunking.
|
||||||
- `channels.nextcloud-talk.blockStreaming`: disable block streaming for this channel.
|
- `channels.nextcloud-talk.blockStreaming`: disable block streaming for this channel.
|
||||||
- `channels.nextcloud-talk.blockStreamingCoalesce`: block streaming coalesce tuning.
|
- `channels.nextcloud-talk.blockStreamingCoalesce`: block streaming coalesce tuning.
|
||||||
- `channels.nextcloud-talk.mediaMaxMb`: inbound media cap (MB).
|
- `channels.nextcloud-talk.mediaMaxMb`: inbound media cap (MB).
|
||||||
|
|||||||
@ -74,6 +74,22 @@ Example:
|
|||||||
|
|
||||||
Multi-account support: use `channels.signal.accounts` with per-account config and optional `name`. See [`gateway/configuration`](/gateway/configuration#telegramaccounts--discordaccounts--slackaccounts--signalaccounts--imessageaccounts) for the shared pattern.
|
Multi-account support: use `channels.signal.accounts` with per-account config and optional `name`. See [`gateway/configuration`](/gateway/configuration#telegramaccounts--discordaccounts--slackaccounts--signalaccounts--imessageaccounts) for the shared pattern.
|
||||||
|
|
||||||
|
## External daemon mode (httpUrl)
|
||||||
|
If you want to manage `signal-cli` yourself (slow JVM cold starts, container init, or shared CPUs), run the daemon separately and point Clawdbot at it:
|
||||||
|
|
||||||
|
```json5
|
||||||
|
{
|
||||||
|
channels: {
|
||||||
|
signal: {
|
||||||
|
httpUrl: "http://127.0.0.1:8080",
|
||||||
|
autoStart: false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
This skips auto-spawn and the startup wait inside Clawdbot. For slow starts when auto-spawning, set `channels.signal.startupTimeoutMs`.
|
||||||
|
|
||||||
## Access control (DMs + groups)
|
## Access control (DMs + groups)
|
||||||
DMs:
|
DMs:
|
||||||
- Default: `channels.signal.dmPolicy = "pairing"`.
|
- Default: `channels.signal.dmPolicy = "pairing"`.
|
||||||
@ -95,6 +111,7 @@ Groups:
|
|||||||
|
|
||||||
## Media + limits
|
## Media + limits
|
||||||
- Outbound text is chunked to `channels.signal.textChunkLimit` (default 4000).
|
- Outbound text is chunked to `channels.signal.textChunkLimit` (default 4000).
|
||||||
|
- Optional newline chunking: set `channels.signal.chunkMode="newline"` to split on blank lines (paragraph boundaries) before length chunking.
|
||||||
- Attachments supported (base64 fetched from `signal-cli`).
|
- Attachments supported (base64 fetched from `signal-cli`).
|
||||||
- Default media cap: `channels.signal.mediaMaxMb` (default 8).
|
- Default media cap: `channels.signal.mediaMaxMb` (default 8).
|
||||||
- Use `channels.signal.ignoreAttachments` to skip downloading media.
|
- Use `channels.signal.ignoreAttachments` to skip downloading media.
|
||||||
@ -105,8 +122,29 @@ Groups:
|
|||||||
- **Read receipts**: when `channels.signal.sendReadReceipts` is true, Clawdbot forwards read receipts for allowed DMs.
|
- **Read receipts**: when `channels.signal.sendReadReceipts` is true, Clawdbot forwards read receipts for allowed DMs.
|
||||||
- Signal-cli does not expose read receipts for groups.
|
- Signal-cli does not expose read receipts for groups.
|
||||||
|
|
||||||
|
## Reactions (message tool)
|
||||||
|
- Use `message action=react` with `channel=signal`.
|
||||||
|
- Targets: sender E.164 or UUID (use `uuid:<id>` from pairing output; bare UUID works too).
|
||||||
|
- `messageId` is the Signal timestamp for the message you’re reacting to.
|
||||||
|
- Group reactions require `targetAuthor` or `targetAuthorUuid`.
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
```
|
||||||
|
message action=react channel=signal target=uuid:123e4567-e89b-12d3-a456-426614174000 messageId=1737630212345 emoji=🔥
|
||||||
|
message action=react channel=signal target=+15551234567 messageId=1737630212345 emoji=🔥 remove=true
|
||||||
|
message action=react channel=signal target=signal:group:<groupId> targetAuthor=uuid:<sender-uuid> messageId=1737630212345 emoji=✅
|
||||||
|
```
|
||||||
|
|
||||||
|
Config:
|
||||||
|
- `channels.signal.actions.reactions`: enable/disable reaction actions (default true).
|
||||||
|
- `channels.signal.reactionLevel`: `off | ack | minimal | extensive`.
|
||||||
|
- `off`/`ack` disables agent reactions (message tool `react` will error).
|
||||||
|
- `minimal`/`extensive` enables agent reactions and sets the guidance level.
|
||||||
|
- Per-account overrides: `channels.signal.accounts.<id>.actions.reactions`, `channels.signal.accounts.<id>.reactionLevel`.
|
||||||
|
|
||||||
## Delivery targets (CLI/cron)
|
## Delivery targets (CLI/cron)
|
||||||
- DMs: `signal:+15551234567` (or plain E.164).
|
- DMs: `signal:+15551234567` (or plain E.164).
|
||||||
|
- UUID DMs: `uuid:<id>` (or bare UUID).
|
||||||
- Groups: `signal:group:<groupId>`.
|
- Groups: `signal:group:<groupId>`.
|
||||||
- Usernames: `username:<name>` (if supported by your Signal account).
|
- Usernames: `username:<name>` (if supported by your Signal account).
|
||||||
|
|
||||||
@ -120,6 +158,7 @@ Provider options:
|
|||||||
- `channels.signal.httpUrl`: full daemon URL (overrides host/port).
|
- `channels.signal.httpUrl`: full daemon URL (overrides host/port).
|
||||||
- `channels.signal.httpHost`, `channels.signal.httpPort`: daemon bind (default 127.0.0.1:8080).
|
- `channels.signal.httpHost`, `channels.signal.httpPort`: daemon bind (default 127.0.0.1:8080).
|
||||||
- `channels.signal.autoStart`: auto-spawn daemon (default true if `httpUrl` unset).
|
- `channels.signal.autoStart`: auto-spawn daemon (default true if `httpUrl` unset).
|
||||||
|
- `channels.signal.startupTimeoutMs`: startup wait timeout in ms (cap 120000).
|
||||||
- `channels.signal.receiveMode`: `on-start | manual`.
|
- `channels.signal.receiveMode`: `on-start | manual`.
|
||||||
- `channels.signal.ignoreAttachments`: skip attachment downloads.
|
- `channels.signal.ignoreAttachments`: skip attachment downloads.
|
||||||
- `channels.signal.ignoreStories`: ignore stories from the daemon.
|
- `channels.signal.ignoreStories`: ignore stories from the daemon.
|
||||||
@ -131,6 +170,7 @@ Provider options:
|
|||||||
- `channels.signal.historyLimit`: max group messages to include as context (0 disables).
|
- `channels.signal.historyLimit`: max group messages to include as context (0 disables).
|
||||||
- `channels.signal.dmHistoryLimit`: DM history limit in user turns. Per-user overrides: `channels.signal.dms["<phone_or_uuid>"].historyLimit`.
|
- `channels.signal.dmHistoryLimit`: DM history limit in user turns. Per-user overrides: `channels.signal.dms["<phone_or_uuid>"].historyLimit`.
|
||||||
- `channels.signal.textChunkLimit`: outbound chunk size (chars).
|
- `channels.signal.textChunkLimit`: outbound chunk size (chars).
|
||||||
|
- `channels.signal.chunkMode`: `length` (default) or `newline` to split on blank lines (paragraph boundaries) before length chunking.
|
||||||
- `channels.signal.mediaMaxMb`: inbound/outbound media cap (MB).
|
- `channels.signal.mediaMaxMb`: inbound/outbound media cap (MB).
|
||||||
|
|
||||||
Related global options:
|
Related global options:
|
||||||
|
|||||||
@ -26,7 +26,7 @@ Minimal config:
|
|||||||
```
|
```
|
||||||
|
|
||||||
### Setup
|
### Setup
|
||||||
1) Create a Slack app (From scratch) in https://api.channels.slack.com/apps.
|
1) Create a Slack app (From scratch) in https://api.slack.com/apps.
|
||||||
2) **Socket Mode** → toggle on. Then go to **Basic Information** → **App-Level Tokens** → **Generate Token and Scopes** with scope `connections:write`. Copy the **App Token** (`xapp-...`).
|
2) **Socket Mode** → toggle on. Then go to **Basic Information** → **App-Level Tokens** → **Generate Token and Scopes** with scope `connections:write`. Copy the **App Token** (`xapp-...`).
|
||||||
3) **OAuth & Permissions** → add bot token scopes (use the manifest below). Click **Install to Workspace**. Copy the **Bot User OAuth Token** (`xoxb-...`).
|
3) **OAuth & Permissions** → add bot token scopes (use the manifest below). Click **Install to Workspace**. Copy the **Bot User OAuth Token** (`xoxb-...`).
|
||||||
4) Optional: **OAuth & Permissions** → add **User Token Scopes** (see the read-only list below). Reinstall the app and copy the **User OAuth Token** (`xoxp-...`).
|
4) Optional: **OAuth & Permissions** → add **User Token Scopes** (see the read-only list below). Reinstall the app and copy the **User OAuth Token** (`xoxp-...`).
|
||||||
@ -245,29 +245,29 @@ If you enable native commands, add one `slash_commands` entry per command you wa
|
|||||||
## Scopes (current vs optional)
|
## Scopes (current vs optional)
|
||||||
Slack's Conversations API is type-scoped: you only need the scopes for the
|
Slack's Conversations API is type-scoped: you only need the scopes for the
|
||||||
conversation types you actually touch (channels, groups, im, mpim). See
|
conversation types you actually touch (channels, groups, im, mpim). See
|
||||||
https://api.channels.slack.com/docs/conversations-api for the overview.
|
https://docs.slack.dev/apis/web-api/using-the-conversations-api/ for the overview.
|
||||||
|
|
||||||
### Bot token scopes (required)
|
### Bot token scopes (required)
|
||||||
- `chat:write` (send/update/delete messages via `chat.postMessage`)
|
- `chat:write` (send/update/delete messages via `chat.postMessage`)
|
||||||
https://api.channels.slack.com/methods/chat.postMessage
|
https://docs.slack.dev/reference/methods/chat.postMessage
|
||||||
- `im:write` (open DMs via `conversations.open` for user DMs)
|
- `im:write` (open DMs via `conversations.open` for user DMs)
|
||||||
https://api.channels.slack.com/methods/conversations.open
|
https://docs.slack.dev/reference/methods/conversations.open
|
||||||
- `channels:history`, `groups:history`, `im:history`, `mpim:history`
|
- `channels:history`, `groups:history`, `im:history`, `mpim:history`
|
||||||
https://api.channels.slack.com/methods/conversations.history
|
https://docs.slack.dev/reference/methods/conversations.history
|
||||||
- `channels:read`, `groups:read`, `im:read`, `mpim:read`
|
- `channels:read`, `groups:read`, `im:read`, `mpim:read`
|
||||||
https://api.channels.slack.com/methods/conversations.info
|
https://docs.slack.dev/reference/methods/conversations.info
|
||||||
- `users:read` (user lookup)
|
- `users:read` (user lookup)
|
||||||
https://api.channels.slack.com/methods/users.info
|
https://docs.slack.dev/reference/methods/users.info
|
||||||
- `reactions:read`, `reactions:write` (`reactions.get` / `reactions.add`)
|
- `reactions:read`, `reactions:write` (`reactions.get` / `reactions.add`)
|
||||||
https://api.channels.slack.com/methods/reactions.get
|
https://docs.slack.dev/reference/methods/reactions.get
|
||||||
https://api.channels.slack.com/methods/reactions.add
|
https://docs.slack.dev/reference/methods/reactions.add
|
||||||
- `pins:read`, `pins:write` (`pins.list` / `pins.add` / `pins.remove`)
|
- `pins:read`, `pins:write` (`pins.list` / `pins.add` / `pins.remove`)
|
||||||
https://api.channels.slack.com/scopes/pins:read
|
https://docs.slack.dev/reference/scopes/pins.read
|
||||||
https://api.channels.slack.com/scopes/pins:write
|
https://docs.slack.dev/reference/scopes/pins.write
|
||||||
- `emoji:read` (`emoji.list`)
|
- `emoji:read` (`emoji.list`)
|
||||||
https://api.channels.slack.com/scopes/emoji:read
|
https://docs.slack.dev/reference/scopes/emoji.read
|
||||||
- `files:write` (uploads via `files.uploadV2`)
|
- `files:write` (uploads via `files.uploadV2`)
|
||||||
https://api.channels.slack.com/messaging/files/uploading
|
https://docs.slack.dev/messaging/working-with-files/#upload
|
||||||
|
|
||||||
### User token scopes (optional, read-only by default)
|
### User token scopes (optional, read-only by default)
|
||||||
Add these under **User Token Scopes** if you configure `channels.slack.userToken`.
|
Add these under **User Token Scopes** if you configure `channels.slack.userToken`.
|
||||||
@ -284,9 +284,9 @@ Add these under **User Token Scopes** if you configure `channels.slack.userToken
|
|||||||
- `mpim:write` (only if we add group-DM open/DM start via `conversations.open`)
|
- `mpim:write` (only if we add group-DM open/DM start via `conversations.open`)
|
||||||
- `groups:write` (only if we add private-channel management: create/rename/invite/archive)
|
- `groups:write` (only if we add private-channel management: create/rename/invite/archive)
|
||||||
- `chat:write.public` (only if we want to post to channels the bot isn't in)
|
- `chat:write.public` (only if we want to post to channels the bot isn't in)
|
||||||
https://api.channels.slack.com/scopes/chat:write.public
|
https://docs.slack.dev/reference/scopes/chat.write.public
|
||||||
- `users:read.email` (only if we need email fields from `users.info`)
|
- `users:read.email` (only if we need email fields from `users.info`)
|
||||||
https://api.channels.slack.com/changelog/2017-04-narrowing-email-access
|
https://docs.slack.dev/changelog/2017-04-narrowing-email-access
|
||||||
- `files:read` (only if we start listing/reading file metadata)
|
- `files:read` (only if we start listing/reading file metadata)
|
||||||
|
|
||||||
## Config
|
## Config
|
||||||
@ -349,6 +349,7 @@ ack reaction after the bot replies.
|
|||||||
|
|
||||||
## Limits
|
## Limits
|
||||||
- Outbound text is chunked to `channels.slack.textChunkLimit` (default 4000).
|
- Outbound text is chunked to `channels.slack.textChunkLimit` (default 4000).
|
||||||
|
- Optional newline chunking: set `channels.slack.chunkMode="newline"` to split on blank lines (paragraph boundaries) before length chunking.
|
||||||
- Media uploads are capped by `channels.slack.mediaMaxMb` (default 20).
|
- Media uploads are capped by `channels.slack.mediaMaxMb` (default 20).
|
||||||
|
|
||||||
## Reply threading
|
## Reply threading
|
||||||
|
|||||||
@ -120,6 +120,13 @@ You can add custom commands to the menu via config:
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
- `setMyCommands failed` in logs usually means outbound HTTPS/DNS is blocked to `api.telegram.org`.
|
||||||
|
- If you see `sendMessage` or `sendChatAction` failures, check IPv6 routing and DNS.
|
||||||
|
|
||||||
|
More help: [Channel troubleshooting](/channels/troubleshooting).
|
||||||
|
|
||||||
Notes:
|
Notes:
|
||||||
- Custom commands are **menu entries only**; Clawdbot does not implement them unless you handle them elsewhere.
|
- Custom commands are **menu entries only**; Clawdbot does not implement them unless you handle them elsewhere.
|
||||||
- Command names are normalized (leading `/` stripped, lowercased) and must match `a-z`, `0-9`, `_` (1–32 chars).
|
- Command names are normalized (leading `/` stripped, lowercased) and must match `a-z`, `0-9`, `_` (1–32 chars).
|
||||||
@ -128,6 +135,7 @@ Notes:
|
|||||||
|
|
||||||
## Limits
|
## Limits
|
||||||
- Outbound text is chunked to `channels.telegram.textChunkLimit` (default 4000).
|
- Outbound text is chunked to `channels.telegram.textChunkLimit` (default 4000).
|
||||||
|
- Optional newline chunking: set `channels.telegram.chunkMode="newline"` to split on blank lines (paragraph boundaries) before length chunking.
|
||||||
- Media downloads/uploads are capped by `channels.telegram.mediaMaxMb` (default 5).
|
- Media downloads/uploads are capped by `channels.telegram.mediaMaxMb` (default 5).
|
||||||
- Telegram Bot API requests time out after `channels.telegram.timeoutSeconds` (default 500 via grammY). Set lower to avoid long hangs.
|
- Telegram Bot API requests time out after `channels.telegram.timeoutSeconds` (default 500 via grammY). Set lower to avoid long hangs.
|
||||||
- Group history context uses `channels.telegram.historyLimit` (or `channels.telegram.accounts.*.historyLimit`), falling back to `messages.groupChat.historyLimit`. Set `0` to disable (default 50).
|
- Group history context uses `channels.telegram.historyLimit` (or `channels.telegram.accounts.*.historyLimit`), falling back to `messages.groupChat.historyLimit`. Set `0` to disable (default 50).
|
||||||
@ -516,6 +524,8 @@ Provider options:
|
|||||||
- `channels.telegram.accounts.<account>.capabilities.inlineButtons`: per-account override.
|
- `channels.telegram.accounts.<account>.capabilities.inlineButtons`: per-account override.
|
||||||
- `channels.telegram.replyToMode`: `off | first | all` (default: `first`).
|
- `channels.telegram.replyToMode`: `off | first | all` (default: `first`).
|
||||||
- `channels.telegram.textChunkLimit`: outbound chunk size (chars).
|
- `channels.telegram.textChunkLimit`: outbound chunk size (chars).
|
||||||
|
- `channels.telegram.chunkMode`: `length` (default) or `newline` to split on blank lines (paragraph boundaries) before length chunking.
|
||||||
|
- `channels.telegram.linkPreview`: toggle link previews for outbound messages (default: true).
|
||||||
- `channels.telegram.streamMode`: `off | partial | block` (draft streaming).
|
- `channels.telegram.streamMode`: `off | partial | block` (draft streaming).
|
||||||
- `channels.telegram.mediaMaxMb`: inbound/outbound media cap (MB).
|
- `channels.telegram.mediaMaxMb`: inbound/outbound media cap (MB).
|
||||||
- `channels.telegram.retry`: retry policy for outbound Telegram API calls (attempts, minDelayMs, maxDelayMs, jitter).
|
- `channels.telegram.retry`: retry policy for outbound Telegram API calls (attempts, minDelayMs, maxDelayMs, jitter).
|
||||||
|
|||||||
133
docs/channels/tlon.md
Normal file
133
docs/channels/tlon.md
Normal file
@ -0,0 +1,133 @@
|
|||||||
|
---
|
||||||
|
summary: "Tlon/Urbit support status, capabilities, and configuration"
|
||||||
|
read_when:
|
||||||
|
- Working on Tlon/Urbit channel features
|
||||||
|
---
|
||||||
|
# Tlon (plugin)
|
||||||
|
|
||||||
|
Tlon is a decentralized messenger built on Urbit. Clawdbot connects to your Urbit ship and can
|
||||||
|
respond to DMs and group chat messages. Group replies require an @ mention by default and can
|
||||||
|
be further restricted via allowlists.
|
||||||
|
|
||||||
|
Status: supported via plugin. DMs, group mentions, thread replies, and text-only media fallback
|
||||||
|
(URL appended to caption). Reactions, polls, and native media uploads are not supported.
|
||||||
|
|
||||||
|
## Plugin required
|
||||||
|
|
||||||
|
Tlon ships as a plugin and is not bundled with the core install.
|
||||||
|
|
||||||
|
Install via CLI (npm registry):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
clawdbot plugins install @clawdbot/tlon
|
||||||
|
```
|
||||||
|
|
||||||
|
Local checkout (when running from a git repo):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
clawdbot plugins install ./extensions/tlon
|
||||||
|
```
|
||||||
|
|
||||||
|
Details: [Plugins](/plugin)
|
||||||
|
|
||||||
|
## Setup
|
||||||
|
|
||||||
|
1) Install the Tlon plugin.
|
||||||
|
2) Gather your ship URL and login code.
|
||||||
|
3) Configure `channels.tlon`.
|
||||||
|
4) Restart the gateway.
|
||||||
|
5) DM the bot or mention it in a group channel.
|
||||||
|
|
||||||
|
Minimal config (single account):
|
||||||
|
|
||||||
|
```json5
|
||||||
|
{
|
||||||
|
channels: {
|
||||||
|
tlon: {
|
||||||
|
enabled: true,
|
||||||
|
ship: "~sampel-palnet",
|
||||||
|
url: "https://your-ship-host",
|
||||||
|
code: "lidlut-tabwed-pillex-ridrup"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Group channels
|
||||||
|
|
||||||
|
Auto-discovery is enabled by default. You can also pin channels manually:
|
||||||
|
|
||||||
|
```json5
|
||||||
|
{
|
||||||
|
channels: {
|
||||||
|
tlon: {
|
||||||
|
groupChannels: [
|
||||||
|
"chat/~host-ship/general",
|
||||||
|
"chat/~host-ship/support"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Disable auto-discovery:
|
||||||
|
|
||||||
|
```json5
|
||||||
|
{
|
||||||
|
channels: {
|
||||||
|
tlon: {
|
||||||
|
autoDiscoverChannels: false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Access control
|
||||||
|
|
||||||
|
DM allowlist (empty = allow all):
|
||||||
|
|
||||||
|
```json5
|
||||||
|
{
|
||||||
|
channels: {
|
||||||
|
tlon: {
|
||||||
|
dmAllowlist: ["~zod", "~nec"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Group authorization (restricted by default):
|
||||||
|
|
||||||
|
```json5
|
||||||
|
{
|
||||||
|
channels: {
|
||||||
|
tlon: {
|
||||||
|
defaultAuthorizedShips: ["~zod"],
|
||||||
|
authorization: {
|
||||||
|
channelRules: {
|
||||||
|
"chat/~host-ship/general": {
|
||||||
|
mode: "restricted",
|
||||||
|
allowedShips: ["~zod", "~nec"]
|
||||||
|
},
|
||||||
|
"chat/~host-ship/announcements": {
|
||||||
|
mode: "open"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Delivery targets (CLI/cron)
|
||||||
|
|
||||||
|
Use these with `clawdbot message send` or cron delivery:
|
||||||
|
|
||||||
|
- DM: `~sampel-palnet` or `dm/~sampel-palnet`
|
||||||
|
- Group: `chat/~host-ship/channel` or `group:~host-ship/channel`
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- Group replies require a mention (e.g. `~your-bot-ship`) to respond.
|
||||||
|
- Thread replies: if the inbound message is in a thread, Clawdbot replies in-thread.
|
||||||
|
- Media: `sendMedia` falls back to text + URL (no native upload).
|
||||||
@ -22,3 +22,4 @@ clawdbot channels status --probe
|
|||||||
|
|
||||||
## Telegram quick fixes
|
## Telegram quick fixes
|
||||||
- Logs show `HttpError: Network request for 'sendMessage' failed` or `sendChatAction` → check IPv6 DNS. If `api.telegram.org` resolves to IPv6 first and the host lacks IPv6 egress, force IPv4 or enable IPv6. See [/channels/telegram#troubleshooting](/channels/telegram#troubleshooting).
|
- Logs show `HttpError: Network request for 'sendMessage' failed` or `sendChatAction` → check IPv6 DNS. If `api.telegram.org` resolves to IPv6 first and the host lacks IPv6 egress, force IPv4 or enable IPv6. See [/channels/telegram#troubleshooting](/channels/telegram#troubleshooting).
|
||||||
|
- Logs show `setMyCommands failed` → check outbound HTTPS and DNS reachability to `api.telegram.org` (common on locked-down VPS or proxies).
|
||||||
|
|||||||
366
docs/channels/twitch.md
Normal file
366
docs/channels/twitch.md
Normal file
@ -0,0 +1,366 @@
|
|||||||
|
---
|
||||||
|
summary: "Twitch chat bot configuration and setup"
|
||||||
|
read_when:
|
||||||
|
- Setting up Twitch chat integration for Clawdbot
|
||||||
|
---
|
||||||
|
# Twitch (plugin)
|
||||||
|
|
||||||
|
Twitch chat support via IRC connection. Clawdbot connects as a Twitch user (bot account) to receive and send messages in channels.
|
||||||
|
|
||||||
|
## Plugin required
|
||||||
|
|
||||||
|
Twitch ships as a plugin and is not bundled with the core install.
|
||||||
|
|
||||||
|
Install via CLI (npm registry):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
clawdbot plugins install @clawdbot/twitch
|
||||||
|
```
|
||||||
|
|
||||||
|
Local checkout (when running from a git repo):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
clawdbot plugins install ./extensions/twitch
|
||||||
|
```
|
||||||
|
|
||||||
|
Details: [Plugins](/plugin)
|
||||||
|
|
||||||
|
## Quick setup (beginner)
|
||||||
|
|
||||||
|
1) Create a dedicated Twitch account for the bot (or use an existing account).
|
||||||
|
2) Generate credentials: [Twitch Token Generator](https://twitchtokengenerator.com/)
|
||||||
|
- Select **Bot Token**
|
||||||
|
- Verify scopes `chat:read` and `chat:write` are selected
|
||||||
|
- Copy the **Client ID** and **Access Token**
|
||||||
|
3) Find your Twitch user ID: https://www.streamweasels.com/tools/convert-twitch-username-to-user-id/
|
||||||
|
4) Configure the token:
|
||||||
|
- Env: `CLAWDBOT_TWITCH_ACCESS_TOKEN=...` (default account only)
|
||||||
|
- Or config: `channels.twitch.accessToken`
|
||||||
|
- If both are set, config takes precedence (env fallback is default-account only).
|
||||||
|
5) Start the gateway.
|
||||||
|
|
||||||
|
**⚠️ Important:** Add access control (`allowFrom` or `allowedRoles`) to prevent unauthorized users from triggering the bot. `requireMention` defaults to `true`.
|
||||||
|
|
||||||
|
Minimal config:
|
||||||
|
|
||||||
|
```json5
|
||||||
|
{
|
||||||
|
channels: {
|
||||||
|
twitch: {
|
||||||
|
enabled: true,
|
||||||
|
username: "clawdbot", // Bot's Twitch account
|
||||||
|
accessToken: "oauth:abc123...", // OAuth Access Token (or use CLAWDBOT_TWITCH_ACCESS_TOKEN env var)
|
||||||
|
clientId: "xyz789...", // Client ID from Token Generator
|
||||||
|
channel: "vevisk", // Which Twitch channel's chat to join (required)
|
||||||
|
allowFrom: ["123456789"] // (recommended) Your Twitch user ID only - get it from https://www.streamweasels.com/tools/convert-twitch-username-to-user-id/
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## What it is
|
||||||
|
|
||||||
|
- A Twitch channel owned by the Gateway.
|
||||||
|
- Deterministic routing: replies always go back to Twitch.
|
||||||
|
- Each account maps to an isolated session key `agent:<agentId>:twitch:<accountName>`.
|
||||||
|
- `username` is the bot's account (who authenticates), `channel` is which chat room to join.
|
||||||
|
|
||||||
|
## Setup (detailed)
|
||||||
|
|
||||||
|
### Generate credentials
|
||||||
|
|
||||||
|
Use [Twitch Token Generator](https://twitchtokengenerator.com/):
|
||||||
|
- Select **Bot Token**
|
||||||
|
- Verify scopes `chat:read` and `chat:write` are selected
|
||||||
|
- Copy the **Client ID** and **Access Token**
|
||||||
|
|
||||||
|
No manual app registration needed. Tokens expire after several hours.
|
||||||
|
|
||||||
|
### Configure the bot
|
||||||
|
|
||||||
|
**Env var (default account only):**
|
||||||
|
```bash
|
||||||
|
CLAWDBOT_TWITCH_ACCESS_TOKEN=oauth:abc123...
|
||||||
|
```
|
||||||
|
|
||||||
|
**Or config:**
|
||||||
|
```json5
|
||||||
|
{
|
||||||
|
channels: {
|
||||||
|
twitch: {
|
||||||
|
enabled: true,
|
||||||
|
username: "clawdbot",
|
||||||
|
accessToken: "oauth:abc123...",
|
||||||
|
clientId: "xyz789...",
|
||||||
|
channel: "vevisk"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
If both env and config are set, config takes precedence.
|
||||||
|
|
||||||
|
### Access control (recommended)
|
||||||
|
|
||||||
|
```json5
|
||||||
|
{
|
||||||
|
channels: {
|
||||||
|
twitch: {
|
||||||
|
allowFrom: ["123456789"], // (recommended) Your Twitch user ID only
|
||||||
|
allowedRoles: ["moderator"] // Or restrict to roles
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Available roles:** `"moderator"`, `"owner"`, `"vip"`, `"subscriber"`, `"all"`.
|
||||||
|
|
||||||
|
**Why user IDs?** Usernames can change, allowing impersonation. User IDs are permanent.
|
||||||
|
|
||||||
|
Find your Twitch user ID: https://www.streamweasels.com/tools/convert-twitch-username-%20to-user-id/ (Convert your Twitch username to ID)
|
||||||
|
|
||||||
|
## Token refresh (optional)
|
||||||
|
|
||||||
|
Tokens from [Twitch Token Generator](https://twitchtokengenerator.com/) cannot be automatically refreshed - regenerate when expired.
|
||||||
|
|
||||||
|
For automatic token refresh, create your own Twitch application at [Twitch Developer Console](https://dev.twitch.tv/console) and add to config:
|
||||||
|
|
||||||
|
```json5
|
||||||
|
{
|
||||||
|
channels: {
|
||||||
|
twitch: {
|
||||||
|
clientSecret: "your_client_secret",
|
||||||
|
refreshToken: "your_refresh_token"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The bot automatically refreshes tokens before expiration and logs refresh events.
|
||||||
|
|
||||||
|
## Multi-account support
|
||||||
|
|
||||||
|
Use `channels.twitch.accounts` with per-account tokens. See [`gateway/configuration`](/gateway/configuration) for the shared pattern.
|
||||||
|
|
||||||
|
Example (one bot account in two channels):
|
||||||
|
|
||||||
|
```json5
|
||||||
|
{
|
||||||
|
channels: {
|
||||||
|
twitch: {
|
||||||
|
accounts: {
|
||||||
|
channel1: {
|
||||||
|
username: "clawdbot",
|
||||||
|
accessToken: "oauth:abc123...",
|
||||||
|
clientId: "xyz789...",
|
||||||
|
channel: "vevisk"
|
||||||
|
},
|
||||||
|
channel2: {
|
||||||
|
username: "clawdbot",
|
||||||
|
accessToken: "oauth:def456...",
|
||||||
|
clientId: "uvw012...",
|
||||||
|
channel: "secondchannel"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Note:** Each account needs its own token (one token per channel).
|
||||||
|
|
||||||
|
## Access control
|
||||||
|
|
||||||
|
### Role-based restrictions
|
||||||
|
|
||||||
|
```json5
|
||||||
|
{
|
||||||
|
channels: {
|
||||||
|
twitch: {
|
||||||
|
accounts: {
|
||||||
|
default: {
|
||||||
|
allowedRoles: ["moderator", "vip"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Allowlist by User ID (most secure)
|
||||||
|
|
||||||
|
```json5
|
||||||
|
{
|
||||||
|
channels: {
|
||||||
|
twitch: {
|
||||||
|
accounts: {
|
||||||
|
default: {
|
||||||
|
allowFrom: ["123456789", "987654321"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Combined allowlist + roles
|
||||||
|
|
||||||
|
Users in `allowFrom` bypass role checks:
|
||||||
|
|
||||||
|
```json5
|
||||||
|
{
|
||||||
|
channels: {
|
||||||
|
twitch: {
|
||||||
|
accounts: {
|
||||||
|
default: {
|
||||||
|
allowFrom: ["123456789"],
|
||||||
|
allowedRoles: ["moderator"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Disable @mention requirement
|
||||||
|
|
||||||
|
By default, `requireMention` is `true`. To disable and respond to all messages:
|
||||||
|
|
||||||
|
```json5
|
||||||
|
{
|
||||||
|
channels: {
|
||||||
|
twitch: {
|
||||||
|
accounts: {
|
||||||
|
default: {
|
||||||
|
requireMention: false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
First, run diagnostic commands:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
clawdbot doctor
|
||||||
|
clawdbot channels status --probe
|
||||||
|
```
|
||||||
|
|
||||||
|
### Bot doesn't respond to messages
|
||||||
|
|
||||||
|
**Check access control:** Temporarily set `allowedRoles: ["all"]` to test.
|
||||||
|
|
||||||
|
**Check the bot is in the channel:** The bot must join the channel specified in `channel`.
|
||||||
|
|
||||||
|
### Token issues
|
||||||
|
|
||||||
|
**"Failed to connect" or authentication errors:**
|
||||||
|
- Verify `accessToken` is the OAuth access token value (typically starts with `oauth:` prefix)
|
||||||
|
- Check token has `chat:read` and `chat:write` scopes
|
||||||
|
- If using token refresh, verify `clientSecret` and `refreshToken` are set
|
||||||
|
|
||||||
|
### Token refresh not working
|
||||||
|
|
||||||
|
**Check logs for refresh events:**
|
||||||
|
```
|
||||||
|
Using env token source for mybot
|
||||||
|
Access token refreshed for user 123456 (expires in 14400s)
|
||||||
|
```
|
||||||
|
|
||||||
|
If you see "token refresh disabled (no refresh token)":
|
||||||
|
- Ensure `clientSecret` is provided
|
||||||
|
- Ensure `refreshToken` is provided
|
||||||
|
|
||||||
|
## Config
|
||||||
|
|
||||||
|
**Account config:**
|
||||||
|
- `username` - Bot username
|
||||||
|
- `accessToken` - OAuth access token with `chat:read` and `chat:write`
|
||||||
|
- `clientId` - Twitch Client ID (from Token Generator or your app)
|
||||||
|
- `channel` - Channel to join (required)
|
||||||
|
- `enabled` - Enable this account (default: `true`)
|
||||||
|
- `clientSecret` - Optional: For automatic token refresh
|
||||||
|
- `refreshToken` - Optional: For automatic token refresh
|
||||||
|
- `expiresIn` - Token expiry in seconds
|
||||||
|
- `obtainmentTimestamp` - Token obtained timestamp
|
||||||
|
- `allowFrom` - User ID allowlist
|
||||||
|
- `allowedRoles` - Role-based access control (`"moderator" | "owner" | "vip" | "subscriber" | "all"`)
|
||||||
|
- `requireMention` - Require @mention (default: `true`)
|
||||||
|
|
||||||
|
**Provider options:**
|
||||||
|
- `channels.twitch.enabled` - Enable/disable channel startup
|
||||||
|
- `channels.twitch.username` - Bot username (simplified single-account config)
|
||||||
|
- `channels.twitch.accessToken` - OAuth access token (simplified single-account config)
|
||||||
|
- `channels.twitch.clientId` - Twitch Client ID (simplified single-account config)
|
||||||
|
- `channels.twitch.channel` - Channel to join (simplified single-account config)
|
||||||
|
- `channels.twitch.accounts.<accountName>` - Multi-account config (all account fields above)
|
||||||
|
|
||||||
|
Full example:
|
||||||
|
|
||||||
|
```json5
|
||||||
|
{
|
||||||
|
channels: {
|
||||||
|
twitch: {
|
||||||
|
enabled: true,
|
||||||
|
username: "clawdbot",
|
||||||
|
accessToken: "oauth:abc123...",
|
||||||
|
clientId: "xyz789...",
|
||||||
|
channel: "vevisk",
|
||||||
|
clientSecret: "secret123...",
|
||||||
|
refreshToken: "refresh456...",
|
||||||
|
allowFrom: ["123456789"],
|
||||||
|
allowedRoles: ["moderator", "vip"],
|
||||||
|
accounts: {
|
||||||
|
default: {
|
||||||
|
username: "mybot",
|
||||||
|
accessToken: "oauth:abc123...",
|
||||||
|
clientId: "xyz789...",
|
||||||
|
channel: "your_channel",
|
||||||
|
enabled: true,
|
||||||
|
clientSecret: "secret123...",
|
||||||
|
refreshToken: "refresh456...",
|
||||||
|
expiresIn: 14400,
|
||||||
|
obtainmentTimestamp: 1706092800000,
|
||||||
|
allowFrom: ["123456789", "987654321"],
|
||||||
|
allowedRoles: ["moderator"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Tool actions
|
||||||
|
|
||||||
|
The agent can call `twitch` with action:
|
||||||
|
- `send` - Send a message to a channel
|
||||||
|
|
||||||
|
Example:
|
||||||
|
|
||||||
|
```json5
|
||||||
|
{
|
||||||
|
"action": "twitch",
|
||||||
|
"params": {
|
||||||
|
"message": "Hello Twitch!",
|
||||||
|
"to": "#mychannel"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Safety & ops
|
||||||
|
|
||||||
|
- **Treat tokens like passwords** - Never commit tokens to git
|
||||||
|
- **Use automatic token refresh** for long-running bots
|
||||||
|
- **Use user ID allowlists** instead of usernames for access control
|
||||||
|
- **Monitor logs** for token refresh events and connection status
|
||||||
|
- **Scope tokens minimally** - Only request `chat:read` and `chat:write`
|
||||||
|
- **If stuck**: Restart the gateway after confirming no other process owns the session
|
||||||
|
|
||||||
|
## Limits
|
||||||
|
|
||||||
|
- **500 characters** per message (auto-chunked at word boundaries)
|
||||||
|
- Markdown is stripped before chunking
|
||||||
|
- No rate limiting (uses Twitch's built-in rate limits)
|
||||||
@ -271,12 +271,13 @@ WhatsApp can automatically send emoji reactions to incoming messages immediately
|
|||||||
|
|
||||||
## Limits
|
## Limits
|
||||||
- Outbound text is chunked to `channels.whatsapp.textChunkLimit` (default 4000).
|
- Outbound text is chunked to `channels.whatsapp.textChunkLimit` (default 4000).
|
||||||
|
- Optional newline chunking: set `channels.whatsapp.chunkMode="newline"` to split on blank lines (paragraph boundaries) before length chunking.
|
||||||
- Inbound media saves are capped by `channels.whatsapp.mediaMaxMb` (default 50 MB).
|
- Inbound media saves are capped by `channels.whatsapp.mediaMaxMb` (default 50 MB).
|
||||||
- Outbound media items are capped by `agents.defaults.mediaMaxMb` (default 5 MB).
|
- Outbound media items are capped by `agents.defaults.mediaMaxMb` (default 5 MB).
|
||||||
|
|
||||||
## Outbound send (text + media)
|
## Outbound send (text + media)
|
||||||
- Uses active web listener; error if gateway not running.
|
- Uses active web listener; error if gateway not running.
|
||||||
- Text chunking: 4k max per message (configurable via `channels.whatsapp.textChunkLimit`).
|
- Text chunking: 4k max per message (configurable via `channels.whatsapp.textChunkLimit`, optional `channels.whatsapp.chunkMode`).
|
||||||
- Media:
|
- Media:
|
||||||
- Image/video/audio/document supported.
|
- Image/video/audio/document supported.
|
||||||
- Audio sent as PTT; `audio/ogg` => `audio/ogg; codecs=opus`.
|
- Audio sent as PTT; `audio/ogg` => `audio/ogg; codecs=opus`.
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
---
|
---
|
||||||
summary: "CLI reference for `clawdbot channels` (accounts, status, login/logout, logs)"
|
summary: "CLI reference for `clawdbot channels` (accounts, status, login/logout, logs)"
|
||||||
read_when:
|
read_when:
|
||||||
- You want to add/remove channel accounts (WhatsApp/Telegram/Discord/Slack/Mattermost (plugin)/Signal/iMessage)
|
- You want to add/remove channel accounts (WhatsApp/Telegram/Discord/Google Chat/Slack/Mattermost (plugin)/Signal/iMessage)
|
||||||
- You want to check channel status or tail channel logs
|
- You want to check channel status or tail channel logs
|
||||||
---
|
---
|
||||||
|
|
||||||
@ -44,6 +44,7 @@ clawdbot channels logout --channel whatsapp
|
|||||||
|
|
||||||
- Run `clawdbot status --deep` for a broad probe.
|
- Run `clawdbot status --deep` for a broad probe.
|
||||||
- Use `clawdbot doctor` for guided fixes.
|
- Use `clawdbot doctor` for guided fixes.
|
||||||
|
- `clawdbot channels list` prints `Claude: HTTP 403 ... user:profile` → usage snapshot needs the `user:profile` scope. Use `--no-usage`, or provide a claude.ai session key (`CLAUDE_WEB_SESSION_KEY` / `CLAUDE_WEB_COOKIE`), or re-auth via Claude Code CLI.
|
||||||
|
|
||||||
## Capabilities probe
|
## Capabilities probe
|
||||||
|
|
||||||
|
|||||||
@ -29,6 +29,7 @@ This page describes the current CLI behavior. If commands change, update this do
|
|||||||
- [`sessions`](/cli/sessions)
|
- [`sessions`](/cli/sessions)
|
||||||
- [`gateway`](/cli/gateway)
|
- [`gateway`](/cli/gateway)
|
||||||
- [`logs`](/cli/logs)
|
- [`logs`](/cli/logs)
|
||||||
|
- [`system`](/cli/system)
|
||||||
- [`models`](/cli/models)
|
- [`models`](/cli/models)
|
||||||
- [`memory`](/cli/memory)
|
- [`memory`](/cli/memory)
|
||||||
- [`nodes`](/cli/nodes)
|
- [`nodes`](/cli/nodes)
|
||||||
@ -38,7 +39,6 @@ This page describes the current CLI behavior. If commands change, update this do
|
|||||||
- [`sandbox`](/cli/sandbox)
|
- [`sandbox`](/cli/sandbox)
|
||||||
- [`tui`](/cli/tui)
|
- [`tui`](/cli/tui)
|
||||||
- [`browser`](/cli/browser)
|
- [`browser`](/cli/browser)
|
||||||
- [`wake`](/cli/wake)
|
|
||||||
- [`cron`](/cli/cron)
|
- [`cron`](/cli/cron)
|
||||||
- [`dns`](/cli/dns)
|
- [`dns`](/cli/dns)
|
||||||
- [`docs`](/cli/docs)
|
- [`docs`](/cli/docs)
|
||||||
@ -145,6 +145,10 @@ clawdbot [--dev] [--profile <name>] <command>
|
|||||||
restart
|
restart
|
||||||
run
|
run
|
||||||
logs
|
logs
|
||||||
|
system
|
||||||
|
event
|
||||||
|
heartbeat last|enable|disable
|
||||||
|
presence
|
||||||
models
|
models
|
||||||
list
|
list
|
||||||
status
|
status
|
||||||
@ -160,7 +164,6 @@ clawdbot [--dev] [--profile <name>] <command>
|
|||||||
list
|
list
|
||||||
recreate
|
recreate
|
||||||
explain
|
explain
|
||||||
wake
|
|
||||||
cron
|
cron
|
||||||
status
|
status
|
||||||
list
|
list
|
||||||
@ -294,7 +297,7 @@ Options:
|
|||||||
- `--non-interactive`
|
- `--non-interactive`
|
||||||
- `--mode <local|remote>`
|
- `--mode <local|remote>`
|
||||||
- `--flow <quickstart|advanced|manual>` (manual is an alias for advanced)
|
- `--flow <quickstart|advanced|manual>` (manual is an alias for advanced)
|
||||||
- `--auth-choice <setup-token|claude-cli|token|openai-codex|openai-api-key|openrouter-api-key|ai-gateway-api-key|moonshot-api-key|kimi-code-api-key|codex-cli|gemini-api-key|zai-api-key|apiKey|minimax-api|opencode-zen|skip>`
|
- `--auth-choice <setup-token|token|chutes|openai-codex|openai-api-key|openrouter-api-key|ai-gateway-api-key|moonshot-api-key|kimi-code-api-key|synthetic-api-key|venice-api-key|gemini-api-key|zai-api-key|apiKey|minimax-api|minimax-api-lightning|opencode-zen|skip>`
|
||||||
- `--token-provider <id>` (non-interactive; used with `--auth-choice token`)
|
- `--token-provider <id>` (non-interactive; used with `--auth-choice token`)
|
||||||
- `--token <token>` (non-interactive; used with `--auth-choice token`)
|
- `--token <token>` (non-interactive; used with `--auth-choice token`)
|
||||||
- `--token-profile-id <id>` (non-interactive; default: `<provider>:manual`)
|
- `--token-profile-id <id>` (non-interactive; default: `<provider>:manual`)
|
||||||
@ -311,7 +314,7 @@ Options:
|
|||||||
- `--opencode-zen-api-key <key>`
|
- `--opencode-zen-api-key <key>`
|
||||||
- `--gateway-port <port>`
|
- `--gateway-port <port>`
|
||||||
- `--gateway-bind <loopback|lan|tailnet|auto|custom>`
|
- `--gateway-bind <loopback|lan|tailnet|auto|custom>`
|
||||||
- `--gateway-auth <off|token|password>`
|
- `--gateway-auth <token|password>`
|
||||||
- `--gateway-token <token>`
|
- `--gateway-token <token>`
|
||||||
- `--gateway-password <password>`
|
- `--gateway-password <password>`
|
||||||
- `--remote-url <url>`
|
- `--remote-url <url>`
|
||||||
@ -352,10 +355,10 @@ Options:
|
|||||||
## Channel helpers
|
## Channel helpers
|
||||||
|
|
||||||
### `channels`
|
### `channels`
|
||||||
Manage chat channel accounts (WhatsApp/Telegram/Discord/Slack/Mattermost (plugin)/Signal/iMessage/MS Teams).
|
Manage chat channel accounts (WhatsApp/Telegram/Discord/Google Chat/Slack/Mattermost (plugin)/Signal/iMessage/MS Teams).
|
||||||
|
|
||||||
Subcommands:
|
Subcommands:
|
||||||
- `channels list`: show configured channels and auth profiles (Claude Code + Codex CLI OAuth sync included).
|
- `channels list`: show configured channels and auth profiles.
|
||||||
- `channels status`: check gateway reachability and channel health (`--probe` runs extra checks; use `clawdbot health` or `clawdbot status --deep` for gateway health probes).
|
- `channels status`: check gateway reachability and channel health (`--probe` runs extra checks; use `clawdbot health` or `clawdbot status --deep` for gateway health probes).
|
||||||
- Tip: `channels status` prints warnings with suggested fixes when it can detect common misconfigurations (then points you to `clawdbot doctor`).
|
- Tip: `channels status` prints warnings with suggested fixes when it can detect common misconfigurations (then points you to `clawdbot doctor`).
|
||||||
- `channels logs`: show recent channel logs from the gateway log file.
|
- `channels logs`: show recent channel logs from the gateway log file.
|
||||||
@ -365,7 +368,7 @@ Subcommands:
|
|||||||
- `channels logout`: log out of a channel session (if supported).
|
- `channels logout`: log out of a channel session (if supported).
|
||||||
|
|
||||||
Common options:
|
Common options:
|
||||||
- `--channel <name>`: `whatsapp|telegram|discord|slack|mattermost|signal|imessage|msteams`
|
- `--channel <name>`: `whatsapp|telegram|discord|googlechat|slack|mattermost|signal|imessage|msteams`
|
||||||
- `--account <id>`: channel account id (default `default`)
|
- `--account <id>`: channel account id (default `default`)
|
||||||
- `--name <label>`: display name for the account
|
- `--name <label>`: display name for the account
|
||||||
|
|
||||||
@ -387,12 +390,6 @@ Common options:
|
|||||||
- `--lines <n>` (default `200`)
|
- `--lines <n>` (default `200`)
|
||||||
- `--json`
|
- `--json`
|
||||||
|
|
||||||
OAuth sync sources:
|
|
||||||
- Claude Code → `anthropic:claude-cli`
|
|
||||||
- macOS: Keychain item "Claude Code-credentials" (choose "Always Allow" to avoid launchd prompts)
|
|
||||||
- Linux/Windows: `~/.claude/.credentials.json`
|
|
||||||
- `~/.codex/auth.json` → `openai-codex:codex-cli`
|
|
||||||
|
|
||||||
More detail: [/concepts/oauth](/concepts/oauth)
|
More detail: [/concepts/oauth](/concepts/oauth)
|
||||||
|
|
||||||
Examples:
|
Examples:
|
||||||
@ -663,7 +660,7 @@ Subcommands:
|
|||||||
|
|
||||||
Common RPCs:
|
Common RPCs:
|
||||||
- `config.apply` (validate + write config + restart + wake)
|
- `config.apply` (validate + write config + restart + wake)
|
||||||
- `config.patch` (merge a partial update without clobbering unrelated keys)
|
- `config.patch` (merge a partial update + restart + wake)
|
||||||
- `update.run` (run update + restart + wake)
|
- `update.run` (run update + restart + wake)
|
||||||
|
|
||||||
Tip: when calling `config.set`/`config.apply`/`config.patch` directly, pass `baseHash` from
|
Tip: when calling `config.set`/`config.apply`/`config.patch` directly, pass `baseHash` from
|
||||||
@ -673,10 +670,11 @@ Tip: when calling `config.set`/`config.apply`/`config.patch` directly, pass `bas
|
|||||||
|
|
||||||
See [/concepts/models](/concepts/models) for fallback behavior and scanning strategy.
|
See [/concepts/models](/concepts/models) for fallback behavior and scanning strategy.
|
||||||
|
|
||||||
Preferred Anthropic auth (CLI token, not API key):
|
Preferred Anthropic auth (setup-token):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
claude setup-token
|
claude setup-token
|
||||||
|
clawdbot models auth setup-token --provider anthropic
|
||||||
clawdbot models status
|
clawdbot models status
|
||||||
```
|
```
|
||||||
|
|
||||||
@ -700,8 +698,15 @@ Options:
|
|||||||
- `--json`
|
- `--json`
|
||||||
- `--plain`
|
- `--plain`
|
||||||
- `--check` (exit 1=expired/missing, 2=expiring)
|
- `--check` (exit 1=expired/missing, 2=expiring)
|
||||||
|
- `--probe` (live probe of configured auth profiles)
|
||||||
|
- `--probe-provider <name>`
|
||||||
|
- `--probe-profile <id>` (repeat or comma-separated)
|
||||||
|
- `--probe-timeout <ms>`
|
||||||
|
- `--probe-concurrency <n>`
|
||||||
|
- `--probe-max-tokens <n>`
|
||||||
|
|
||||||
Always includes the auth overview and OAuth expiry status for profiles in the auth store.
|
Always includes the auth overview and OAuth expiry status for profiles in the auth store.
|
||||||
|
`--probe` runs live requests (may consume tokens and trigger rate limits).
|
||||||
|
|
||||||
### `models set <model>`
|
### `models set <model>`
|
||||||
Set `agents.defaults.model.primary`.
|
Set `agents.defaults.model.primary`.
|
||||||
@ -756,9 +761,9 @@ Options:
|
|||||||
- `set`: `--provider <name>`, `--agent <id>`, `<profileIds...>`
|
- `set`: `--provider <name>`, `--agent <id>`, `<profileIds...>`
|
||||||
- `clear`: `--provider <name>`, `--agent <id>`
|
- `clear`: `--provider <name>`, `--agent <id>`
|
||||||
|
|
||||||
## Cron + wake
|
## System
|
||||||
|
|
||||||
### `wake`
|
### `system event`
|
||||||
Enqueue a system event and optionally trigger a heartbeat (Gateway RPC).
|
Enqueue a system event and optionally trigger a heartbeat (Gateway RPC).
|
||||||
|
|
||||||
Required:
|
Required:
|
||||||
@ -769,7 +774,21 @@ Options:
|
|||||||
- `--json`
|
- `--json`
|
||||||
- `--url`, `--token`, `--timeout`, `--expect-final`
|
- `--url`, `--token`, `--timeout`, `--expect-final`
|
||||||
|
|
||||||
### `cron`
|
### `system heartbeat last|enable|disable`
|
||||||
|
Heartbeat controls (Gateway RPC).
|
||||||
|
|
||||||
|
Options:
|
||||||
|
- `--json`
|
||||||
|
- `--url`, `--token`, `--timeout`, `--expect-final`
|
||||||
|
|
||||||
|
### `system presence`
|
||||||
|
List system presence entries (Gateway RPC).
|
||||||
|
|
||||||
|
Options:
|
||||||
|
- `--json`
|
||||||
|
- `--url`, `--token`, `--timeout`, `--expect-final`
|
||||||
|
|
||||||
|
## Cron
|
||||||
Manage scheduled jobs (Gateway RPC). See [/automation/cron-jobs](/automation/cron-jobs).
|
Manage scheduled jobs (Gateway RPC). See [/automation/cron-jobs](/automation/cron-jobs).
|
||||||
|
|
||||||
Subcommands:
|
Subcommands:
|
||||||
|
|||||||
@ -8,7 +8,7 @@ read_when:
|
|||||||
# `clawdbot message`
|
# `clawdbot message`
|
||||||
|
|
||||||
Single outbound command for sending messages and channel actions
|
Single outbound command for sending messages and channel actions
|
||||||
(Discord/Slack/Mattermost (plugin)/Telegram/WhatsApp/Signal/iMessage/MS Teams).
|
(Discord/Google Chat/Slack/Mattermost (plugin)/Telegram/WhatsApp/Signal/iMessage/MS Teams).
|
||||||
|
|
||||||
## Usage
|
## Usage
|
||||||
|
|
||||||
@ -19,12 +19,13 @@ clawdbot message <subcommand> [flags]
|
|||||||
Channel selection:
|
Channel selection:
|
||||||
- `--channel` required if more than one channel is configured.
|
- `--channel` required if more than one channel is configured.
|
||||||
- If exactly one channel is configured, it becomes the default.
|
- If exactly one channel is configured, it becomes the default.
|
||||||
- Values: `whatsapp|telegram|discord|slack|mattermost|signal|imessage|msteams` (Mattermost requires plugin)
|
- Values: `whatsapp|telegram|discord|googlechat|slack|mattermost|signal|imessage|msteams` (Mattermost requires plugin)
|
||||||
|
|
||||||
Target formats (`--target`):
|
Target formats (`--target`):
|
||||||
- WhatsApp: E.164 or group JID
|
- WhatsApp: E.164 or group JID
|
||||||
- Telegram: chat id or `@username`
|
- Telegram: chat id or `@username`
|
||||||
- Discord: `channel:<id>` or `user:<id>` (or `<@id>` mention; raw numeric ids are treated as channels)
|
- Discord: `channel:<id>` or `user:<id>` (or `<@id>` mention; raw numeric ids are treated as channels)
|
||||||
|
- Google Chat: `spaces/<spaceId>` or `users/<userId>`
|
||||||
- Slack: `channel:<id>` or `user:<id>` (raw channel id is accepted)
|
- Slack: `channel:<id>` or `user:<id>` (raw channel id is accepted)
|
||||||
- Mattermost (plugin): `channel:<id>`, `user:<id>`, or `@username` (bare ids are treated as channels)
|
- Mattermost (plugin): `channel:<id>`, `user:<id>`, or `@username` (bare ids are treated as channels)
|
||||||
- Signal: `+E.164`, `group:<id>`, `signal:+E.164`, `signal:group:<id>`, or `username:<name>`/`u:<name>`
|
- Signal: `+E.164`, `group:<id>`, `signal:+E.164`, `signal:group:<id>`, or `username:<name>`/`u:<name>`
|
||||||
@ -50,7 +51,7 @@ Name lookup:
|
|||||||
### Core
|
### Core
|
||||||
|
|
||||||
- `send`
|
- `send`
|
||||||
- Channels: WhatsApp/Telegram/Discord/Slack/Mattermost (plugin)/Signal/iMessage/MS Teams
|
- Channels: WhatsApp/Telegram/Discord/Google Chat/Slack/Mattermost (plugin)/Signal/iMessage/MS Teams
|
||||||
- Required: `--target`, plus `--message` or `--media`
|
- Required: `--target`, plus `--message` or `--media`
|
||||||
- Optional: `--media`, `--reply-to`, `--thread-id`, `--gif-playback`
|
- Optional: `--media`, `--reply-to`, `--thread-id`, `--gif-playback`
|
||||||
- Telegram only: `--buttons` (requires `channels.telegram.capabilities.inlineButtons` to allow it)
|
- Telegram only: `--buttons` (requires `channels.telegram.capabilities.inlineButtons` to allow it)
|
||||||
@ -65,14 +66,15 @@ Name lookup:
|
|||||||
- Discord only: `--poll-duration-hours`, `--message`
|
- Discord only: `--poll-duration-hours`, `--message`
|
||||||
|
|
||||||
- `react`
|
- `react`
|
||||||
- Channels: Discord/Slack/Telegram/WhatsApp
|
- Channels: Discord/Google Chat/Slack/Telegram/WhatsApp/Signal
|
||||||
- Required: `--message-id`, `--target`
|
- Required: `--message-id`, `--target`
|
||||||
- Optional: `--emoji`, `--remove`, `--participant`, `--from-me`
|
- Optional: `--emoji`, `--remove`, `--participant`, `--from-me`, `--target-author`, `--target-author-uuid`
|
||||||
- Note: `--remove` requires `--emoji` (omit `--emoji` to clear own reactions where supported; see /tools/reactions)
|
- Note: `--remove` requires `--emoji` (omit `--emoji` to clear own reactions where supported; see /tools/reactions)
|
||||||
- WhatsApp only: `--participant`, `--from-me`
|
- WhatsApp only: `--participant`, `--from-me`
|
||||||
|
- Signal group reactions: `--target-author` or `--target-author-uuid` required
|
||||||
|
|
||||||
- `reactions`
|
- `reactions`
|
||||||
- Channels: Discord/Slack
|
- Channels: Discord/Google Chat/Slack
|
||||||
- Required: `--message-id`, `--target`
|
- Required: `--message-id`, `--target`
|
||||||
- Optional: `--limit`
|
- Optional: `--limit`
|
||||||
|
|
||||||
@ -212,6 +214,13 @@ clawdbot message react --channel slack \
|
|||||||
--target C123 --message-id 456 --emoji "✅"
|
--target C123 --message-id 456 --emoji "✅"
|
||||||
```
|
```
|
||||||
|
|
||||||
|
React in a Signal group:
|
||||||
|
```
|
||||||
|
clawdbot message react --channel signal \
|
||||||
|
--target signal:group:abc123 --message-id 1737630212345 \
|
||||||
|
--emoji "✅" --target-author-uuid 123e4567-e89b-12d3-a456-426614174000
|
||||||
|
```
|
||||||
|
|
||||||
Send Telegram inline buttons:
|
Send Telegram inline buttons:
|
||||||
```
|
```
|
||||||
clawdbot message send --channel telegram --target @mychat --message "Choose:" \
|
clawdbot message send --channel telegram --target @mychat --message "Choose:" \
|
||||||
|
|||||||
@ -25,12 +25,26 @@ clawdbot models scan
|
|||||||
`clawdbot models status` shows the resolved default/fallbacks plus an auth overview.
|
`clawdbot models status` shows the resolved default/fallbacks plus an auth overview.
|
||||||
When provider usage snapshots are available, the OAuth/token status section includes
|
When provider usage snapshots are available, the OAuth/token status section includes
|
||||||
provider usage headers.
|
provider usage headers.
|
||||||
|
Add `--probe` to run live auth probes against each configured provider profile.
|
||||||
|
Probes are real requests (may consume tokens and trigger rate limits).
|
||||||
|
|
||||||
Notes:
|
Notes:
|
||||||
- `models set <model-or-alias>` accepts `provider/model` or an alias.
|
- `models set <model-or-alias>` accepts `provider/model` or an alias.
|
||||||
- Model refs are parsed by splitting on the **first** `/`. If the model ID includes `/` (OpenRouter-style), include the provider prefix (example: `openrouter/moonshotai/kimi-k2`).
|
- Model refs are parsed by splitting on the **first** `/`. If the model ID includes `/` (OpenRouter-style), include the provider prefix (example: `openrouter/moonshotai/kimi-k2`).
|
||||||
- If you omit the provider, Clawdbot treats the input as an alias or a model for the **default provider** (only works when there is no `/` in the model ID).
|
- If you omit the provider, Clawdbot treats the input as an alias or a model for the **default provider** (only works when there is no `/` in the model ID).
|
||||||
|
|
||||||
|
### `models status`
|
||||||
|
Options:
|
||||||
|
- `--json`
|
||||||
|
- `--plain`
|
||||||
|
- `--check` (exit 1=expired/missing, 2=expiring)
|
||||||
|
- `--probe` (live probe of configured auth profiles)
|
||||||
|
- `--probe-provider <name>` (probe one provider)
|
||||||
|
- `--probe-profile <id>` (repeat or comma-separated profile ids)
|
||||||
|
- `--probe-timeout <ms>`
|
||||||
|
- `--probe-concurrency <n>`
|
||||||
|
- `--probe-max-tokens <n>`
|
||||||
|
|
||||||
## Aliases + fallbacks
|
## Aliases + fallbacks
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@ -50,5 +64,5 @@ clawdbot models auth paste-token
|
|||||||
`clawdbot plugins list` to see which providers are installed.
|
`clawdbot plugins list` to see which providers are installed.
|
||||||
|
|
||||||
Notes:
|
Notes:
|
||||||
- `setup-token` runs `claude setup-token` on the current machine (requires the Claude Code CLI).
|
- `setup-token` prompts for a setup-token value (generate it with `claude setup-token` on any machine).
|
||||||
- `paste-token` accepts a token string generated elsewhere.
|
- `paste-token` accepts a token string generated elsewhere or from automation.
|
||||||
|
|||||||
@ -23,6 +23,24 @@ Common use cases:
|
|||||||
Execution is still guarded by **exec approvals** and per‑agent allowlists on the
|
Execution is still guarded by **exec approvals** and per‑agent allowlists on the
|
||||||
node host, so you can keep command access scoped and explicit.
|
node host, so you can keep command access scoped and explicit.
|
||||||
|
|
||||||
|
## Browser proxy (zero-config)
|
||||||
|
|
||||||
|
Node hosts automatically advertise a browser proxy if `browser.enabled` is not
|
||||||
|
disabled on the node. This lets the agent use browser automation on that node
|
||||||
|
without extra configuration.
|
||||||
|
|
||||||
|
Disable it on the node if needed:
|
||||||
|
|
||||||
|
```json5
|
||||||
|
{
|
||||||
|
nodeHost: {
|
||||||
|
browserProxy: {
|
||||||
|
enabled: false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
## Run (foreground)
|
## Run (foreground)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user