Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
43c2c9b697 | ||
|
|
bb82851124 | ||
|
|
ca627ddcd4 | ||
|
|
9a6964b81f |
No files matched your search
@@ -7,10 +7,6 @@
|
|||||||
[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,17 +0,0 @@
|
|||||||
# 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'
|
|
||||||
@@ -1,113 +0,0 @@
|
|||||||
# 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
|
|
||||||
+24
-28
@@ -32,21 +32,20 @@ jobs:
|
|||||||
node-version: 22.x
|
node-version: 22.x
|
||||||
check-latest: true
|
check-latest: true
|
||||||
|
|
||||||
- name: Setup pnpm
|
|
||||||
uses: pnpm/action-setup@v4
|
|
||||||
with:
|
|
||||||
version: 10.23.0
|
|
||||||
run_install: false
|
|
||||||
|
|
||||||
- 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
|
||||||
@@ -109,12 +108,6 @@ jobs:
|
|||||||
node-version: 22.x
|
node-version: 22.x
|
||||||
check-latest: true
|
check-latest: true
|
||||||
|
|
||||||
- name: Setup pnpm
|
|
||||||
uses: pnpm/action-setup@v4
|
|
||||||
with:
|
|
||||||
version: 10.23.0
|
|
||||||
run_install: false
|
|
||||||
|
|
||||||
- name: Setup Bun
|
- name: Setup Bun
|
||||||
uses: oven-sh/setup-bun@v2
|
uses: oven-sh/setup-bun@v2
|
||||||
with:
|
with:
|
||||||
@@ -125,11 +118,16 @@ 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
|
||||||
@@ -214,12 +212,6 @@ jobs:
|
|||||||
node-version: 22.x
|
node-version: 22.x
|
||||||
check-latest: true
|
check-latest: true
|
||||||
|
|
||||||
- name: Setup pnpm
|
|
||||||
uses: pnpm/action-setup@v4
|
|
||||||
with:
|
|
||||||
version: 10.23.0
|
|
||||||
run_install: false
|
|
||||||
|
|
||||||
- name: Setup Bun
|
- name: Setup Bun
|
||||||
uses: oven-sh/setup-bun@v2
|
uses: oven-sh/setup-bun@v2
|
||||||
with:
|
with:
|
||||||
@@ -230,11 +222,16 @@ 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
|
||||||
@@ -282,21 +279,20 @@ jobs:
|
|||||||
node-version: 22.x
|
node-version: 22.x
|
||||||
check-latest: true
|
check-latest: true
|
||||||
|
|
||||||
- name: Setup pnpm
|
|
||||||
uses: pnpm/action-setup@v4
|
|
||||||
with:
|
|
||||||
version: 10.23.0
|
|
||||||
run_install: false
|
|
||||||
|
|
||||||
- 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
|
||||||
|
|||||||
@@ -14,10 +14,11 @@ jobs:
|
|||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Setup pnpm
|
- name: Setup pnpm
|
||||||
uses: pnpm/action-setup@v4
|
uses: pnpm/action-setup@v3
|
||||||
with:
|
with:
|
||||||
version: 10.23.0
|
version: 10
|
||||||
run_install: false
|
- name: Enable Corepack
|
||||||
|
run: corepack enable
|
||||||
|
|
||||||
- name: Install pnpm deps (minimal)
|
- name: Install pnpm deps (minimal)
|
||||||
run: pnpm install --ignore-scripts --frozen-lockfile
|
run: pnpm install --ignore-scripts --frozen-lockfile
|
||||||
|
|||||||
@@ -1,105 +0,0 @@
|
|||||||
# 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]
|
|
||||||
+123
-1796
File diff suppressed because it is too large.
Load diff
@@ -1,25 +0,0 @@
|
|||||||
# 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
|
|
||||||
+1
-1
@@ -23,7 +23,7 @@
|
|||||||
# Whitespace
|
# Whitespace
|
||||||
--trimwhitespace always
|
--trimwhitespace always
|
||||||
--emptybraces no-space
|
--emptybraces no-space
|
||||||
--nospaceoperators ...,..<
|
--nospaceoperators ...,..<
|
||||||
--ranges no-space
|
--ranges no-space
|
||||||
--someAny true
|
--someAny true
|
||||||
--voidtype void
|
--voidtype void
|
||||||
|
|||||||
@@ -37,7 +37,6 @@
|
|||||||
## 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`.
|
||||||
|
|||||||
@@ -13,8 +13,6 @@ Docs: https://docs.clawd.bot
|
|||||||
- Web search: add Brave freshness filter parameter for time-scoped results. (#1688) Thanks @JonUleis. https://docs.clawd.bot/tools/web
|
- Web search: add Brave freshness filter parameter for time-scoped results. (#1688) Thanks @JonUleis. https://docs.clawd.bot/tools/web
|
||||||
- TTS: add auto mode enum (off/always/inbound/tagged) with per-session `/tts` override. (#1667) Thanks @sebslight. 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
|
||||||
- Channels: add LINE plugin (Messaging API) with rich replies, quick replies, and plugin HTTP registry. (#1630) Thanks @plum-dawg.
|
- Channels: add LINE plugin (Messaging API) with rich replies, quick replies, and plugin HTTP registry. (#1630) Thanks @plum-dawg.
|
||||||
- Dev: add prek pre-commit hooks + dependabot config for weekly updates. (#1720) Thanks @dguido.
|
|
||||||
- UI: refresh Control UI dashboard design system (typography, colors, spacing). (#1786) Thanks @mousberg.
|
|
||||||
- Docs: expand FAQ (migration, scheduling, concurrency, model recommendations, OpenAI subscription auth, Pi sizing, hackable install, docs SSL workaround).
|
- 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 verbose installer troubleshooting guidance.
|
||||||
- Docs: add macOS VM guide with local/hosted options + VPS/nodes guidance. (#1693) Thanks @f-trycua.
|
- Docs: add macOS VM guide with local/hosted options + VPS/nodes guidance. (#1693) Thanks @f-trycua.
|
||||||
@@ -25,15 +23,8 @@ Docs: https://docs.clawd.bot
|
|||||||
- Telegram: add `channels.telegram.linkPreview` to toggle outbound link previews. (#1700) Thanks @zerone0x. https://docs.clawd.bot/channels/telegram
|
- Telegram: add `channels.telegram.linkPreview` to toggle outbound link previews. (#1700) Thanks @zerone0x. https://docs.clawd.bot/channels/telegram
|
||||||
- Telegram: treat DM topics as separate sessions and keep DM history limits stable with thread suffixes. (#1597) Thanks @rohannagpal.
|
- Telegram: treat DM topics as separate sessions and keep DM history limits stable with thread suffixes. (#1597) Thanks @rohannagpal.
|
||||||
- Telegram: add verbose raw-update logging for inbound Telegram updates. (#1597) Thanks @rohannagpal.
|
- Telegram: add verbose raw-update logging for inbound Telegram updates. (#1597) Thanks @rohannagpal.
|
||||||
- Diagnostics: add diagnostic flags for targeted debug logs (config + env override). https://docs.clawd.bot/diagnostics/flags
|
|
||||||
|
|
||||||
### Fixes
|
### Fixes
|
||||||
- Gemini CLI OAuth: auto-detect client id from installed CLI and harden discovery paths. (#1773) Thanks @benostein.
|
|
||||||
- Matrix: decrypt E2EE media attachments with preflight size guard. (#1744) Thanks @araa47.
|
|
||||||
- Gateway: allow Control UI token-only auth to skip device pairing even when device identity is present (`gateway.controlUi.allowInsecureAuth`). (#1679) Thanks @steipete.
|
|
||||||
- Gateway: include inline config env vars in service install environments. (#1735) Thanks @Seredeep.
|
|
||||||
- Exec: treat Windows platform labels as Windows for node shell selection. (#1760) Thanks @ymat19.
|
|
||||||
- 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.
|
- BlueBubbles: keep part-index GUIDs in reply tags when short IDs are missing.
|
||||||
- Web UI: hide internal `message_id` hints in chat bubbles.
|
- Web UI: hide internal `message_id` hints in chat bubbles.
|
||||||
- Web UI: show Stop button during active runs, swap back to New session when idle. (#1664) Thanks @ndbroadbent.
|
- Web UI: show Stop button during active runs, swap back to New session when idle. (#1664) Thanks @ndbroadbent.
|
||||||
@@ -42,7 +33,6 @@ Docs: https://docs.clawd.bot
|
|||||||
- TUI: reload history after gateway reconnect to restore session state. (#1663)
|
- TUI: reload history after gateway reconnect to restore session state. (#1663)
|
||||||
- Telegram: use wrapped fetch for long-polling on Node to normalize AbortSignal handling. (#1639)
|
- Telegram: use wrapped fetch for long-polling on Node to normalize AbortSignal handling. (#1639)
|
||||||
- Telegram: set fetch duplex="half" for uploads on Node 22 to avoid sendPhoto failures. (#1684) Thanks @commdata2338.
|
- Telegram: set fetch duplex="half" for uploads on Node 22 to avoid sendPhoto failures. (#1684) Thanks @commdata2338.
|
||||||
- Telegram: honor per-account proxy for outbound API calls. (#1774) Thanks @radek-paclt.
|
|
||||||
- Signal: repair reaction sends (group/UUID targets + CLI author flags). (#1651) Thanks @vilkasdev.
|
- 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
|
- Signal: add configurable signal-cli startup timeout + external daemon mode docs. (#1677) https://docs.clawd.bot/channels/signal
|
||||||
- Exec: keep approvals for elevated ask unless full mode. (#1616) Thanks @ivancasco.
|
- Exec: keep approvals for elevated ask unless full mode. (#1616) Thanks @ivancasco.
|
||||||
@@ -57,7 +47,6 @@ Docs: https://docs.clawd.bot
|
|||||||
- Gateway: store lock files in the temp directory to avoid stale locks on persistent volumes. (#1676)
|
- 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.
|
- macOS: default direct-transport `ws://` URLs to port 18789; document `gateway.remote.transport`. (#1603) Thanks @ngutman.
|
||||||
- Voice Call: return stream TwiML for outbound conversation calls on initial Twilio webhook. (#1634)
|
- 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: tighten email allowlist matching, typing cleanup, media caps, and onboarding/docs/tests. (#1635) Thanks @iHildy.
|
||||||
- Google Chat: normalize space targets without double `spaces/` prefix.
|
- Google Chat: normalize space targets without double `spaces/` prefix.
|
||||||
- Messaging: keep newline chunking safe for fenced markdown blocks across channels.
|
- Messaging: keep newline chunking safe for fenced markdown blocks across channels.
|
||||||
|
|||||||
@@ -40,13 +40,3 @@ 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!
|
|
||||||
@@ -459,7 +459,7 @@ Use these when you’re past the onboarding flow and want the deeper reference.
|
|||||||
|
|
||||||
## Clawd
|
## Clawd
|
||||||
|
|
||||||
Clawdbot was built for **Clawd**, a space lobster AI assistant. 🦞
|
Clawdbot was built for **Clawd**, a space lobster AI assistant. 🦞
|
||||||
by Peter Steinberger and the community.
|
by Peter Steinberger and the community.
|
||||||
|
|
||||||
- [clawd.me](https://clawd.me)
|
- [clawd.me](https://clawd.me)
|
||||||
@@ -468,7 +468,7 @@ by Peter Steinberger and the community.
|
|||||||
|
|
||||||
## Community
|
## Community
|
||||||
|
|
||||||
See [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines, maintainers, and how to submit PRs.
|
See [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines, maintainers, and how to submit PRs.
|
||||||
AI/vibe-coded PRs welcome! 🤖
|
AI/vibe-coded PRs welcome! 🤖
|
||||||
|
|
||||||
Special thanks to [Mario Zechner](https://mariozechner.at/) for his support and for
|
Special thanks to [Mario Zechner](https://mariozechner.at/) for his support and for
|
||||||
|
|||||||
@@ -12,3 +12,4 @@ If you believe you’ve found a security issue in Clawdbot, please report it pri
|
|||||||
For threat model + hardening guidance (including `clawdbot security audit --deep` and `--fix`), see:
|
For threat model + hardening guidance (including `clawdbot security audit --deep` and `--fix`), see:
|
||||||
|
|
||||||
- `https://docs.clawd.bot/gateway/security`
|
- `https://docs.clawd.bot/gateway/security`
|
||||||
|
|
||||||
+1
-1
@@ -212,4 +212,4 @@
|
|||||||
<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.21/Clawdbot-2026.1.21.zip" length="22284796" type="application/octet-stream" sparkle:edSignature="pXji4NMA/cu35iMxln385d6LnsT4yIZtFtFiR7sIimKeSC2CsyeWzzSD0EhJsN98PdSoy69iEFZt4I2ZtNCECg=="/>
|
||||||
</item>
|
</item>
|
||||||
</channel>
|
</channel>
|
||||||
</rss>
|
</rss>
|
||||||
@@ -12,3 +12,4 @@ data class CameraHudState(
|
|||||||
val kind: CameraHudKind,
|
val kind: CameraHudKind,
|
||||||
val message: String,
|
val message: String,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -12,3 +12,4 @@ enum class VoiceWakeMode(val rawValue: String) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -135,7 +135,7 @@ class SmsManager(private val context: Context) {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Send an SMS message.
|
* Send an SMS message.
|
||||||
*
|
*
|
||||||
* @param paramsJson JSON with "to" (phone number) and "message" (text) fields
|
* @param paramsJson JSON with "to" (phone number) and "message" (text) fields
|
||||||
* @return SendResult indicating success or failure
|
* @return SendResult indicating success or failure
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
<resources>
|
<resources>
|
||||||
<color name="ic_launcher_background">#0A0A0A</color>
|
<color name="ic_launcher_background">#0A0A0A</color>
|
||||||
</resources>
|
</resources>
|
||||||
|
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
<resources>
|
<resources>
|
||||||
<string name="app_name">Clawdbot Node</string>
|
<string name="app_name">Clawdbot Node</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|
||||||
+1
@@ -23,3 +23,4 @@ class VoiceWakeCommandExtractorTest {
|
|||||||
assertNull(VoiceWakeCommandExtractor.extractCommand("hey claude!", listOf("claude")))
|
assertNull(VoiceWakeCommandExtractor.extractCommand("hey claude!", listOf("claude")))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -16,3 +16,4 @@ dependencyResolutionManagement {
|
|||||||
|
|
||||||
rootProject.name = "ClawdbotNodeAndroid"
|
rootProject.name = "ClawdbotNodeAndroid"
|
||||||
include(":app")
|
include(":app")
|
||||||
|
|
||||||
@@ -3,3 +3,4 @@ parent_config: ../../.swiftlint.yml
|
|||||||
included:
|
included:
|
||||||
- Sources
|
- Sources
|
||||||
- ../shared/ClawdisNodeKit/Sources
|
- ../shared/ClawdisNodeKit/Sources
|
||||||
|
|
||||||
@@ -33,4 +33,4 @@
|
|||||||
],
|
],
|
||||||
"squares" : "shared"
|
"squares" : "shared"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+1
-1
@@ -18,4 +18,4 @@ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|||||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
SOFTWARE.
|
SOFTWARE.
|
||||||
@@ -173,4 +173,4 @@
|
|||||||
"iPod5,1": "iPod touch (5th generation)",
|
"iPod5,1": "iPod touch (5th generation)",
|
||||||
"iPod7,1": "iPod touch (6th generation)",
|
"iPod7,1": "iPod touch (6th generation)",
|
||||||
"iPod9,1": "iPod touch (7th generation)"
|
"iPod9,1": "iPod touch (7th generation)"
|
||||||
}
|
}
|
||||||
@@ -211,4 +211,4 @@
|
|||||||
"Mac Pro (2019)",
|
"Mac Pro (2019)",
|
||||||
"Mac Pro (Rack, 2019)"
|
"Mac Pro (Rack, 2019)"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -574,22 +574,46 @@ public actor GatewayChannelActor {
|
|||||||
params: [String: AnyCodable]?,
|
params: [String: AnyCodable]?,
|
||||||
timeoutMs: Double? = nil) async throws -> Data
|
timeoutMs: Double? = nil) async throws -> Data
|
||||||
{
|
{
|
||||||
try await self.connectOrThrow(context: "gateway connect")
|
do {
|
||||||
|
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
|
||||||
let payload = try self.encodeRequest(method: method, params: params, kind: "request")
|
// 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)
|
||||||
|
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[payload.id] = cont
|
self.pending[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: payload.id, timeoutMs: effectiveTimeout)
|
await self.timeoutRequest(id: id, timeoutMs: effectiveTimeout)
|
||||||
}
|
}
|
||||||
Task {
|
Task {
|
||||||
do {
|
do {
|
||||||
try await self.task?.send(.data(payload.data))
|
try await self.task?.send(.data(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: payload.id)
|
let waiter = self.pending.removeValue(forKey: 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)
|
||||||
@@ -619,29 +643,6 @@ 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 {
|
||||||
@@ -656,42 +657,6 @@ 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.send(method: "node.event", params: params)
|
_ = try await channel.request(method: "node.event", params: params, timeoutMs: 8000)
|
||||||
} 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.send(method: "node.invoke.result", params: params)
|
_ = try await channel.request(method: "node.invoke.result", params: params, timeoutMs: 15000)
|
||||||
} catch {
|
} catch {
|
||||||
self.logger.error("node invoke result failed: \(error.localizedDescription, privacy: .public)")
|
self.logger.error("node invoke result failed: \(error.localizedDescription, privacy: .public)")
|
||||||
}
|
}
|
||||||
|
|||||||
-1
@@ -1 +0,0 @@
|
|||||||
@import"https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&family=JetBrains+Mono:wght@400;500&display=swap";:root{--bg: #0c0d12;--bg-accent: #0d0e14;--bg-elevated: #181a21;--bg-hover: #252830;--bg-muted: #252830;--card: #13151c;--card-foreground: #f8fafc;--card-highlight: rgba(255, 255, 255, .04);--popover: #13151c;--popover-foreground: #f8fafc;--panel: #0c0d12;--panel-strong: #181a21;--panel-hover: #252830;--chrome: rgba(12, 13, 18, .95);--chrome-strong: rgba(12, 13, 18, .98);--text: #f8fafc;--text-strong: #ffffff;--chat-text: #f8fafc;--muted: #94a3b8;--muted-strong: #64748b;--muted-foreground: #94a3b8;--border: #333842;--border-strong: #454d5c;--border-hover: #5a6373;--input: #333842;--ring: #ff4d4d;--accent: #ff4d4d;--accent-hover: #ff6666;--accent-muted: #ff4d4d;--accent-subtle: rgba(255, 77, 77, .12);--accent-foreground: #f8fafc;--primary: #ff4d4d;--primary-foreground: #ffffff;--secondary: #252830;--secondary-foreground: #f8fafc;--accent-2: #3b82f6;--accent-2-muted: rgba(59, 130, 246, .7);--ok: #22c55e;--ok-muted: rgba(34, 197, 94, .7);--ok-subtle: rgba(34, 197, 94, .1);--destructive: #ef4444;--destructive-foreground: #fafafa;--warn: #eab308;--warn-muted: rgba(234, 179, 8, .7);--warn-subtle: rgba(234, 179, 8, .1);--danger: #ef4444;--danger-muted: rgba(239, 68, 68, .7);--danger-subtle: rgba(239, 68, 68, .1);--info: #3b82f6;--focus: rgba(255, 77, 77, .2);--focus-ring: 0 0 0 2px var(--bg), 0 0 0 4px var(--ring);--grid-line: rgba(255, 255, 255, .03);--theme-switch-x: 50%;--theme-switch-y: 50%;--mono: "JetBrains Mono", ui-monospace, SFMono-Regular, "SF Mono", Menlo, Monaco, Consolas, monospace;--font-body: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;--font-display: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;--shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, .05);--shadow-md: 0 4px 6px -1px rgba(0, 0, 0, .1), 0 2px 4px -2px rgba(0, 0, 0, .1);--shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, .1), 0 4px 6px -4px rgba(0, 0, 0, .1);--shadow-xl: 0 20px 25px -5px rgba(0, 0, 0, .1), 0 8px 10px -6px rgba(0, 0, 0, .1);--radius-sm: 4px;--radius-md: 6px;--radius-lg: 8px;--radius-xl: 12px;--radius-full: 9999px;--radius: 6px;--ease-out: cubic-bezier(.16, 1, .3, 1);--ease-in-out: cubic-bezier(.4, 0, .2, 1);--duration-fast: .15s;--duration-normal: .2s;--duration-slow: .3s;color-scheme:dark}:root[data-theme=light]{--bg: #f8f8f7;--bg-accent: #f3f2f0;--bg-elevated: #ffffff;--bg-hover: #eae8e6;--bg-muted: #eae8e6;--bg-content: #f0efed;--card: #ffffff;--card-foreground: #1c1917;--card-highlight: rgba(0, 0, 0, .04);--popover: #ffffff;--popover-foreground: #1c1917;--panel: #f8f8f7;--panel-strong: #f0efed;--panel-hover: #e5e3e1;--chrome: rgba(248, 248, 247, .95);--chrome-strong: rgba(248, 248, 247, .98);--text: #44403c;--text-strong: #292524;--chat-text: #44403c;--muted: #5c5856;--muted-strong: #44403c;--muted-foreground: #5c5856;--border: #e0dedc;--border-strong: #d6d3d1;--border-hover: #a8a5a0;--input: #e0dedc;--accent: #b91c1c;--accent-hover: #dc2626;--accent-muted: #b91c1c;--accent-subtle: rgba(185, 28, 28, .18);--accent-foreground: #ffffff;--primary: #b91c1c;--primary-foreground: #ffffff;--secondary: #eae8e6;--secondary-foreground: #44403c;--ok: #15803d;--ok-muted: rgba(21, 128, 61, .75);--ok-subtle: rgba(21, 128, 61, .12);--destructive: #b91c1c;--destructive-foreground: #fafafa;--warn: #a16207;--warn-muted: rgba(161, 98, 7, .75);--warn-subtle: rgba(161, 98, 7, .12);--danger: #b91c1c;--danger-muted: rgba(185, 28, 28, .75);--danger-subtle: rgba(185, 28, 28, .12);--info: #1d4ed8;--focus: rgba(185, 28, 28, .25);--grid-line: rgba(0, 0, 0, .06);color-scheme:light}*{box-sizing:border-box}html,body{height:100%}body{margin:0;font:400 14px/1.5 var(--font-body);letter-spacing:-.011em;background:var(--bg);color:var(--text);-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}@keyframes theme-circle-transition{0%{clip-path:circle(0% at var(--theme-switch-x, 50%) var(--theme-switch-y, 50%))}to{clip-path:circle(150% at var(--theme-switch-x, 50%) var(--theme-switch-y, 50%))}}html.theme-transition{view-transition-name:theme}html.theme-transition::view-transition-old(theme){mix-blend-mode:normal;animation:none;z-index:1}html.theme-transition::view-transition-new(theme){mix-blend-mode:normal;z-index:2;animation:theme-circle-transition .4s var(--ease-out) forwards}@media(prefers-reduced-motion:reduce){html.theme-transition::view-transition-old(theme),html.theme-transition::view-transition-new(theme){animation:none!important}}clawdbot-app{display:block;position:relative;z-index:1;min-height:100vh}a{color:var(--accent);text-decoration:none}a:hover{text-decoration:underline}button,input,textarea,select{font:inherit;color:inherit}::selection{background:var(--accent-subtle);color:var(--text-strong)}::-webkit-scrollbar{width:8px;height:8px}::-webkit-scrollbar-track{background:transparent}::-webkit-scrollbar-thumb{background:var(--border);border-radius:var(--radius-full)}::-webLine truncated
|
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
@import"https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500&family=Unbounded:wght@400;500;600&family=Work+Sans:wght@400;500;600;700&display=swap";:root{--bg: #0a0f14;--bg-accent: #111826;--bg-grad-1: #162031;--bg-grad-2: #1f2a22;--bg-overlay: rgba(255, 255, 255, .05);--bg-glow: rgba(245, 159, 74, .12);--panel: rgba(14, 20, 30, .88);--panel-strong: rgba(18, 26, 38, .96);--chrome: rgba(9, 14, 20, .72);--chrome-strong: rgba(9, 14, 20, .86);--text: rgba(244, 246, 251, .96);--chat-text: rgba(231, 237, 244, .92);--muted: rgba(156, 169, 189, .72);--border: rgba(255, 255, 255, .09);--border-strong: rgba(255, 255, 255, .16);--accent: #f59f4a;--accent-2: #34c7b7;--ok: #2bd97f;--warn: #f2c94c;--danger: #ff6b6b;--focus: rgba(245, 159, 74, .35);--grid-line: rgba(255, 255, 255, .04);--theme-switch-x: 50%;--theme-switch-y: 50%;--mono: "IBM Plex Mono", ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;--font-body: "Work Sans", system-ui, sans-serif;--font-display: "Unbounded", "Times New Roman", serif;color-scheme:dark}:root[data-theme=light]{--bg: #f5f1ea;--bg-accent: #ffffff;--bg-grad-1: #f1e6d6;--bg-grad-2: #e5eef4;--bg-overlay: rgba(28, 32, 46, .05);--bg-glow: rgba(52, 199, 183, .14);--panel: rgba(255, 255, 255, .9);--panel-strong: rgba(255, 255, 255, .97);--chrome: rgba(255, 255, 255, .75);--chrome-strong: rgba(255, 255, 255, .88);--text: rgba(27, 36, 50, .98);--chat-text: rgba(36, 48, 66, .9);--muted: rgba(80, 94, 114, .7);--border: rgba(18, 24, 40, .12);--border-strong: rgba(18, 24, 40, .2);--accent: #e28a3f;--accent-2: #1ba99d;--ok: #1aa86c;--warn: #b3771c;--danger: #d44848;--focus: rgba(226, 138, 63, .35);--grid-line: rgba(18, 24, 40, .06);color-scheme:light}*{box-sizing:border-box}html,body{height:100%}body{margin:0;font:15px/1.5 var(--font-body);background:radial-gradient(1200px 900px at 15% -10%,var(--bg-grad-1) 0%,transparent 55%) fixed,radial-gradient(900px 700px at 80% 10%,var(--bg-grad-2) 0%,transparent 60%) fixed,linear-gradient(160deg,var(--bg) 0%,var(--bg-accent) 100%) fixed;color:var(--text)}body:before{content:"";position:fixed;inset:0;background:linear-gradient(140deg,var(--bg-overlay) 0%,rgba(255,255,255,0) 40%),radial-gradient(620px 420px at 75% 75%,var(--bg-glow),transparent 60%);pointer-events:none;z-index:0}@keyframes theme-circle-transition{0%{clip-path:circle(0% at var(--theme-switch-x, 50%) var(--theme-switch-y, 50%))}to{clip-path:circle(150% at var(--theme-switch-x, 50%) var(--theme-switch-y, 50%))}}html.theme-transition{view-transition-name:theme}html.theme-transition::view-transition-old(theme){mix-blend-mode:normal;animation:none;z-index:1}html.theme-transition::view-transition-new(theme){mix-blend-mode:normal;z-index:2;animation:theme-circle-transition .45s ease-out forwards}@media(prefers-reduced-motion:reduce){html.theme-transition::view-transition-old(theme),html.theme-transition::view-transition-new(theme){animation:none!important}}clawdbot-app{display:block;position:relative;z-index:1;min-height:100vh}a{color:inherit}button,input,textarea,select{font:inherit;color:inherit}@keyframes rise{0%{opacity:0;transform:translateY(6px)}to{opacity:1;transform:translateY(0)}}@keyframes dashboard-enter{0%{opacity:0;transform:translateY(12px)}to{opacity:1;transform:translateY(0)}}.shell{--shell-pad: 16px;--shell-gap: 16px;--shell-nav-width: 220px;--shell-topbar-height: 56px;--shell-focus-duration: .22s;--shell-focus-ease: cubic-bezier(.2, .85, .25, 1);height:100vh;display:grid;grid-template-columns:var(--shell-nav-width) minmax(0,1fr);grid-template-rows:var(--shell-topbar-height) 1fr;grid-template-areas:"topbar topbar" "nav content";gap:0;animation:dashboard-enter .6s ease-out;transition:grid-template-columns var(--shell-focus-duration) var(--shell-focus-ease);overflow:hidden}@supports (height: 100dvh){.shell{height:100dvh}}.shell--chat{min-height:100vh;height:100vh;overflow:hidden}@supports (height: 100dvh){.shell--chat{height:100dvh}}.shell--nav-collapsed,.shell--chat-focus{grid-template-columns:0px minmax(0,1fr)}.shell--onboarding{grid-template-rows:0 1fr}.shell--onboarding .topbar{display:none}.shell--onboarding .content{padding-top:0}.shell--chat-focus .content{padding-top:0;gap:0}.topbar{grid-area:topbar;position:sticky;top:0;z-index:40;display:flex;justify-content:space-between;align-items:center;gap:16px;padding:0 20px;height:var(--shell-topbar-height);border-bottom:1px solid var(--border);background:var(--panel);-webkit-backdrop-filter:blur(18px);backdrop-filter:blur(18px)}.topbar-left{display:flex;align-items:center;gap:12px}.topbar .nav-collapse-toggle{width:44px;height:44px;margin-bottom:0}.topbar .nav-collapse-toggle__icon{font-size:22px}.brand{display:flex;flex-direction:column;gap:2px}.brand-title{font-family:var(--font-display);font-size:16px;letter-spacing:1px;text-transform:uppercase;font-weight:600;line-height:1.1}.brand-sub{font-size:10px;color:var(--muted);letter-spacing:.8px;text-transform:uppercase;line-heightLine truncated
|
||||||
-3119
File diff suppressed because it is too large.
Load diff
@@ -1 +0,0 @@
|
|||||||
{"version":3,"file":"index-DQcOTEYz.js","sources":["../../../node_modules/.pnpm/@lit+reactive-element@2.1.2/node_modules/@lit/reactive-element/css-tag.js","../../../node_modules/.pnpm/@lit+reactive-element@2.1.2/node_modules/@lit/reactive-element/reactive-element.js","../../../node_modules/.pnpm/lit-html@3.3.2/node_modules/lit-html/lit-html.js","../../../node_modules/.pnpm/lit-element@4.2.2/node_modules/lit-element/lit-element.js","../../../node_modules/.pnpm/@lit+reactive-element@2.1.2/node_modules/@lit/reactive-element/decorators/custom-element.js","../../../node_modules/.pnpm/@lit+reactive-element@2.1.2/node_modules/@lit/reactive-element/decorators/property.js","../../../node_modules/.pnpm/@lit+reactive-element@2.1.2/node_modules/@lit/reactive-element/decorators/state.js","../../../ui/src/ui/assistant-identity.ts","../../../ui/src/ui/storage.ts","../../../src/sessions/session-key-utils.ts","../../../ui/src/ui/navigation.ts","../../../ui/src/ui/icons.ts","../../../src/shared/text/reasoning-tags.ts","../../../ui/src/ui/format.ts","../../../ui/src/ui/chat/message-extract.ts","../../../ui/src/ui/uuid.ts","../../../ui/src/ui/controllers/chat.ts","../../../ui/src/ui/controllers/sessions.ts","../../../ui/src/ui/app-tool-stream.ts","../../../ui/src/ui/app-scroll.ts","../../../ui/src/ui/controllers/config/form-utils.ts","../../../ui/src/ui/controllers/config.ts","../../../ui/src/ui/controllers/cron.ts","../../../ui/src/ui/controllers/channels.ts","../../../ui/src/ui/controllers/debug.ts","../../../ui/src/ui/controllers/logs.ts","../../../node_modules/.pnpm/@noble+ed25519@3.0.0/node_modules/@noble/ed25519/index.js","../../../ui/src/ui/device-identity.ts","../../../ui/src/ui/device-auth.ts","../../../ui/src/ui/controllers/devices.ts","../../../ui/src/ui/controllers/nodes.ts","../../../ui/src/ui/controllers/exec-approvals.ts","../../../ui/src/ui/controllers/presence.ts","../../../ui/src/ui/controllers/skills.ts","../../../ui/src/ui/theme.ts","../../../ui/src/ui/theme-transition.ts","../../../ui/src/ui/app-polling.ts","../../../ui/src/ui/app-settings.ts","../../../ui/src/ui/app-chat.ts","../../../node_modules/.pnpm/lit-html@3.3.2/node_modules/lit-html/directive.js","../../../node_modules/.pnpm/lit-html@3.3.2/node_modules/lit-html/directive-helpers.js","../../../node_modules/.pnpm/lit-html@3.3.2/node_modules/lit-html/directives/repeat.js","../../../ui/src/ui/chat/message-normalizer.ts","../../../node_modules/.pnpm/lit-html@3.3.2/node_modules/lit-html/directives/unsafe-html.js","../../../node_modules/.pnpm/dompurify@3.3.1/node_modules/dompurify/dist/purify.es.mjs","../../../node_modules/.pnpm/marked@17.0.1/node_modules/marked/lib/marked.esm.js","../../../ui/src/ui/markdown.ts","../../../ui/src/ui/chat/copy-as-markdown.ts","../../../ui/src/ui/tool-display.ts","../../../ui/src/ui/chat/constants.ts","../../../ui/src/ui/chat/tool-helpers.ts","../../../ui/src/ui/chat/tool-cards.ts","../../../ui/src/ui/chat/grouped-render.ts","../../../ui/src/ui/views/markdown-sidebar.ts","../../../ui/src/ui/components/resizable-divider.ts","../../../ui/src/ui/views/chat.ts","../../../ui/src/ui/views/config-form.shared.ts","../../../ui/src/ui/views/config-form.node.ts","../../../ui/src/ui/views/config-form.render.ts","../../../ui/src/ui/views/config-form.analyze.ts","../../../ui/src/ui/views/config.ts","../../../ui/src/ui/views/channels.shared.ts","../../../ui/src/ui/views/channels.config.ts","../../../ui/src/ui/views/channels.discord.ts","../../../ui/src/ui/views/channels.googlechat.ts","../../../ui/src/ui/views/channels.imessage.ts","../../../ui/src/ui/views/channels.nostr-profile-form.ts","../../../ui/src/ui/views/channels.nostr.ts","../../../ui/src/ui/views/channels.signal.ts","../../../ui/src/ui/views/channels.slack.ts","../../../ui/src/ui/views/channels.telegram.ts","../../../ui/src/ui/views/channels.whatsapp.ts","../../../ui/src/ui/views/channels.ts","../../../ui/src/ui/presenter.ts","../../../ui/src/ui/views/cron.ts","../../../ui/src/ui/views/debug.ts","../../../ui/src/ui/views/instances.ts","../../../ui/src/ui/views/logs.ts","../../../ui/src/ui/views/nodes.ts","../../../ui/src/ui/views/overview.ts","../../../ui/src/ui/views/sessions.ts","../../../ui/src/ui/views/exec-approval.ts","../../../ui/src/ui/views/skills.ts","../../../ui/src/ui/app-render.helpers.ts","../../../ui/src/ui/app-render.ts","../../../ui/src/ui/app-defaults.ts","../../../ui/src/ui/controllers/agents.ts","../../../src/gateway/protocol/client-info.ts","../../../src/gateway/device-auth.ts","../../../ui/src/ui/gateway.ts","../../../ui/src/ui/controllers/exec-approval.ts","../../../ui/src/ui/controllers/assistant-identity.ts","../../../ui/src/ui/app-gateway.ts","../../../ui/src/ui/app-lifecycle.ts","../../../ui/src/ui/app-channels.ts","../../../ui/src/ui/app.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2019 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n */\nconst t=globalThis,e=t.ShadowRoot&&(void 0===t.ShadyCSS||t.ShadyCSS.nativeShadow)&&\"adoptedStyLine truncated
|
|
||||||
+3059
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1 @@
|
|||||||
|
{"version":3,"file":"index-DsXRcnEw.js","sources":["../../../node_modules/.pnpm/@lit+reactive-element@2.1.2/node_modules/@lit/reactive-element/css-tag.js","../../../node_modules/.pnpm/@lit+reactive-element@2.1.2/node_modules/@lit/reactive-element/reactive-element.js","../../../node_modules/.pnpm/lit-html@3.3.2/node_modules/lit-html/lit-html.js","../../../node_modules/.pnpm/lit-element@4.2.2/node_modules/lit-element/lit-element.js","../../../node_modules/.pnpm/@lit+reactive-element@2.1.2/node_modules/@lit/reactive-element/decorators/custom-element.js","../../../node_modules/.pnpm/@lit+reactive-element@2.1.2/node_modules/@lit/reactive-element/decorators/property.js","../../../node_modules/.pnpm/@lit+reactive-element@2.1.2/node_modules/@lit/reactive-element/decorators/state.js","../../../ui/src/ui/assistant-identity.ts","../../../ui/src/ui/storage.ts","../../../src/sessions/session-key-utils.ts","../../../ui/src/ui/navigation.ts","../../../ui/src/ui/format.ts","../../../ui/src/ui/chat/message-extract.ts","../../../ui/src/ui/uuid.ts","../../../ui/src/ui/controllers/chat.ts","../../../ui/src/ui/controllers/sessions.ts","../../../ui/src/ui/app-tool-stream.ts","../../../ui/src/ui/app-scroll.ts","../../../ui/src/ui/controllers/config/form-utils.ts","../../../ui/src/ui/controllers/config.ts","../../../ui/src/ui/controllers/cron.ts","../../../ui/src/ui/controllers/channels.ts","../../../ui/src/ui/controllers/debug.ts","../../../ui/src/ui/controllers/logs.ts","../../../node_modules/.pnpm/@noble+ed25519@3.0.0/node_modules/@noble/ed25519/index.js","../../../ui/src/ui/device-identity.ts","../../../ui/src/ui/device-auth.ts","../../../ui/src/ui/controllers/devices.ts","../../../ui/src/ui/controllers/nodes.ts","../../../ui/src/ui/controllers/exec-approvals.ts","../../../ui/src/ui/controllers/presence.ts","../../../ui/src/ui/controllers/skills.ts","../../../ui/src/ui/theme.ts","../../../ui/src/ui/theme-transition.ts","../../../ui/src/ui/app-polling.ts","../../../ui/src/ui/app-settings.ts","../../../ui/src/ui/app-chat.ts","../../../node_modules/.pnpm/lit-html@3.3.2/node_modules/lit-html/directive.js","../../../node_modules/.pnpm/lit-html@3.3.2/node_modules/lit-html/directive-helpers.js","../../../node_modules/.pnpm/lit-html@3.3.2/node_modules/lit-html/directives/repeat.js","../../../ui/src/ui/chat/message-normalizer.ts","../../../node_modules/.pnpm/lit-html@3.3.2/node_modules/lit-html/directives/unsafe-html.js","../../../node_modules/.pnpm/dompurify@3.3.1/node_modules/dompurify/dist/purify.es.mjs","../../../node_modules/.pnpm/marked@17.0.1/node_modules/marked/lib/marked.esm.js","../../../ui/src/ui/markdown.ts","../../../ui/src/ui/icons.ts","../../../ui/src/ui/chat/copy-as-markdown.ts","../../../ui/src/ui/tool-display.ts","../../../ui/src/ui/chat/constants.ts","../../../ui/src/ui/chat/tool-helpers.ts","../../../ui/src/ui/chat/tool-cards.ts","../../../ui/src/ui/chat/grouped-render.ts","../../../ui/src/ui/views/markdown-sidebar.ts","../../../ui/src/ui/components/resizable-divider.ts","../../../ui/src/ui/views/chat.ts","../../../ui/src/ui/views/config-form.shared.ts","../../../ui/src/ui/views/config-form.node.ts","../../../ui/src/ui/views/config-form.render.ts","../../../ui/src/ui/views/config-form.analyze.ts","../../../ui/src/ui/views/config.ts","../../../ui/src/ui/views/channels.shared.ts","../../../ui/src/ui/views/channels.config.ts","../../../ui/src/ui/views/channels.discord.ts","../../../ui/src/ui/views/channels.imessage.ts","../../../ui/src/ui/views/channels.nostr-profile-form.ts","../../../ui/src/ui/views/channels.nostr.ts","../../../ui/src/ui/views/channels.signal.ts","../../../ui/src/ui/views/channels.slack.ts","../../../ui/src/ui/views/channels.telegram.ts","../../../ui/src/ui/views/channels.whatsapp.ts","../../../ui/src/ui/views/channels.ts","../../../ui/src/ui/presenter.ts","../../../ui/src/ui/views/cron.ts","../../../ui/src/ui/views/debug.ts","../../../ui/src/ui/views/instances.ts","../../../ui/src/ui/views/logs.ts","../../../ui/src/ui/views/nodes.ts","../../../ui/src/ui/views/overview.ts","../../../ui/src/ui/views/sessions.ts","../../../ui/src/ui/views/exec-approval.ts","../../../ui/src/ui/views/skills.ts","../../../ui/src/ui/app-render.helpers.ts","../../../ui/src/ui/app-render.ts","../../../ui/src/ui/app-defaults.ts","../../../ui/src/ui/controllers/agents.ts","../../../src/gateway/protocol/client-info.ts","../../../src/gateway/device-auth.ts","../../../ui/src/ui/gateway.ts","../../../ui/src/ui/controllers/exec-approval.ts","../../../ui/src/ui/controllers/assistant-identity.ts","../../../ui/src/ui/app-gateway.ts","../../../ui/src/ui/app-lifecycle.ts","../../../ui/src/ui/app-channels.ts","../../../ui/src/ui/app.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2019 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n */\nconst t=globalThis,e=t.ShadowRoot&&(void 0===t.ShadyCSS||t.ShadyCSS.nativeShadow)&&\"adoptedStyleSheets\"in Document.prototype&&\"replace\"in CSSStyleSheet.prototype,s=Symbol(),o=new WeakMapLine truncated
|
||||||
+3047
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1 @@
|
|||||||
|
{"version":3,"file":"index-bYQnHP3a.js","sources":["../../../node_modules/.pnpm/@lit+reactive-element@2.1.2/node_modules/@lit/reactive-element/css-tag.js","../../../node_modules/.pnpm/@lit+reactive-element@2.1.2/node_modules/@lit/reactive-element/reactive-element.js","../../../node_modules/.pnpm/lit-html@3.3.2/node_modules/lit-html/lit-html.js","../../../node_modules/.pnpm/lit-element@4.2.2/node_modules/lit-element/lit-element.js","../../../node_modules/.pnpm/@lit+reactive-element@2.1.2/node_modules/@lit/reactive-element/decorators/custom-element.js","../../../node_modules/.pnpm/@lit+reactive-element@2.1.2/node_modules/@lit/reactive-element/decorators/property.js","../../../node_modules/.pnpm/@lit+reactive-element@2.1.2/node_modules/@lit/reactive-element/decorators/state.js","../../../ui/src/ui/assistant-identity.ts","../../../ui/src/ui/storage.ts","../../../src/sessions/session-key-utils.ts","../../../ui/src/ui/navigation.ts","../../../ui/src/ui/format.ts","../../../ui/src/ui/chat/message-extract.ts","../../../ui/src/ui/uuid.ts","../../../ui/src/ui/controllers/chat.ts","../../../ui/src/ui/controllers/sessions.ts","../../../ui/src/ui/app-tool-stream.ts","../../../ui/src/ui/app-scroll.ts","../../../ui/src/ui/controllers/config/form-utils.ts","../../../ui/src/ui/controllers/config.ts","../../../ui/src/ui/controllers/cron.ts","../../../ui/src/ui/controllers/channels.ts","../../../ui/src/ui/controllers/debug.ts","../../../ui/src/ui/controllers/logs.ts","../../../node_modules/.pnpm/@noble+ed25519@3.0.0/node_modules/@noble/ed25519/index.js","../../../ui/src/ui/device-identity.ts","../../../ui/src/ui/device-auth.ts","../../../ui/src/ui/controllers/devices.ts","../../../ui/src/ui/controllers/nodes.ts","../../../ui/src/ui/controllers/exec-approvals.ts","../../../ui/src/ui/controllers/presence.ts","../../../ui/src/ui/controllers/skills.ts","../../../ui/src/ui/theme.ts","../../../ui/src/ui/theme-transition.ts","../../../ui/src/ui/app-polling.ts","../../../ui/src/ui/app-settings.ts","../../../ui/src/ui/app-chat.ts","../../../node_modules/.pnpm/lit-html@3.3.2/node_modules/lit-html/directive.js","../../../node_modules/.pnpm/lit-html@3.3.2/node_modules/lit-html/directive-helpers.js","../../../node_modules/.pnpm/lit-html@3.3.2/node_modules/lit-html/directives/repeat.js","../../../ui/src/ui/chat/message-normalizer.ts","../../../node_modules/.pnpm/lit-html@3.3.2/node_modules/lit-html/directives/unsafe-html.js","../../../node_modules/.pnpm/dompurify@3.3.1/node_modules/dompurify/dist/purify.es.mjs","../../../node_modules/.pnpm/marked@17.0.1/node_modules/marked/lib/marked.esm.js","../../../ui/src/ui/markdown.ts","../../../ui/src/ui/icons.ts","../../../ui/src/ui/chat/copy-as-markdown.ts","../../../ui/src/ui/tool-display.ts","../../../ui/src/ui/chat/constants.ts","../../../ui/src/ui/chat/tool-helpers.ts","../../../ui/src/ui/chat/tool-cards.ts","../../../ui/src/ui/chat/grouped-render.ts","../../../ui/src/ui/views/markdown-sidebar.ts","../../../ui/src/ui/components/resizable-divider.ts","../../../ui/src/ui/views/chat.ts","../../../ui/src/ui/views/config-form.shared.ts","../../../ui/src/ui/views/config-form.node.ts","../../../ui/src/ui/views/config-form.render.ts","../../../ui/src/ui/views/config-form.analyze.ts","../../../ui/src/ui/views/config.ts","../../../ui/src/ui/views/channels.shared.ts","../../../ui/src/ui/views/channels.config.ts","../../../ui/src/ui/views/channels.discord.ts","../../../ui/src/ui/views/channels.imessage.ts","../../../ui/src/ui/views/channels.nostr-profile-form.ts","../../../ui/src/ui/views/channels.nostr.ts","../../../ui/src/ui/views/channels.signal.ts","../../../ui/src/ui/views/channels.slack.ts","../../../ui/src/ui/views/channels.telegram.ts","../../../ui/src/ui/views/channels.whatsapp.ts","../../../ui/src/ui/views/channels.ts","../../../ui/src/ui/presenter.ts","../../../ui/src/ui/views/cron.ts","../../../ui/src/ui/views/debug.ts","../../../ui/src/ui/views/instances.ts","../../../ui/src/ui/views/logs.ts","../../../ui/src/ui/views/nodes.ts","../../../ui/src/ui/views/overview.ts","../../../ui/src/ui/views/sessions.ts","../../../ui/src/ui/views/exec-approval.ts","../../../ui/src/ui/views/skills.ts","../../../ui/src/ui/app-render.helpers.ts","../../../ui/src/ui/app-render.ts","../../../ui/src/ui/app-defaults.ts","../../../ui/src/ui/controllers/agents.ts","../../../src/gateway/protocol/client-info.ts","../../../src/gateway/device-auth.ts","../../../ui/src/ui/gateway.ts","../../../ui/src/ui/controllers/exec-approval.ts","../../../ui/src/ui/controllers/assistant-identity.ts","../../../ui/src/ui/app-gateway.ts","../../../ui/src/ui/app-lifecycle.ts","../../../ui/src/ui/app-channels.ts","../../../ui/src/ui/app.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2019 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n */\nconst t=globalThis,e=t.ShadowRoot&&(void 0===t.ShadyCSS||t.ShadyCSS.nativeShadow)&&\"adoptedStyleSheets\"in Document.prototype&&\"replace\"in CSSStyleSheet.prototype,s=Symbol(),o=new WeakMapLine truncated
|
||||||
Vendored
+2
-2
@@ -6,8 +6,8 @@
|
|||||||
<title>Clawdbot Control</title>
|
<title>Clawdbot Control</title>
|
||||||
<meta name="color-scheme" content="dark light" />
|
<meta name="color-scheme" content="dark light" />
|
||||||
<link rel="icon" href="./favicon.ico" sizes="any" />
|
<link rel="icon" href="./favicon.ico" sizes="any" />
|
||||||
<script type="module" crossorigin src="./assets/index-DQcOTEYz.js"></script>
|
<script type="module" crossorigin src="./assets/index-DsXRcnEw.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="./assets/index-08nzABV3.css">
|
<link rel="stylesheet" crossorigin href="./assets/index-BvhR9FCb.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<clawdbot-app></clawdbot-app>
|
<clawdbot-app></clawdbot-app>
|
||||||
|
|||||||
@@ -213,7 +213,6 @@ 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.
|
||||||
|
|||||||
@@ -1,89 +0,0 @@
|
|||||||
---
|
|
||||||
summary: "Diagnostics flags for targeted debug logs"
|
|
||||||
read_when:
|
|
||||||
- You need targeted debug logs without raising global logging levels
|
|
||||||
- You need to capture subsystem-specific logs for support
|
|
||||||
---
|
|
||||||
# Diagnostics Flags
|
|
||||||
|
|
||||||
Diagnostics flags let you enable targeted debug logs without turning on verbose logging everywhere. Flags are opt-in and have no effect unless a subsystem checks them.
|
|
||||||
|
|
||||||
## How it works
|
|
||||||
|
|
||||||
- Flags are strings (case-insensitive).
|
|
||||||
- You can enable flags in config or via an env override.
|
|
||||||
- Wildcards are supported:
|
|
||||||
- `telegram.*` matches `telegram.http`
|
|
||||||
- `*` enables all flags
|
|
||||||
|
|
||||||
## Enable via config
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"diagnostics": {
|
|
||||||
"flags": ["telegram.http"]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Multiple flags:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"diagnostics": {
|
|
||||||
"flags": ["telegram.http", "gateway.*"]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Restart the gateway after changing flags.
|
|
||||||
|
|
||||||
## Env override (one-off)
|
|
||||||
|
|
||||||
```bash
|
|
||||||
CLAWDBOT_DIAGNOSTICS=telegram.http,telegram.payload
|
|
||||||
```
|
|
||||||
|
|
||||||
Disable all flags:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
CLAWDBOT_DIAGNOSTICS=0
|
|
||||||
```
|
|
||||||
|
|
||||||
## Where logs go
|
|
||||||
|
|
||||||
Flags emit logs into the standard diagnostics log file. By default:
|
|
||||||
|
|
||||||
```
|
|
||||||
/tmp/clawdbot/clawdbot-YYYY-MM-DD.log
|
|
||||||
```
|
|
||||||
|
|
||||||
If you set `logging.file`, use that path instead. Logs are JSONL (one JSON object per line). Redaction still applies based on `logging.redactSensitive`.
|
|
||||||
|
|
||||||
## Extract logs
|
|
||||||
|
|
||||||
Pick the latest log file:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
ls -t /tmp/clawdbot/clawdbot-*.log | head -n 1
|
|
||||||
```
|
|
||||||
|
|
||||||
Filter for Telegram HTTP diagnostics:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
rg "telegram http error" /tmp/clawdbot/clawdbot-*.log
|
|
||||||
```
|
|
||||||
|
|
||||||
Or tail while reproducing:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
tail -f /tmp/clawdbot/clawdbot-$(date +%F).log | rg "telegram http error"
|
|
||||||
```
|
|
||||||
|
|
||||||
For remote gateways, you can also use `clawdbot logs --follow` (see [/cli/logs](/cli/logs)).
|
|
||||||
|
|
||||||
## Notes
|
|
||||||
|
|
||||||
- If `logging.level` is set higher than `warn`, these logs may be suppressed. Default `info` is fine.
|
|
||||||
- Flags are safe to leave enabled; they only affect log volume for the specific subsystem.
|
|
||||||
- Use [/logging](/logging) to change log destinations, levels, and redaction.
|
|
||||||
@@ -2847,9 +2847,8 @@ Control UI base path:
|
|||||||
- `gateway.controlUi.basePath` sets the URL prefix where the Control UI is served.
|
- `gateway.controlUi.basePath` sets the URL prefix where the Control UI is served.
|
||||||
- Examples: `"/ui"`, `"/clawdbot"`, `"/apps/clawdbot"`.
|
- Examples: `"/ui"`, `"/clawdbot"`, `"/apps/clawdbot"`.
|
||||||
- Default: root (`/`) (unchanged).
|
- Default: root (`/`) (unchanged).
|
||||||
- `gateway.controlUi.allowInsecureAuth` allows token-only auth for the Control UI and skips
|
- `gateway.controlUi.allowInsecureAuth` allows token-only auth over **HTTP** (no device identity).
|
||||||
device identity + pairing (even on HTTPS). Default: `false`. Prefer HTTPS
|
Default: `false`. Prefer HTTPS (Tailscale Serve) or `127.0.0.1`.
|
||||||
(Tailscale Serve) or `127.0.0.1`.
|
|
||||||
|
|
||||||
Related docs:
|
Related docs:
|
||||||
- [Control UI](/web/control-ui)
|
- [Control UI](/web/control-ui)
|
||||||
|
|||||||
@@ -58,7 +58,7 @@ When the audit prints findings, treat this as a priority order:
|
|||||||
|
|
||||||
The Control UI needs a **secure context** (HTTPS or localhost) to generate device
|
The Control UI needs a **secure context** (HTTPS or localhost) to generate device
|
||||||
identity. If you enable `gateway.controlUi.allowInsecureAuth`, the UI falls back
|
identity. If you enable `gateway.controlUi.allowInsecureAuth`, the UI falls back
|
||||||
to **token-only auth** and skips device pairing (even on HTTPS). This is a security
|
to **token-only auth** on plain HTTP and skips device pairing. This is a security
|
||||||
downgrade—prefer HTTPS (Tailscale Serve) or open the UI on `127.0.0.1`.
|
downgrade—prefer HTTPS (Tailscale Serve) or open the UI on `127.0.0.1`.
|
||||||
|
|
||||||
`clawdbot security audit` warns when this setting is enabled.
|
`clawdbot security audit` warns when this setting is enabled.
|
||||||
|
|||||||
@@ -138,7 +138,6 @@ Quick answers plus deeper troubleshooting for real-world setups (local dev, VPS,
|
|||||||
- [Can I use self-hosted models (llama.cpp, vLLM, Ollama)?](#can-i-use-selfhosted-models-llamacpp-vllm-ollama)
|
- [Can I use self-hosted models (llama.cpp, vLLM, Ollama)?](#can-i-use-selfhosted-models-llamacpp-vllm-ollama)
|
||||||
- [What do Clawd, Flawd, and Krill use for models?](#what-do-clawd-flawd-and-krill-use-for-models)
|
- [What do Clawd, Flawd, and Krill use for models?](#what-do-clawd-flawd-and-krill-use-for-models)
|
||||||
- [How do I switch models on the fly (without restarting)?](#how-do-i-switch-models-on-the-fly-without-restarting)
|
- [How do I switch models on the fly (without restarting)?](#how-do-i-switch-models-on-the-fly-without-restarting)
|
||||||
- [Can I use GPT 5.2 for daily tasks and Codex 5.2 for coding](#can-i-use-gpt-52-for-daily-tasks-and-codex-52-for-coding)
|
|
||||||
- [Why do I see “Model … is not allowed” and then no reply?](#why-do-i-see-model-is-not-allowed-and-then-no-reply)
|
- [Why do I see “Model … is not allowed” and then no reply?](#why-do-i-see-model-is-not-allowed-and-then-no-reply)
|
||||||
- [Why do I see “Unknown model: minimax/MiniMax-M2.1”?](#why-do-i-see-unknown-model-minimaxminimaxm21)
|
- [Why do I see “Unknown model: minimax/MiniMax-M2.1”?](#why-do-i-see-unknown-model-minimaxminimaxm21)
|
||||||
- [Can I use MiniMax as my default and OpenAI for complex tasks?](#can-i-use-minimax-as-my-default-and-openai-for-complex-tasks)
|
- [Can I use MiniMax as my default and OpenAI for complex tasks?](#can-i-use-minimax-as-my-default-and-openai-for-complex-tasks)
|
||||||
@@ -1948,16 +1947,6 @@ Re-run `/model` **without** the `@profile` suffix:
|
|||||||
If you want to return to the default, pick it from `/model` (or send `/model <default provider/model>`).
|
If you want to return to the default, pick it from `/model` (or send `/model <default provider/model>`).
|
||||||
Use `/model status` to confirm which auth profile is active.
|
Use `/model status` to confirm which auth profile is active.
|
||||||
|
|
||||||
### Can I use GPT 5.2 for daily tasks and Codex 5.2 for coding
|
|
||||||
|
|
||||||
Yes. Set one as default and switch as needed:
|
|
||||||
|
|
||||||
- **Quick switch (per session):** `/model gpt-5.2` for daily tasks, `/model gpt-5.2-codex` for coding.
|
|
||||||
- **Default + switch:** set `agents.defaults.model.primary` to `openai-codex/gpt-5.2`, then switch to `openai-codex/gpt-5.2-codex` when coding (or the other way around).
|
|
||||||
- **Sub-agents:** route coding tasks to sub-agents with a different default model.
|
|
||||||
|
|
||||||
See [Models](/concepts/models) and [Slash commands](/tools/slash-commands).
|
|
||||||
|
|
||||||
### Why do I see Model is not allowed and then no reply
|
### Why do I see Model is not allowed and then no reply
|
||||||
|
|
||||||
If `agents.defaults.models` is set, it becomes the **allowlist** for `/model` and any
|
If `agents.defaults.models` is set, it becomes the **allowlist** for `/model` and any
|
||||||
|
|||||||
@@ -192,30 +192,6 @@ Use this if you want diagnostics events available to plugins or custom sinks:
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
### Diagnostics flags (targeted logs)
|
|
||||||
|
|
||||||
Use flags to turn on extra, targeted debug logs without raising `logging.level`.
|
|
||||||
Flags are case-insensitive and support wildcards (e.g. `telegram.*` or `*`).
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"diagnostics": {
|
|
||||||
"flags": ["telegram.http"]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Env override (one-off):
|
|
||||||
|
|
||||||
```
|
|
||||||
CLAWDBOT_DIAGNOSTICS=telegram.http,telegram.payload
|
|
||||||
```
|
|
||||||
|
|
||||||
Notes:
|
|
||||||
- Flag logs go to the standard log file (same as `logging.file`).
|
|
||||||
- Output is still redacted according to `logging.redactSensitive`.
|
|
||||||
- Full guide: [/diagnostics/flags](/diagnostics/flags).
|
|
||||||
|
|
||||||
### Export to OpenTelemetry
|
### Export to OpenTelemetry
|
||||||
|
|
||||||
Diagnostics can be exported via the `diagnostics-otel` plugin (OTLP/HTTP). This
|
Diagnostics can be exported via the `diagnostics-otel` plugin (OTLP/HTTP). This
|
||||||
|
|||||||
+5
-19
@@ -80,7 +80,6 @@ primary_region = "iad"
|
|||||||
|---------|-----|
|
|---------|-----|
|
||||||
| `--bind lan` | Binds to `0.0.0.0` so Fly's proxy can reach the gateway |
|
| `--bind lan` | Binds to `0.0.0.0` so Fly's proxy can reach the gateway |
|
||||||
| `--allow-unconfigured` | Starts without a config file (you'll create one after) |
|
| `--allow-unconfigured` | Starts without a config file (you'll create one after) |
|
||||||
| `internal_port = 3000` | Must match `--port 3000` (or `CLAWDBOT_GATEWAY_PORT`) for Fly health checks |
|
|
||||||
| `memory = "2048mb"` | 512MB is too small; 2GB recommended |
|
| `memory = "2048mb"` | 512MB is too small; 2GB recommended |
|
||||||
| `CLAWDBOT_STATE_DIR = "/data"` | Persists state on the volume |
|
| `CLAWDBOT_STATE_DIR = "/data"` | Persists state on the volume |
|
||||||
|
|
||||||
@@ -236,12 +235,6 @@ The gateway is binding to `127.0.0.1` instead of `0.0.0.0`.
|
|||||||
|
|
||||||
**Fix:** Add `--bind lan` to your process command in `fly.toml`.
|
**Fix:** Add `--bind lan` to your process command in `fly.toml`.
|
||||||
|
|
||||||
### Health checks failing / connection refused
|
|
||||||
|
|
||||||
Fly can't reach the gateway on the configured port.
|
|
||||||
|
|
||||||
**Fix:** Ensure `internal_port` matches the gateway port (set `--port 3000` or `CLAWDBOT_GATEWAY_PORT=3000`).
|
|
||||||
|
|
||||||
### OOM / Memory Issues
|
### OOM / Memory Issues
|
||||||
|
|
||||||
Container keeps restarting or getting killed. Signs: `SIGABRT`, `v8::internal::Runtime_AllocateInYoungGeneration`, or silent restarts.
|
Container keeps restarting or getting killed. Signs: `SIGABRT`, `v8::internal::Runtime_AllocateInYoungGeneration`, or silent restarts.
|
||||||
@@ -275,11 +268,11 @@ The lock file is at `/data/gateway.*.lock` (not in a subdirectory).
|
|||||||
|
|
||||||
### Config Not Being Read
|
### Config Not Being Read
|
||||||
|
|
||||||
If using `--allow-unconfigured`, the gateway creates a minimal config. Your custom config at `/data/clawdbot.json` should be read on restart.
|
If using `--allow-unconfigured`, the gateway creates a minimal config. Your custom config at `/data/.clawdbot/clawdbot.json` should be read on restart.
|
||||||
|
|
||||||
Verify the config exists:
|
Verify the config exists:
|
||||||
```bash
|
```bash
|
||||||
fly ssh console --command "cat /data/clawdbot.json"
|
fly ssh console --command "cat /data/.clawdbot/clawdbot.json"
|
||||||
```
|
```
|
||||||
|
|
||||||
### Writing Config via SSH
|
### Writing Config via SSH
|
||||||
@@ -288,24 +281,18 @@ The `fly ssh console -C` command doesn't support shell redirection. To write a c
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Use echo + tee (pipe from local to remote)
|
# Use echo + tee (pipe from local to remote)
|
||||||
echo '{"your":"config"}' | fly ssh console -C "tee /data/clawdbot.json"
|
echo '{"your":"config"}' | fly ssh console -C "tee /data/.clawdbot/clawdbot.json"
|
||||||
|
|
||||||
# Or use sftp
|
# Or use sftp
|
||||||
fly sftp shell
|
fly sftp shell
|
||||||
> put /local/path/config.json /data/clawdbot.json
|
> put /local/path/config.json /data/.clawdbot/clawdbot.json
|
||||||
```
|
```
|
||||||
|
|
||||||
**Note:** `fly sftp` may fail if the file already exists. Delete first:
|
**Note:** `fly sftp` may fail if the file already exists. Delete first:
|
||||||
```bash
|
```bash
|
||||||
fly ssh console --command "rm /data/clawdbot.json"
|
fly ssh console --command "rm /data/.clawdbot/clawdbot.json"
|
||||||
```
|
```
|
||||||
|
|
||||||
### State Not Persisting
|
|
||||||
|
|
||||||
If you lose credentials or sessions after a restart, the state dir is writing to the container filesystem.
|
|
||||||
|
|
||||||
**Fix:** Ensure `CLAWDBOT_STATE_DIR=/data` is set in `fly.toml` and redeploy.
|
|
||||||
|
|
||||||
## Updates
|
## Updates
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -343,7 +330,6 @@ fly machine update <machine-id> --vm-memory 2048 --command "node dist/index.js g
|
|||||||
- The Dockerfile is compatible with both architectures
|
- The Dockerfile is compatible with both architectures
|
||||||
- For WhatsApp/Telegram onboarding, use `fly ssh console`
|
- For WhatsApp/Telegram onboarding, use `fly ssh console`
|
||||||
- Persistent data lives on the volume at `/data`
|
- Persistent data lives on the volume at `/data`
|
||||||
- Signal requires Java + signal-cli; use a custom image and keep memory at 2GB+.
|
|
||||||
|
|
||||||
## Cost
|
## Cost
|
||||||
|
|
||||||
|
|||||||
@@ -1,41 +0,0 @@
|
|||||||
# Creating Custom Skills 🛠
|
|
||||||
|
|
||||||
Clawdbot is designed to be easily extensible. "Skills" are the primary way to add new capabilities to your assistant.
|
|
||||||
|
|
||||||
## What is a Skill?
|
|
||||||
A skill is a directory containing a `SKILL.md` file (which provides instructions and tool definitions to the LLM) and optionally some scripts or resources.
|
|
||||||
|
|
||||||
## Step-by-Step: Your First Skill
|
|
||||||
|
|
||||||
### 1. Create the Directory
|
|
||||||
Skills live in your workspace, usually `~/clawd/skills/`. Create a new folder for your skill:
|
|
||||||
```bash
|
|
||||||
mkdir -p ~/clawd/skills/hello-world
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. Define the `SKILL.md`
|
|
||||||
Create a `SKILL.md` file in that directory. This file uses YAML frontmatter for metadata and Markdown for instructions.
|
|
||||||
|
|
||||||
```markdown
|
|
||||||
---
|
|
||||||
name: hello_world
|
|
||||||
description: A simple skill that says hello.
|
|
||||||
---
|
|
||||||
|
|
||||||
# Hello World Skill
|
|
||||||
When the user asks for a greeting, use the `echo` tool to say "Hello from your custom skill!".
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3. Add Tools (Optional)
|
|
||||||
You can define custom tools in the frontmatter or instruct the agent to use existing system tools (like `bash` or `browser`).
|
|
||||||
|
|
||||||
### 4. Refresh Clawdbot
|
|
||||||
Ask your agent to "refresh skills" or restart the gateway. Clawdbot will discover the new directory and index the `SKILL.md`.
|
|
||||||
|
|
||||||
## Best Practices
|
|
||||||
- **Be Concise**: Instruct the model on *what* to do, not how to be an AI.
|
|
||||||
- **Safety First**: If your skill uses `bash`, ensure the prompts don't allow arbitrary command injection from untrusted user input.
|
|
||||||
- **Test Locally**: Use `clawdbot agent --message "use my new skill"` to test.
|
|
||||||
|
|
||||||
## Shared Skills
|
|
||||||
You can also browse and contribute skills to [ClawdHub](https://clawdhub.com).
|
|
||||||
@@ -108,8 +108,8 @@ Clawdbot **blocks** Control UI connections without device identity.
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
This disables device identity + pairing for the Control UI (even on HTTPS). Use
|
This disables device identity + pairing for the Control UI. Use only if you
|
||||||
only if you trust the network.
|
trust the network.
|
||||||
|
|
||||||
See [Tailscale](/gateway/tailscale) for HTTPS setup guidance.
|
See [Tailscale](/gateway/tailscale) for HTTPS setup guidance.
|
||||||
|
|
||||||
|
|||||||
@@ -385,14 +385,14 @@ describe("send", () => {
|
|||||||
).rejects.toThrow("password is required");
|
).rejects.toThrow("password is required");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("throws when chatGuid cannot be resolved for non-handle targets", async () => {
|
it("throws when chatGuid cannot be resolved", async () => {
|
||||||
mockFetch.mockResolvedValue({
|
mockFetch.mockResolvedValue({
|
||||||
ok: true,
|
ok: true,
|
||||||
json: () => Promise.resolve({ data: [] }),
|
json: () => Promise.resolve({ data: [] }),
|
||||||
});
|
});
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
sendMessageBlueBubbles("chat_id:999", "Hello", {
|
sendMessageBlueBubbles("chat_id:123", "Hello", {
|
||||||
serverUrl: "http://localhost:1234",
|
serverUrl: "http://localhost:1234",
|
||||||
password: "test",
|
password: "test",
|
||||||
}),
|
}),
|
||||||
@@ -439,57 +439,6 @@ describe("send", () => {
|
|||||||
expect(body.method).toBeUndefined();
|
expect(body.method).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("creates a new chat when handle target is missing", async () => {
|
|
||||||
mockFetch
|
|
||||||
.mockResolvedValueOnce({
|
|
||||||
ok: true,
|
|
||||||
json: () => Promise.resolve({ data: [] }),
|
|
||||||
})
|
|
||||||
.mockResolvedValueOnce({
|
|
||||||
ok: true,
|
|
||||||
text: () =>
|
|
||||||
Promise.resolve(
|
|
||||||
JSON.stringify({
|
|
||||||
data: { guid: "new-msg-guid" },
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = await sendMessageBlueBubbles("+15550009999", "Hello new chat", {
|
|
||||||
serverUrl: "http://localhost:1234",
|
|
||||||
password: "test",
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result.messageId).toBe("new-msg-guid");
|
|
||||||
expect(mockFetch).toHaveBeenCalledTimes(2);
|
|
||||||
|
|
||||||
const createCall = mockFetch.mock.calls[1];
|
|
||||||
expect(createCall[0]).toContain("/api/v1/chat/new");
|
|
||||||
const body = JSON.parse(createCall[1].body);
|
|
||||||
expect(body.addresses).toEqual(["+15550009999"]);
|
|
||||||
expect(body.message).toBe("Hello new chat");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("throws when creating a new chat requires Private API", async () => {
|
|
||||||
mockFetch
|
|
||||||
.mockResolvedValueOnce({
|
|
||||||
ok: true,
|
|
||||||
json: () => Promise.resolve({ data: [] }),
|
|
||||||
})
|
|
||||||
.mockResolvedValueOnce({
|
|
||||||
ok: false,
|
|
||||||
status: 403,
|
|
||||||
text: () => Promise.resolve("Private API not enabled"),
|
|
||||||
});
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
sendMessageBlueBubbles("+15550008888", "Hello", {
|
|
||||||
serverUrl: "http://localhost:1234",
|
|
||||||
password: "test",
|
|
||||||
}),
|
|
||||||
).rejects.toThrow("Private API must be enabled");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("uses private-api when reply metadata is present", async () => {
|
it("uses private-api when reply metadata is present", async () => {
|
||||||
mockFetch
|
mockFetch
|
||||||
.mockResolvedValueOnce({
|
.mockResolvedValueOnce({
|
||||||
|
|||||||
@@ -18,18 +18,7 @@ Restart the Gateway after enabling.
|
|||||||
clawdbot models auth login --provider google-gemini-cli --set-default
|
clawdbot models auth login --provider google-gemini-cli --set-default
|
||||||
```
|
```
|
||||||
|
|
||||||
## Requirements
|
## Env vars
|
||||||
|
|
||||||
If the Gemini CLI is installed, Clawdbot extracts the OAuth client ID automatically.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
brew install gemini-cli
|
|
||||||
# or: npm install -g @google/gemini-cli
|
|
||||||
```
|
|
||||||
|
|
||||||
## Env vars (optional)
|
|
||||||
|
|
||||||
Override auto-detected credentials with:
|
|
||||||
|
|
||||||
- `CLAWDBOT_GEMINI_OAUTH_CLIENT_ID` / `GEMINI_CLI_OAUTH_CLIENT_ID`
|
- `CLAWDBOT_GEMINI_OAUTH_CLIENT_ID` / `GEMINI_CLI_OAUTH_CLIENT_ID`
|
||||||
- `CLAWDBOT_GEMINI_OAUTH_CLIENT_SECRET` / `GEMINI_CLI_OAUTH_CLIENT_SECRET`
|
- `CLAWDBOT_GEMINI_OAUTH_CLIENT_SECRET` / `GEMINI_CLI_OAUTH_CLIENT_SECRET`
|
||||||
@@ -1,205 +0,0 @@
|
|||||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
||||||
import { join } from "node:path";
|
|
||||||
|
|
||||||
// Mock fs module before importing the module under test
|
|
||||||
const mockExistsSync = vi.fn();
|
|
||||||
const mockReadFileSync = vi.fn();
|
|
||||||
const mockRealpathSync = vi.fn();
|
|
||||||
const mockReaddirSync = vi.fn();
|
|
||||||
|
|
||||||
vi.mock("node:fs", async (importOriginal) => {
|
|
||||||
const actual = await importOriginal<typeof import("node:fs")>();
|
|
||||||
return {
|
|
||||||
...actual,
|
|
||||||
existsSync: (...args: Parameters<typeof actual.existsSync>) => mockExistsSync(...args),
|
|
||||||
readFileSync: (...args: Parameters<typeof actual.readFileSync>) => mockReadFileSync(...args),
|
|
||||||
realpathSync: (...args: Parameters<typeof actual.realpathSync>) => mockRealpathSync(...args),
|
|
||||||
readdirSync: (...args: Parameters<typeof actual.readdirSync>) => mockReaddirSync(...args),
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("extractGeminiCliCredentials", () => {
|
|
||||||
const FAKE_CLIENT_ID = "123456789-abcdef.apps.googleusercontent.com";
|
|
||||||
const FAKE_CLIENT_SECRET = "GOCSPX-FakeSecretValue123";
|
|
||||||
const FAKE_OAUTH2_CONTENT = `
|
|
||||||
const clientId = "${FAKE_CLIENT_ID}";
|
|
||||||
const clientSecret = "${FAKE_CLIENT_SECRET}";
|
|
||||||
`;
|
|
||||||
const FAKE_OAUTH2_ID_ONLY = `
|
|
||||||
const clientId = "${FAKE_CLIENT_ID}";
|
|
||||||
`;
|
|
||||||
|
|
||||||
let originalPath: string | undefined;
|
|
||||||
|
|
||||||
beforeEach(async () => {
|
|
||||||
vi.resetModules();
|
|
||||||
vi.clearAllMocks();
|
|
||||||
originalPath = process.env.PATH;
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
process.env.PATH = originalPath;
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns null when gemini binary is not in PATH", async () => {
|
|
||||||
process.env.PATH = "/nonexistent";
|
|
||||||
mockExistsSync.mockReturnValue(false);
|
|
||||||
|
|
||||||
const { extractGeminiCliCredentials, clearCredentialsCache } = await import("./oauth.js");
|
|
||||||
clearCredentialsCache();
|
|
||||||
expect(extractGeminiCliCredentials()).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("extracts credentials from oauth2.js in known path", async () => {
|
|
||||||
const fakeBinDir = "/fake/bin";
|
|
||||||
const fakeGeminiPath = join(fakeBinDir, "gemini");
|
|
||||||
const fakeCliRoot = "/fake/lib/node_modules/@google/gemini-cli";
|
|
||||||
const fakeResolvedPath = `${fakeCliRoot}/dist/index.js`;
|
|
||||||
const fakeOauth2Path = `${fakeCliRoot}/node_modules/@google/gemini-cli-core/dist/src/code_assist/oauth2.js`;
|
|
||||||
const fakeShimContent = `node "${fakeResolvedPath}"`;
|
|
||||||
|
|
||||||
process.env.PATH = fakeBinDir;
|
|
||||||
|
|
||||||
mockExistsSync.mockImplementation((p: string) => {
|
|
||||||
if (p === fakeGeminiPath) return true;
|
|
||||||
if (p === fakeCliRoot) return true;
|
|
||||||
if (p === fakeOauth2Path) return true;
|
|
||||||
return false;
|
|
||||||
});
|
|
||||||
mockRealpathSync.mockReturnValue(fakeResolvedPath);
|
|
||||||
mockReadFileSync.mockImplementation((p: string) => {
|
|
||||||
if (p === fakeGeminiPath || p === fakeResolvedPath) return fakeShimContent;
|
|
||||||
if (p === fakeOauth2Path) return FAKE_OAUTH2_CONTENT;
|
|
||||||
return "";
|
|
||||||
});
|
|
||||||
|
|
||||||
const { extractGeminiCliCredentials, clearCredentialsCache } = await import("./oauth.js");
|
|
||||||
clearCredentialsCache();
|
|
||||||
const result = extractGeminiCliCredentials();
|
|
||||||
|
|
||||||
expect(result).toEqual({
|
|
||||||
clientId: FAKE_CLIENT_ID,
|
|
||||||
clientSecret: FAKE_CLIENT_SECRET,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns null when oauth2.js cannot be found", async () => {
|
|
||||||
const fakeBinDir = "/fake/bin";
|
|
||||||
const fakeGeminiPath = join(fakeBinDir, "gemini");
|
|
||||||
const fakeCliRoot = "/fake/lib/node_modules/@google/gemini-cli";
|
|
||||||
const fakeResolvedPath = `${fakeCliRoot}/dist/index.js`;
|
|
||||||
const fakeShimContent = `node "${fakeResolvedPath}"`;
|
|
||||||
|
|
||||||
process.env.PATH = fakeBinDir;
|
|
||||||
|
|
||||||
mockExistsSync.mockImplementation((p: string) => p === fakeGeminiPath || p === fakeCliRoot);
|
|
||||||
mockRealpathSync.mockReturnValue(fakeResolvedPath);
|
|
||||||
mockReaddirSync.mockReturnValue([]); // Empty directory for recursive search
|
|
||||||
mockReadFileSync.mockImplementation((p: string) => {
|
|
||||||
if (p === fakeGeminiPath || p === fakeResolvedPath) return fakeShimContent;
|
|
||||||
return "";
|
|
||||||
});
|
|
||||||
|
|
||||||
const { extractGeminiCliCredentials, clearCredentialsCache } = await import("./oauth.js");
|
|
||||||
clearCredentialsCache();
|
|
||||||
expect(extractGeminiCliCredentials()).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns null when oauth2.js lacks credentials", async () => {
|
|
||||||
const fakeBinDir = "/fake/bin";
|
|
||||||
const fakeGeminiPath = join(fakeBinDir, "gemini");
|
|
||||||
const fakeCliRoot = "/fake/lib/node_modules/@google/gemini-cli";
|
|
||||||
const fakeResolvedPath = `${fakeCliRoot}/dist/index.js`;
|
|
||||||
const fakeOauth2Path = `${fakeCliRoot}/node_modules/@google/gemini-cli-core/dist/src/code_assist/oauth2.js`;
|
|
||||||
const fakeShimContent = `node "${fakeResolvedPath}"`;
|
|
||||||
|
|
||||||
process.env.PATH = fakeBinDir;
|
|
||||||
|
|
||||||
mockExistsSync.mockImplementation((p: string) => {
|
|
||||||
if (p === fakeGeminiPath) return true;
|
|
||||||
if (p === fakeCliRoot) return true;
|
|
||||||
if (p === fakeOauth2Path) return true;
|
|
||||||
return false;
|
|
||||||
});
|
|
||||||
mockRealpathSync.mockReturnValue(fakeResolvedPath);
|
|
||||||
mockReadFileSync.mockImplementation((p: string) => {
|
|
||||||
if (p === fakeGeminiPath || p === fakeResolvedPath) return fakeShimContent;
|
|
||||||
if (p === fakeOauth2Path) return "// no credentials here";
|
|
||||||
return "";
|
|
||||||
});
|
|
||||||
|
|
||||||
const { extractGeminiCliCredentials, clearCredentialsCache } = await import("./oauth.js");
|
|
||||||
clearCredentialsCache();
|
|
||||||
expect(extractGeminiCliCredentials()).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("caches credentials after first extraction", async () => {
|
|
||||||
const fakeBinDir = "/fake/bin";
|
|
||||||
const fakeGeminiPath = join(fakeBinDir, "gemini");
|
|
||||||
const fakeCliRoot = "/fake/lib/node_modules/@google/gemini-cli";
|
|
||||||
const fakeResolvedPath = `${fakeCliRoot}/dist/index.js`;
|
|
||||||
const fakeOauth2Path = `${fakeCliRoot}/node_modules/@google/gemini-cli-core/dist/src/code_assist/oauth2.js`;
|
|
||||||
const fakeShimContent = `node "${fakeResolvedPath}"`;
|
|
||||||
|
|
||||||
process.env.PATH = fakeBinDir;
|
|
||||||
|
|
||||||
mockExistsSync.mockImplementation((p: string) => {
|
|
||||||
if (p === fakeGeminiPath) return true;
|
|
||||||
if (p === fakeCliRoot) return true;
|
|
||||||
if (p === fakeOauth2Path) return true;
|
|
||||||
return false;
|
|
||||||
});
|
|
||||||
mockRealpathSync.mockReturnValue(fakeResolvedPath);
|
|
||||||
mockReadFileSync.mockImplementation((p: string) => {
|
|
||||||
if (p === fakeGeminiPath || p === fakeResolvedPath) return fakeShimContent;
|
|
||||||
if (p === fakeOauth2Path) return FAKE_OAUTH2_CONTENT;
|
|
||||||
return "";
|
|
||||||
});
|
|
||||||
|
|
||||||
const { extractGeminiCliCredentials, clearCredentialsCache } = await import("./oauth.js");
|
|
||||||
clearCredentialsCache();
|
|
||||||
|
|
||||||
// First call
|
|
||||||
const result1 = extractGeminiCliCredentials();
|
|
||||||
expect(result1).not.toBeNull();
|
|
||||||
|
|
||||||
// Second call should use cache (readFileSync not called again)
|
|
||||||
const readCount = mockReadFileSync.mock.calls.length;
|
|
||||||
const result2 = extractGeminiCliCredentials();
|
|
||||||
expect(result2).toEqual(result1);
|
|
||||||
expect(mockReadFileSync.mock.calls.length).toBe(readCount);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns client id when secret is missing", async () => {
|
|
||||||
const fakeBinDir = "/fake/bin";
|
|
||||||
const fakeGeminiPath = join(fakeBinDir, "gemini");
|
|
||||||
const fakeCliRoot = "/fake/lib/node_modules/@google/gemini-cli";
|
|
||||||
const fakeResolvedPath = `${fakeCliRoot}/dist/index.js`;
|
|
||||||
const fakeOauth2Path = `${fakeCliRoot}/node_modules/@google/gemini-cli-core/dist/src/code_assist/oauth2.js`;
|
|
||||||
const fakeShimContent = `node "${fakeResolvedPath}"`;
|
|
||||||
|
|
||||||
process.env.PATH = fakeBinDir;
|
|
||||||
|
|
||||||
mockExistsSync.mockImplementation((p: string) => {
|
|
||||||
if (p === fakeGeminiPath) return true;
|
|
||||||
if (p === fakeCliRoot) return true;
|
|
||||||
if (p === fakeOauth2Path) return true;
|
|
||||||
return false;
|
|
||||||
});
|
|
||||||
mockRealpathSync.mockReturnValue(fakeResolvedPath);
|
|
||||||
mockReadFileSync.mockImplementation((p: string) => {
|
|
||||||
if (p === fakeGeminiPath || p === fakeResolvedPath) return fakeShimContent;
|
|
||||||
if (p === fakeOauth2Path) return FAKE_OAUTH2_ID_ONLY;
|
|
||||||
return "";
|
|
||||||
});
|
|
||||||
|
|
||||||
const { extractGeminiCliCredentials, clearCredentialsCache } = await import("./oauth.js");
|
|
||||||
clearCredentialsCache();
|
|
||||||
const result = extractGeminiCliCredentials();
|
|
||||||
|
|
||||||
expect(result).toEqual({
|
|
||||||
clientId: FAKE_CLIENT_ID,
|
|
||||||
clientSecret: undefined,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
import { createHash, randomBytes } from "node:crypto";
|
import { createHash, randomBytes } from "node:crypto";
|
||||||
import { existsSync, readFileSync, readdirSync, realpathSync } from "node:fs";
|
import { readFileSync } from "node:fs";
|
||||||
import { createServer } from "node:http";
|
import { createServer } from "node:http";
|
||||||
import { delimiter, dirname, isAbsolute, join, normalize } from "node:path";
|
|
||||||
|
|
||||||
const CLIENT_ID_KEYS = ["CLAWDBOT_GEMINI_OAUTH_CLIENT_ID", "GEMINI_CLI_OAUTH_CLIENT_ID"];
|
const CLIENT_ID_KEYS = ["CLAWDBOT_GEMINI_OAUTH_CLIENT_ID", "GEMINI_CLI_OAUTH_CLIENT_ID"];
|
||||||
const CLIENT_SECRET_KEYS = [
|
const CLIENT_SECRET_KEYS = [
|
||||||
@@ -48,203 +47,15 @@ function resolveEnv(keys: string[]): string | undefined {
|
|||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
type GeminiCliClientConfig = { clientId: string; clientSecret?: string };
|
|
||||||
|
|
||||||
let cachedGeminiCliCredentials: GeminiCliClientConfig | null = null;
|
|
||||||
|
|
||||||
/** @internal */
|
|
||||||
export function clearCredentialsCache(): void {
|
|
||||||
cachedGeminiCliCredentials = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Extracts OAuth credentials from the installed Gemini CLI's bundled oauth2.js. */
|
|
||||||
export function extractGeminiCliCredentials(): GeminiCliClientConfig | null {
|
|
||||||
if (cachedGeminiCliCredentials) return cachedGeminiCliCredentials;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const oauthPath = findGeminiCliOAuthPath();
|
|
||||||
if (!oauthPath) return null;
|
|
||||||
|
|
||||||
const content = readFileSync(oauthPath, "utf8");
|
|
||||||
const idMatch = content.match(/(\d+-[a-z0-9]+\.apps\.googleusercontent\.com)/i);
|
|
||||||
const secretMatch = content.match(/(GOCSPX-[A-Za-z0-9_-]+)/);
|
|
||||||
if (idMatch) {
|
|
||||||
cachedGeminiCliCredentials = { clientId: idMatch[1], clientSecret: secretMatch?.[1] };
|
|
||||||
return cachedGeminiCliCredentials;
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// Gemini CLI not installed or extraction failed
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function findGeminiCliOAuthPath(): string | null {
|
|
||||||
const geminiPath = findInPath("gemini");
|
|
||||||
if (!geminiPath) return null;
|
|
||||||
|
|
||||||
for (const root of getGeminiCliRoots(geminiPath)) {
|
|
||||||
const searchPaths = [
|
|
||||||
join(root, "node_modules", "@google", "gemini-cli-core", "dist", "src", "code_assist", "oauth2.js"),
|
|
||||||
join(root, "node_modules", "@google", "gemini-cli-core", "dist", "code_assist", "oauth2.js"),
|
|
||||||
];
|
|
||||||
for (const p of searchPaths) {
|
|
||||||
if (existsSync(p)) return p;
|
|
||||||
}
|
|
||||||
if (shouldSearchRoot(root)) {
|
|
||||||
const found = findFile(root, "oauth2.js", 8);
|
|
||||||
if (found) return found;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getGeminiCliRoots(geminiPath: string): string[] {
|
|
||||||
const roots = new Set<string>();
|
|
||||||
const resolved = safeRealpath(geminiPath);
|
|
||||||
for (const p of [geminiPath, resolved].filter(Boolean) as string[]) {
|
|
||||||
for (const root of extractGeminiCliRootsFromFile(p)) {
|
|
||||||
roots.add(root);
|
|
||||||
}
|
|
||||||
const fromPath = extractGeminiCliRootFromPath(p);
|
|
||||||
if (fromPath) roots.add(fromPath);
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const globalRoot of getGlobalNodeModulesRoots()) {
|
|
||||||
const candidate = join(globalRoot, "@google", "gemini-cli");
|
|
||||||
if (existsSync(candidate)) roots.add(candidate);
|
|
||||||
}
|
|
||||||
|
|
||||||
return [...roots].filter((root) => existsSync(root));
|
|
||||||
}
|
|
||||||
|
|
||||||
function extractGeminiCliRootsFromFile(filePath: string): string[] {
|
|
||||||
const roots: string[] = [];
|
|
||||||
try {
|
|
||||||
const content = readFileSync(filePath, "utf8");
|
|
||||||
if (!content.includes("gemini-cli")) return roots;
|
|
||||||
|
|
||||||
const dir = dirname(filePath);
|
|
||||||
const regex = /["']([^"'\\r\\n]*@google[\\/]+gemini-cli[\\/]+dist[\\/]+index\\.js)["']/gi;
|
|
||||||
for (const match of content.matchAll(regex)) {
|
|
||||||
const resolved = resolveShimPath(dir, match[1]);
|
|
||||||
const root = extractGeminiCliRootFromPath(resolved);
|
|
||||||
if (root) roots.push(root);
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// ignore non-text files or missing paths
|
|
||||||
}
|
|
||||||
return roots;
|
|
||||||
}
|
|
||||||
|
|
||||||
function resolveShimPath(baseDir: string, rawPath: string): string {
|
|
||||||
let candidate = rawPath
|
|
||||||
.replace(/%~?dp0%/gi, baseDir)
|
|
||||||
.replace(/\$PSScriptRoot/gi, baseDir)
|
|
||||||
.replace(/__dirname/g, baseDir);
|
|
||||||
|
|
||||||
if (!isAbsolute(candidate)) {
|
|
||||||
candidate = join(baseDir, candidate);
|
|
||||||
}
|
|
||||||
return normalize(candidate);
|
|
||||||
}
|
|
||||||
|
|
||||||
function extractGeminiCliRootFromPath(pathValue: string): string | null {
|
|
||||||
const normalized = pathValue.replace(/\\/g, "/");
|
|
||||||
const marker = "/@google/gemini-cli/";
|
|
||||||
const idx = normalized.indexOf(marker);
|
|
||||||
if (idx === -1) return null;
|
|
||||||
return normalize(`${normalized.slice(0, idx)}${marker.slice(0, -1)}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
function safeRealpath(pathValue: string): string | null {
|
|
||||||
try {
|
|
||||||
return realpathSync(pathValue);
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function getGlobalNodeModulesRoots(): string[] {
|
|
||||||
const roots = new Set<string>();
|
|
||||||
const prefix = process.env.npm_config_prefix ?? process.env.NPM_CONFIG_PREFIX ?? process.env.PREFIX;
|
|
||||||
|
|
||||||
if (prefix) {
|
|
||||||
if (process.platform === "win32") {
|
|
||||||
roots.add(join(prefix, "node_modules"));
|
|
||||||
} else {
|
|
||||||
roots.add(join(prefix, "lib", "node_modules"));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (process.platform === "win32") {
|
|
||||||
const appData = process.env.APPDATA;
|
|
||||||
if (appData) roots.add(join(appData, "npm", "node_modules"));
|
|
||||||
} else {
|
|
||||||
roots.add("/usr/local/lib/node_modules");
|
|
||||||
roots.add("/opt/homebrew/lib/node_modules");
|
|
||||||
}
|
|
||||||
|
|
||||||
const pnpmHome = process.env.PNPM_HOME;
|
|
||||||
if (pnpmHome) {
|
|
||||||
roots.add(join(pnpmHome, "global", "5", "node_modules"));
|
|
||||||
roots.add(join(pnpmHome, "global", "4", "node_modules"));
|
|
||||||
}
|
|
||||||
|
|
||||||
return [...roots].filter((root) => existsSync(root));
|
|
||||||
}
|
|
||||||
|
|
||||||
function shouldSearchRoot(root: string): boolean {
|
|
||||||
const normalized = root.replace(/\\/g, "/");
|
|
||||||
if (normalized === "/" || /^[A-Za-z]:\/?$/.test(normalized)) return false;
|
|
||||||
const segments = normalized.split("/").filter(Boolean);
|
|
||||||
return segments.length >= 3;
|
|
||||||
}
|
|
||||||
|
|
||||||
function findInPath(name: string): string | null {
|
|
||||||
const exts = process.platform === "win32" ? [".cmd", ".bat", ".exe", ""] : [""];
|
|
||||||
for (const dir of (process.env.PATH ?? "").split(delimiter)) {
|
|
||||||
for (const ext of exts) {
|
|
||||||
const p = join(dir, name + ext);
|
|
||||||
if (existsSync(p)) return p;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function findFile(dir: string, name: string, depth: number): string | null {
|
|
||||||
if (depth <= 0) return null;
|
|
||||||
try {
|
|
||||||
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
||||||
const p = join(dir, entry.name);
|
|
||||||
if (entry.isFile() && entry.name === name) return p;
|
|
||||||
if (entry.isDirectory() && !entry.name.startsWith(".")) {
|
|
||||||
const found = findFile(p, name, depth - 1);
|
|
||||||
if (found) return found;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch {}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function resolveOAuthClientConfig(): { clientId: string; clientSecret?: string } {
|
function resolveOAuthClientConfig(): { clientId: string; clientSecret?: string } {
|
||||||
// 1. Check env vars first (user override)
|
const clientId = resolveEnv(CLIENT_ID_KEYS);
|
||||||
const envClientId = resolveEnv(CLIENT_ID_KEYS);
|
if (!clientId) {
|
||||||
const envClientSecret = resolveEnv(CLIENT_SECRET_KEYS);
|
throw new Error(
|
||||||
if (envClientId) {
|
"Missing Gemini OAuth client ID. Set CLAWDBOT_GEMINI_OAUTH_CLIENT_ID (or GEMINI_CLI_OAUTH_CLIENT_ID).",
|
||||||
return { clientId: envClientId, clientSecret: envClientSecret };
|
);
|
||||||
}
|
}
|
||||||
|
const clientSecret = resolveEnv(CLIENT_SECRET_KEYS);
|
||||||
// 2. Try to extract from installed Gemini CLI
|
return { clientId, clientSecret };
|
||||||
const extracted = extractGeminiCliCredentials();
|
|
||||||
if (extracted?.clientId) {
|
|
||||||
return extracted;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. No credentials available
|
|
||||||
throw new Error(
|
|
||||||
"Gemini CLI credentials not found. Install it first: brew install gemini-cli (or npm install -g @google/gemini-cli), or set GEMINI_CLI_OAUTH_CLIENT_ID.",
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function isWSL(): boolean {
|
function isWSL(): boolean {
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ export function registerMatrixAutoJoin(params: {
|
|||||||
// For "allowlist" mode, handle invites manually
|
// For "allowlist" mode, handle invites manually
|
||||||
client.on("room.invite", async (roomId: string, _inviteEvent: unknown) => {
|
client.on("room.invite", async (roomId: string, _inviteEvent: unknown) => {
|
||||||
if (autoJoin !== "allowlist") return;
|
if (autoJoin !== "allowlist") return;
|
||||||
|
|
||||||
// Get room alias if available
|
// Get room alias if available
|
||||||
let alias: string | undefined;
|
let alias: string | undefined;
|
||||||
let altAliases: string[] = [];
|
let altAliases: string[] = [];
|
||||||
|
|||||||
@@ -329,20 +329,16 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const contentInfo =
|
const contentType =
|
||||||
"info" in content && content.info && typeof content.info === "object"
|
"info" in content && content.info && "mimetype" in content.info
|
||||||
? (content.info as { mimetype?: string; size?: number })
|
? (content.info as { mimetype?: string }).mimetype
|
||||||
: undefined;
|
: undefined;
|
||||||
const contentType = contentInfo?.mimetype;
|
|
||||||
const contentSize =
|
|
||||||
typeof contentInfo?.size === "number" ? contentInfo.size : undefined;
|
|
||||||
if (mediaUrl?.startsWith("mxc://")) {
|
if (mediaUrl?.startsWith("mxc://")) {
|
||||||
try {
|
try {
|
||||||
media = await downloadMatrixMedia({
|
media = await downloadMatrixMedia({
|
||||||
client,
|
client,
|
||||||
mxcUrl: mediaUrl,
|
mxcUrl: mediaUrl,
|
||||||
contentType,
|
contentType,
|
||||||
sizeBytes: contentSize,
|
|
||||||
maxBytes: mediaMaxBytes,
|
maxBytes: mediaMaxBytes,
|
||||||
file: contentFile,
|
file: contentFile,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -25,8 +25,10 @@ describe("downloadMatrixMedia", () => {
|
|||||||
|
|
||||||
it("decrypts encrypted media when file payloads are present", async () => {
|
it("decrypts encrypted media when file payloads are present", async () => {
|
||||||
const decryptMedia = vi.fn().mockResolvedValue(Buffer.from("decrypted"));
|
const decryptMedia = vi.fn().mockResolvedValue(Buffer.from("decrypted"));
|
||||||
|
const downloadContent = vi.fn().mockResolvedValue(Buffer.from("encrypted"));
|
||||||
|
|
||||||
const client = {
|
const client = {
|
||||||
|
downloadContent,
|
||||||
crypto: { decryptMedia },
|
crypto: { decryptMedia },
|
||||||
mxcToHttp: vi.fn().mockReturnValue("https://example/mxc"),
|
mxcToHttp: vi.fn().mockReturnValue("https://example/mxc"),
|
||||||
} as unknown as import("matrix-bot-sdk").MatrixClient;
|
} as unknown as import("matrix-bot-sdk").MatrixClient;
|
||||||
@@ -53,8 +55,7 @@ describe("downloadMatrixMedia", () => {
|
|||||||
file,
|
file,
|
||||||
});
|
});
|
||||||
|
|
||||||
// decryptMedia should be called with just the file object (it handles download internally)
|
expect(decryptMedia).toHaveBeenCalled();
|
||||||
expect(decryptMedia).toHaveBeenCalledWith(file);
|
|
||||||
expect(saveMediaBuffer).toHaveBeenCalledWith(
|
expect(saveMediaBuffer).toHaveBeenCalledWith(
|
||||||
Buffer.from("decrypted"),
|
Buffer.from("decrypted"),
|
||||||
"image/png",
|
"image/png",
|
||||||
@@ -63,41 +64,4 @@ describe("downloadMatrixMedia", () => {
|
|||||||
);
|
);
|
||||||
expect(result?.path).toBe("/tmp/media");
|
expect(result?.path).toBe("/tmp/media");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("rejects encrypted media that exceeds maxBytes before decrypting", async () => {
|
|
||||||
const decryptMedia = vi.fn().mockResolvedValue(Buffer.from("decrypted"));
|
|
||||||
|
|
||||||
const client = {
|
|
||||||
crypto: { decryptMedia },
|
|
||||||
mxcToHttp: vi.fn().mockReturnValue("https://example/mxc"),
|
|
||||||
} as unknown as import("matrix-bot-sdk").MatrixClient;
|
|
||||||
|
|
||||||
const file = {
|
|
||||||
url: "mxc://example/file",
|
|
||||||
key: {
|
|
||||||
kty: "oct",
|
|
||||||
key_ops: ["encrypt", "decrypt"],
|
|
||||||
alg: "A256CTR",
|
|
||||||
k: "secret",
|
|
||||||
ext: true,
|
|
||||||
},
|
|
||||||
iv: "iv",
|
|
||||||
hashes: { sha256: "hash" },
|
|
||||||
v: "v2",
|
|
||||||
};
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
downloadMatrixMedia({
|
|
||||||
client,
|
|
||||||
mxcUrl: "mxc://example/file",
|
|
||||||
contentType: "image/png",
|
|
||||||
sizeBytes: 2048,
|
|
||||||
maxBytes: 1024,
|
|
||||||
file,
|
|
||||||
}),
|
|
||||||
).rejects.toThrow("Matrix media exceeds configured size limit");
|
|
||||||
|
|
||||||
expect(decryptMedia).not.toHaveBeenCalled();
|
|
||||||
expect(saveMediaBuffer).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
@@ -25,7 +25,7 @@ async function fetchMatrixMediaBuffer(params: {
|
|||||||
// matrix-bot-sdk provides mxcToHttp helper
|
// matrix-bot-sdk provides mxcToHttp helper
|
||||||
const url = params.client.mxcToHttp(params.mxcUrl);
|
const url = params.client.mxcToHttp(params.mxcUrl);
|
||||||
if (!url) return null;
|
if (!url) return null;
|
||||||
|
|
||||||
// Use the client's download method which handles auth
|
// Use the client's download method which handles auth
|
||||||
try {
|
try {
|
||||||
const buffer = await params.client.downloadContent(params.mxcUrl);
|
const buffer = await params.client.downloadContent(params.mxcUrl);
|
||||||
@@ -40,7 +40,6 @@ async function fetchMatrixMediaBuffer(params: {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Download and decrypt encrypted media from a Matrix room.
|
* Download and decrypt encrypted media from a Matrix room.
|
||||||
* Uses matrix-bot-sdk's decryptMedia which handles both download and decryption.
|
|
||||||
*/
|
*/
|
||||||
async function fetchEncryptedMediaBuffer(params: {
|
async function fetchEncryptedMediaBuffer(params: {
|
||||||
client: MatrixClient;
|
client: MatrixClient;
|
||||||
@@ -51,13 +50,18 @@ async function fetchEncryptedMediaBuffer(params: {
|
|||||||
throw new Error("Cannot decrypt media: crypto not enabled");
|
throw new Error("Cannot decrypt media: crypto not enabled");
|
||||||
}
|
}
|
||||||
|
|
||||||
// decryptMedia handles downloading and decrypting the encrypted content internally
|
// Download the encrypted content
|
||||||
const decrypted = await params.client.crypto.decryptMedia(params.file);
|
const encryptedBuffer = await params.client.downloadContent(params.file.url);
|
||||||
|
if (encryptedBuffer.byteLength > params.maxBytes) {
|
||||||
if (decrypted.byteLength > params.maxBytes) {
|
|
||||||
throw new Error("Matrix media exceeds configured size limit");
|
throw new Error("Matrix media exceeds configured size limit");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Decrypt using matrix-bot-sdk crypto
|
||||||
|
const decrypted = await params.client.crypto.decryptMedia(
|
||||||
|
Buffer.from(encryptedBuffer),
|
||||||
|
params.file,
|
||||||
|
);
|
||||||
|
|
||||||
return { buffer: decrypted };
|
return { buffer: decrypted };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -65,7 +69,6 @@ export async function downloadMatrixMedia(params: {
|
|||||||
client: MatrixClient;
|
client: MatrixClient;
|
||||||
mxcUrl: string;
|
mxcUrl: string;
|
||||||
contentType?: string;
|
contentType?: string;
|
||||||
sizeBytes?: number;
|
|
||||||
maxBytes: number;
|
maxBytes: number;
|
||||||
file?: EncryptedFile;
|
file?: EncryptedFile;
|
||||||
}): Promise<{
|
}): Promise<{
|
||||||
@@ -74,13 +77,7 @@ export async function downloadMatrixMedia(params: {
|
|||||||
placeholder: string;
|
placeholder: string;
|
||||||
} | null> {
|
} | null> {
|
||||||
let fetched: { buffer: Buffer; headerType?: string } | null;
|
let fetched: { buffer: Buffer; headerType?: string } | null;
|
||||||
if (
|
|
||||||
typeof params.sizeBytes === "number" &&
|
|
||||||
params.sizeBytes > params.maxBytes
|
|
||||||
) {
|
|
||||||
throw new Error("Matrix media exceeds configured size limit");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (params.file) {
|
if (params.file) {
|
||||||
// Encrypted media
|
// Encrypted media
|
||||||
fetched = await fetchEncryptedMediaBuffer({
|
fetched = await fetchEncryptedMediaBuffer({
|
||||||
@@ -96,7 +93,7 @@ export async function downloadMatrixMedia(params: {
|
|||||||
maxBytes: params.maxBytes,
|
maxBytes: params.maxBytes,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!fetched) return null;
|
if (!fetched) return null;
|
||||||
const headerType = fetched.headerType ?? params.contentType ?? undefined;
|
const headerType = fetched.headerType ?? params.contentType ?? undefined;
|
||||||
const saved = await getMatrixRuntime().channel.media.saveMediaBuffer(
|
const saved = await getMatrixRuntime().channel.media.saveMediaBuffer(
|
||||||
|
|||||||
@@ -29,7 +29,6 @@ export type RoomMessageEventContent = MessageEventContent & {
|
|||||||
file?: EncryptedFile;
|
file?: EncryptedFile;
|
||||||
info?: {
|
info?: {
|
||||||
mimetype?: string;
|
mimetype?: string;
|
||||||
size?: number;
|
|
||||||
};
|
};
|
||||||
"m.relates_to"?: {
|
"m.relates_to"?: {
|
||||||
rel_type?: string;
|
rel_type?: string;
|
||||||
|
|||||||
@@ -11,4 +11,4 @@ export function resolveMattermostGroupRequireMention(
|
|||||||
});
|
});
|
||||||
if (typeof account.requireMention === "boolean") return account.requireMention;
|
if (typeof account.requireMention === "boolean") return account.requireMention;
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -112,4 +112,4 @@ export function listEnabledMattermostAccounts(cfg: ClawdbotConfig): ResolvedMatt
|
|||||||
return listMattermostAccountIds(cfg)
|
return listMattermostAccountIds(cfg)
|
||||||
.map((accountId) => resolveMattermostAccount({ cfg, accountId }))
|
.map((accountId) => resolveMattermostAccount({ cfg, accountId }))
|
||||||
.filter((account) => account.enabled);
|
.filter((account) => account.enabled);
|
||||||
}
|
}
|
||||||
@@ -205,4 +205,4 @@ export async function uploadMattermostFile(
|
|||||||
throw new Error("Mattermost file upload failed");
|
throw new Error("Mattermost file upload failed");
|
||||||
}
|
}
|
||||||
return info;
|
return info;
|
||||||
}
|
}
|
||||||
@@ -147,4 +147,4 @@ export function resolveThreadSessionKeys(params: {
|
|||||||
? `${params.baseSessionKey}:thread:${threadId}`
|
? `${params.baseSessionKey}:thread:${threadId}`
|
||||||
: params.baseSessionKey;
|
: params.baseSessionKey;
|
||||||
return { sessionKey, parentSessionKey: params.parentSessionKey };
|
return { sessionKey, parentSessionKey: params.parentSessionKey };
|
||||||
}
|
}
|
||||||
@@ -67,4 +67,4 @@ export async function probeMattermost(
|
|||||||
} finally {
|
} finally {
|
||||||
if (timer) clearTimeout(timer);
|
if (timer) clearTimeout(timer);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -39,4 +39,4 @@ export async function promptAccountId(params: PromptAccountIdParams): Promise<st
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
return normalized;
|
return normalized;
|
||||||
}
|
}
|
||||||
@@ -184,4 +184,4 @@ export const mattermostOnboardingAdapter: ChannelOnboardingAdapter = {
|
|||||||
mattermost: { ...cfg.channels?.mattermost, enabled: false },
|
mattermost: { ...cfg.channels?.mattermost, enabled: false },
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
@@ -22,11 +22,11 @@ parallel:
|
|||||||
security = session: security_expert
|
security = session: security_expert
|
||||||
prompt: "Perform a deep security audit of the changes. Look for OWASP top 10 issues."
|
prompt: "Perform a deep security audit of the changes. Look for OWASP top 10 issues."
|
||||||
context: overview
|
context: overview
|
||||||
|
|
||||||
perf = session: performance_expert
|
perf = session: performance_expert
|
||||||
prompt: "Analyze the performance implications. Identify potential bottlenecks or regressions."
|
prompt: "Analyze the performance implications. Identify potential bottlenecks or regressions."
|
||||||
context: overview
|
context: overview
|
||||||
|
|
||||||
style = session: reviewer
|
style = session: reviewer
|
||||||
prompt: "Review for code style, maintainability, and adherence to best practices."
|
prompt: "Review for code style, maintainability, and adherence to best practices."
|
||||||
context: overview
|
context: overview
|
||||||
|
|||||||
@@ -19,3 +19,4 @@ export type CallManagerContext = {
|
|||||||
transcriptWaiters: Map<CallId, TranscriptWaiter>;
|
transcriptWaiters: Map<CallId, TranscriptWaiter>;
|
||||||
maxDurationTimers: Map<CallId, NodeJS.Timeout>;
|
maxDurationTimers: Map<CallId, NodeJS.Timeout>;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -175,3 +175,4 @@ export function processEvent(ctx: CallManagerContext, event: NormalizedEvent): v
|
|||||||
|
|
||||||
persistCallRecord(ctx.storePath, call);
|
persistCallRecord(ctx.storePath, call);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -31,3 +31,4 @@ export function findCall(params: {
|
|||||||
providerCallId: params.callIdOrProviderCallId,
|
providerCallId: params.callIdOrProviderCallId,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -48,3 +48,4 @@ export function addTranscriptEntry(
|
|||||||
};
|
};
|
||||||
call.transcript.push(entry);
|
call.transcript.push(entry);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -86,3 +86,4 @@ export async function getCallHistoryFromStore(
|
|||||||
|
|
||||||
return calls;
|
return calls;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -84,3 +84,4 @@ export function waitForFinalTranscript(
|
|||||||
ctx.transcriptWaiters.set(callId, { resolve, reject, timeout });
|
ctx.transcriptWaiters.set(callId, { resolve, reject, timeout });
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -7,3 +7,4 @@ export function generateNotifyTwiml(message: string, voice: string): string {
|
|||||||
<Hangup/>
|
<Hangup/>
|
||||||
</Response>`;
|
</Response>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1,97 +0,0 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
|
||||||
|
|
||||||
import type {
|
|
||||||
OpenAIRealtimeSTTProvider,
|
|
||||||
RealtimeSTTSession,
|
|
||||||
} from "./providers/stt-openai-realtime.js";
|
|
||||||
import { MediaStreamHandler } from "./media-stream.js";
|
|
||||||
|
|
||||||
const createStubSession = (): RealtimeSTTSession => ({
|
|
||||||
connect: async () => {},
|
|
||||||
sendAudio: () => {},
|
|
||||||
waitForTranscript: async () => "",
|
|
||||||
onPartial: () => {},
|
|
||||||
onTranscript: () => {},
|
|
||||||
onSpeechStart: () => {},
|
|
||||||
close: () => {},
|
|
||||||
isConnected: () => true,
|
|
||||||
});
|
|
||||||
|
|
||||||
const createStubSttProvider = (): OpenAIRealtimeSTTProvider =>
|
|
||||||
({
|
|
||||||
createSession: () => createStubSession(),
|
|
||||||
}) as unknown as OpenAIRealtimeSTTProvider;
|
|
||||||
|
|
||||||
const flush = async (): Promise<void> => {
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
||||||
};
|
|
||||||
|
|
||||||
const waitForAbort = (signal: AbortSignal): Promise<void> =>
|
|
||||||
new Promise((resolve) => {
|
|
||||||
if (signal.aborted) {
|
|
||||||
resolve();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
signal.addEventListener("abort", () => resolve(), { once: true });
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("MediaStreamHandler TTS queue", () => {
|
|
||||||
it("serializes TTS playback and resolves in order", async () => {
|
|
||||||
const handler = new MediaStreamHandler({
|
|
||||||
sttProvider: createStubSttProvider(),
|
|
||||||
});
|
|
||||||
const started: number[] = [];
|
|
||||||
const finished: number[] = [];
|
|
||||||
|
|
||||||
let resolveFirst!: () => void;
|
|
||||||
const firstGate = new Promise<void>((resolve) => {
|
|
||||||
resolveFirst = resolve;
|
|
||||||
});
|
|
||||||
|
|
||||||
const first = handler.queueTts("stream-1", async () => {
|
|
||||||
started.push(1);
|
|
||||||
await firstGate;
|
|
||||||
finished.push(1);
|
|
||||||
});
|
|
||||||
const second = handler.queueTts("stream-1", async () => {
|
|
||||||
started.push(2);
|
|
||||||
finished.push(2);
|
|
||||||
});
|
|
||||||
|
|
||||||
await flush();
|
|
||||||
expect(started).toEqual([1]);
|
|
||||||
|
|
||||||
resolveFirst();
|
|
||||||
await first;
|
|
||||||
await second;
|
|
||||||
|
|
||||||
expect(started).toEqual([1, 2]);
|
|
||||||
expect(finished).toEqual([1, 2]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("cancels active playback and clears queued items", async () => {
|
|
||||||
const handler = new MediaStreamHandler({
|
|
||||||
sttProvider: createStubSttProvider(),
|
|
||||||
});
|
|
||||||
|
|
||||||
let queuedRan = false;
|
|
||||||
const started: string[] = [];
|
|
||||||
|
|
||||||
const active = handler.queueTts("stream-1", async (signal) => {
|
|
||||||
started.push("active");
|
|
||||||
await waitForAbort(signal);
|
|
||||||
});
|
|
||||||
void handler.queueTts("stream-1", async () => {
|
|
||||||
queuedRan = true;
|
|
||||||
});
|
|
||||||
|
|
||||||
await flush();
|
|
||||||
expect(started).toEqual(["active"]);
|
|
||||||
|
|
||||||
handler.clearTtsQueue("stream-1");
|
|
||||||
await active;
|
|
||||||
await flush();
|
|
||||||
|
|
||||||
expect(queuedRan).toBe(false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -29,8 +29,6 @@ export interface MediaStreamConfig {
|
|||||||
onPartialTranscript?: (callId: string, partial: string) => void;
|
onPartialTranscript?: (callId: string, partial: string) => void;
|
||||||
/** Callback when stream connects */
|
/** Callback when stream connects */
|
||||||
onConnect?: (callId: string, streamSid: string) => void;
|
onConnect?: (callId: string, streamSid: string) => void;
|
||||||
/** Callback when speech starts (barge-in) */
|
|
||||||
onSpeechStart?: (callId: string) => void;
|
|
||||||
/** Callback when stream disconnects */
|
/** Callback when stream disconnects */
|
||||||
onDisconnect?: (callId: string) => void;
|
onDisconnect?: (callId: string) => void;
|
||||||
}
|
}
|
||||||
@@ -45,13 +43,6 @@ interface StreamSession {
|
|||||||
sttSession: RealtimeSTTSession;
|
sttSession: RealtimeSTTSession;
|
||||||
}
|
}
|
||||||
|
|
||||||
type TtsQueueEntry = {
|
|
||||||
playFn: (signal: AbortSignal) => Promise<void>;
|
|
||||||
controller: AbortController;
|
|
||||||
resolve: () => void;
|
|
||||||
reject: (error: unknown) => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Manages WebSocket connections for Twilio media streams.
|
* Manages WebSocket connections for Twilio media streams.
|
||||||
*/
|
*/
|
||||||
@@ -59,12 +50,6 @@ export class MediaStreamHandler {
|
|||||||
private wss: WebSocketServer | null = null;
|
private wss: WebSocketServer | null = null;
|
||||||
private sessions = new Map<string, StreamSession>();
|
private sessions = new Map<string, StreamSession>();
|
||||||
private config: MediaStreamConfig;
|
private config: MediaStreamConfig;
|
||||||
/** TTS playback queues per stream (serialize audio to prevent overlap) */
|
|
||||||
private ttsQueues = new Map<string, TtsQueueEntry[]>();
|
|
||||||
/** Whether TTS is currently playing per stream */
|
|
||||||
private ttsPlaying = new Map<string, boolean>();
|
|
||||||
/** Active TTS playback controllers per stream */
|
|
||||||
private ttsActiveControllers = new Map<string, AbortController>();
|
|
||||||
|
|
||||||
constructor(config: MediaStreamConfig) {
|
constructor(config: MediaStreamConfig) {
|
||||||
this.config = config;
|
this.config = config;
|
||||||
@@ -163,10 +148,6 @@ export class MediaStreamHandler {
|
|||||||
this.config.onTranscript?.(callSid, transcript);
|
this.config.onTranscript?.(callSid, transcript);
|
||||||
});
|
});
|
||||||
|
|
||||||
sttSession.onSpeechStart(() => {
|
|
||||||
this.config.onSpeechStart?.(callSid);
|
|
||||||
});
|
|
||||||
|
|
||||||
const session: StreamSession = {
|
const session: StreamSession = {
|
||||||
callId: callSid,
|
callId: callSid,
|
||||||
streamSid,
|
streamSid,
|
||||||
@@ -196,7 +177,6 @@ export class MediaStreamHandler {
|
|||||||
private handleStop(session: StreamSession): void {
|
private handleStop(session: StreamSession): void {
|
||||||
console.log(`[MediaStream] Stream stopped: ${session.streamSid}`);
|
console.log(`[MediaStream] Stream stopped: ${session.streamSid}`);
|
||||||
|
|
||||||
this.clearTtsState(session.streamSid);
|
|
||||||
session.sttSession.close();
|
session.sttSession.close();
|
||||||
this.sessions.delete(session.streamSid);
|
this.sessions.delete(session.streamSid);
|
||||||
this.config.onDisconnect?.(session.callId);
|
this.config.onDisconnect?.(session.callId);
|
||||||
@@ -248,46 +228,6 @@ export class MediaStreamHandler {
|
|||||||
this.sendToStream(streamSid, { event: "clear", streamSid });
|
this.sendToStream(streamSid, { event: "clear", streamSid });
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Queue a TTS operation for sequential playback.
|
|
||||||
* Only one TTS operation plays at a time per stream to prevent overlap.
|
|
||||||
*/
|
|
||||||
async queueTts(
|
|
||||||
streamSid: string,
|
|
||||||
playFn: (signal: AbortSignal) => Promise<void>,
|
|
||||||
): Promise<void> {
|
|
||||||
const queue = this.getTtsQueue(streamSid);
|
|
||||||
let resolveEntry: () => void;
|
|
||||||
let rejectEntry: (error: unknown) => void;
|
|
||||||
const promise = new Promise<void>((resolve, reject) => {
|
|
||||||
resolveEntry = resolve;
|
|
||||||
rejectEntry = reject;
|
|
||||||
});
|
|
||||||
|
|
||||||
queue.push({
|
|
||||||
playFn,
|
|
||||||
controller: new AbortController(),
|
|
||||||
resolve: resolveEntry!,
|
|
||||||
reject: rejectEntry!,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!this.ttsPlaying.get(streamSid)) {
|
|
||||||
void this.processQueue(streamSid);
|
|
||||||
}
|
|
||||||
|
|
||||||
return promise;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Clear TTS queue and interrupt current playback (barge-in).
|
|
||||||
*/
|
|
||||||
clearTtsQueue(streamSid: string): void {
|
|
||||||
const queue = this.getTtsQueue(streamSid);
|
|
||||||
queue.length = 0;
|
|
||||||
this.ttsActiveControllers.get(streamSid)?.abort();
|
|
||||||
this.clearAudio(streamSid);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get active session by call ID.
|
* Get active session by call ID.
|
||||||
*/
|
*/
|
||||||
@@ -302,65 +242,11 @@ export class MediaStreamHandler {
|
|||||||
*/
|
*/
|
||||||
closeAll(): void {
|
closeAll(): void {
|
||||||
for (const session of this.sessions.values()) {
|
for (const session of this.sessions.values()) {
|
||||||
this.clearTtsState(session.streamSid);
|
|
||||||
session.sttSession.close();
|
session.sttSession.close();
|
||||||
session.ws.close();
|
session.ws.close();
|
||||||
}
|
}
|
||||||
this.sessions.clear();
|
this.sessions.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
private getTtsQueue(streamSid: string): TtsQueueEntry[] {
|
|
||||||
const existing = this.ttsQueues.get(streamSid);
|
|
||||||
if (existing) return existing;
|
|
||||||
const queue: TtsQueueEntry[] = [];
|
|
||||||
this.ttsQueues.set(streamSid, queue);
|
|
||||||
return queue;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Process the TTS queue for a stream.
|
|
||||||
* Uses iterative approach to avoid stack accumulation from recursion.
|
|
||||||
*/
|
|
||||||
private async processQueue(streamSid: string): Promise<void> {
|
|
||||||
this.ttsPlaying.set(streamSid, true);
|
|
||||||
|
|
||||||
while (true) {
|
|
||||||
const queue = this.ttsQueues.get(streamSid);
|
|
||||||
if (!queue || queue.length === 0) {
|
|
||||||
this.ttsPlaying.set(streamSid, false);
|
|
||||||
this.ttsActiveControllers.delete(streamSid);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const entry = queue.shift()!;
|
|
||||||
this.ttsActiveControllers.set(streamSid, entry.controller);
|
|
||||||
|
|
||||||
try {
|
|
||||||
await entry.playFn(entry.controller.signal);
|
|
||||||
entry.resolve();
|
|
||||||
} catch (error) {
|
|
||||||
if (entry.controller.signal.aborted) {
|
|
||||||
entry.resolve();
|
|
||||||
} else {
|
|
||||||
console.error("[MediaStream] TTS playback error:", error);
|
|
||||||
entry.reject(error);
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
if (this.ttsActiveControllers.get(streamSid) === entry.controller) {
|
|
||||||
this.ttsActiveControllers.delete(streamSid);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private clearTtsState(streamSid: string): void {
|
|
||||||
const queue = this.ttsQueues.get(streamSid);
|
|
||||||
if (queue) queue.length = 0;
|
|
||||||
this.ttsActiveControllers.get(streamSid)?.abort();
|
|
||||||
this.ttsActiveControllers.delete(streamSid);
|
|
||||||
this.ttsPlaying.delete(streamSid);
|
|
||||||
this.ttsQueues.delete(streamSid);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -26,3 +26,4 @@ describe("PlivoProvider", () => {
|
|||||||
expect(result.providerResponseBody).toContain('length="300"');
|
expect(result.providerResponseBody).toContain('length="300"');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -38,8 +38,6 @@ export interface RealtimeSTTSession {
|
|||||||
onPartial(callback: (partial: string) => void): void;
|
onPartial(callback: (partial: string) => void): void;
|
||||||
/** Set callback for final transcripts */
|
/** Set callback for final transcripts */
|
||||||
onTranscript(callback: (transcript: string) => void): void;
|
onTranscript(callback: (transcript: string) => void): void;
|
||||||
/** Set callback when speech starts (VAD) */
|
|
||||||
onSpeechStart(callback: () => void): void;
|
|
||||||
/** Close the session */
|
/** Close the session */
|
||||||
close(): void;
|
close(): void;
|
||||||
/** Check if session is connected */
|
/** Check if session is connected */
|
||||||
@@ -93,7 +91,6 @@ class OpenAIRealtimeSTTSession implements RealtimeSTTSession {
|
|||||||
private pendingTranscript = "";
|
private pendingTranscript = "";
|
||||||
private onTranscriptCallback: ((transcript: string) => void) | null = null;
|
private onTranscriptCallback: ((transcript: string) => void) | null = null;
|
||||||
private onPartialCallback: ((partial: string) => void) | null = null;
|
private onPartialCallback: ((partial: string) => void) | null = null;
|
||||||
private onSpeechStartCallback: (() => void) | null = null;
|
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly apiKey: string,
|
private readonly apiKey: string,
|
||||||
@@ -246,7 +243,6 @@ class OpenAIRealtimeSTTSession implements RealtimeSTTSession {
|
|||||||
case "input_audio_buffer.speech_started":
|
case "input_audio_buffer.speech_started":
|
||||||
console.log("[RealtimeSTT] Speech started");
|
console.log("[RealtimeSTT] Speech started");
|
||||||
this.pendingTranscript = "";
|
this.pendingTranscript = "";
|
||||||
this.onSpeechStartCallback?.();
|
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case "error":
|
case "error":
|
||||||
@@ -277,10 +273,6 @@ class OpenAIRealtimeSTTSession implements RealtimeSTTSession {
|
|||||||
this.onTranscriptCallback = callback;
|
this.onTranscriptCallback = callback;
|
||||||
}
|
}
|
||||||
|
|
||||||
onSpeechStart(callback: () => void): void {
|
|
||||||
this.onSpeechStartCallback = callback;
|
|
||||||
}
|
|
||||||
|
|
||||||
async waitForTranscript(timeoutMs = 30000): Promise<string> {
|
async waitForTranscript(timeoutMs = 30000): Promise<string> {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const timeout = setTimeout(() => {
|
const timeout = setTimeout(() => {
|
||||||
|
|||||||
@@ -135,17 +135,6 @@ export class TwilioProvider implements VoiceCallProvider {
|
|||||||
this.callStreamMap.delete(callSid);
|
this.callStreamMap.delete(callSid);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Clear TTS queue for a call (barge-in).
|
|
||||||
* Used when user starts speaking to interrupt current TTS playback.
|
|
||||||
*/
|
|
||||||
clearTtsQueue(callSid: string): void {
|
|
||||||
const streamSid = this.callStreamMap.get(callSid);
|
|
||||||
if (streamSid && this.mediaStreamHandler) {
|
|
||||||
this.mediaStreamHandler.clearTtsQueue(streamSid);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Make an authenticated request to the Twilio API.
|
* Make an authenticated request to the Twilio API.
|
||||||
*/
|
*/
|
||||||
@@ -515,7 +504,7 @@ export class TwilioProvider implements VoiceCallProvider {
|
|||||||
/**
|
/**
|
||||||
* Play TTS via core TTS and Twilio Media Streams.
|
* Play TTS via core TTS and Twilio Media Streams.
|
||||||
* Generates audio with core TTS, converts to mu-law, and streams via WebSocket.
|
* Generates audio with core TTS, converts to mu-law, and streams via WebSocket.
|
||||||
* Uses a queue to serialize playback and prevent overlapping audio.
|
* Uses a jitter buffer to smooth out timing variations.
|
||||||
*/
|
*/
|
||||||
private async playTtsViaStream(
|
private async playTtsViaStream(
|
||||||
text: string,
|
text: string,
|
||||||
@@ -525,29 +514,22 @@ export class TwilioProvider implements VoiceCallProvider {
|
|||||||
throw new Error("TTS provider and media stream handler required");
|
throw new Error("TTS provider and media stream handler required");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Generate audio with core TTS (returns mu-law at 8kHz)
|
||||||
|
const muLawAudio = await this.ttsProvider.synthesizeForTelephony(text);
|
||||||
|
|
||||||
// Stream audio in 20ms chunks (160 bytes at 8kHz mu-law)
|
// Stream audio in 20ms chunks (160 bytes at 8kHz mu-law)
|
||||||
const CHUNK_SIZE = 160;
|
const CHUNK_SIZE = 160;
|
||||||
const CHUNK_DELAY_MS = 20;
|
const CHUNK_DELAY_MS = 20;
|
||||||
|
|
||||||
const handler = this.mediaStreamHandler;
|
for (const chunk of chunkAudio(muLawAudio, CHUNK_SIZE)) {
|
||||||
const ttsProvider = this.ttsProvider;
|
this.mediaStreamHandler.sendAudio(streamSid, chunk);
|
||||||
await handler.queueTts(streamSid, async (signal) => {
|
|
||||||
// Generate audio with core TTS (returns mu-law at 8kHz)
|
|
||||||
const muLawAudio = await ttsProvider.synthesizeForTelephony(text);
|
|
||||||
for (const chunk of chunkAudio(muLawAudio, CHUNK_SIZE)) {
|
|
||||||
if (signal.aborted) break;
|
|
||||||
handler.sendAudio(streamSid, chunk);
|
|
||||||
|
|
||||||
// Pace the audio to match real-time playback
|
// Pace the audio to match real-time playback
|
||||||
await new Promise((resolve) => setTimeout(resolve, CHUNK_DELAY_MS));
|
await new Promise((resolve) => setTimeout(resolve, CHUNK_DELAY_MS));
|
||||||
if (signal.aborted) break;
|
}
|
||||||
}
|
|
||||||
|
|
||||||
if (!signal.aborted) {
|
// Send a mark to track when audio finishes
|
||||||
// Send a mark to track when audio finishes
|
this.mediaStreamHandler.sendMark(streamSid, `tts-${Date.now()}`);
|
||||||
handler.sendMark(streamSid, `tts-${Date.now()}`);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -27,3 +27,4 @@ export function verifyTwilioProviderWebhook(params: {
|
|||||||
reason: result.reason,
|
reason: result.reason,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -78,11 +78,6 @@ export class VoiceCallWebhookServer {
|
|||||||
`[voice-call] Transcript for ${providerCallId}: ${transcript}`,
|
`[voice-call] Transcript for ${providerCallId}: ${transcript}`,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Clear TTS queue on barge-in (user started speaking, interrupt current playback)
|
|
||||||
if (this.provider.name === "twilio") {
|
|
||||||
(this.provider as TwilioProvider).clearTtsQueue(providerCallId);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Look up our internal call ID from the provider call ID
|
// Look up our internal call ID from the provider call ID
|
||||||
const call = this.manager.getCallByProviderCallId(providerCallId);
|
const call = this.manager.getCallByProviderCallId(providerCallId);
|
||||||
if (!call) {
|
if (!call) {
|
||||||
@@ -114,11 +109,6 @@ export class VoiceCallWebhookServer {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onSpeechStart: (providerCallId) => {
|
|
||||||
if (this.provider.name === "twilio") {
|
|
||||||
(this.provider as TwilioProvider).clearTtsQueue(providerCallId);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onPartialTranscript: (callId, partial) => {
|
onPartialTranscript: (callId, partial) => {
|
||||||
console.log(`[voice-call] Partial for ${callId}: ${partial}`);
|
console.log(`[voice-call] Partial for ${callId}: ${partial}`);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -15,3 +15,4 @@ describe("zalouser outbound chunker", () => {
|
|||||||
expect(chunks.every((c) => c.length <= limit)).toBe(true);
|
expect(chunks.every((c) => c.length <= limit)).toBe(true);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
+10
-10
@@ -124,7 +124,7 @@ EOF
|
|||||||
# Function to list categories
|
# Function to list categories
|
||||||
list_categories() {
|
list_categories() {
|
||||||
echo -e "${BLUE}Fetching VibeTunnel log categories from the last hour...${NC}\n"
|
echo -e "${BLUE}Fetching VibeTunnel log categories from the last hour...${NC}\n"
|
||||||
|
|
||||||
# Get unique categories from recent logs
|
# Get unique categories from recent logs
|
||||||
log show --predicate "subsystem == \"$SUBSYSTEM\"" --last 1h 2>/dev/null | \
|
log show --predicate "subsystem == \"$SUBSYSTEM\"" --last 1h 2>/dev/null | \
|
||||||
grep -E "category: \"[^\"]+\"" | \
|
grep -E "category: \"[^\"]+\"" | \
|
||||||
@@ -133,7 +133,7 @@ list_categories() {
|
|||||||
while read -r cat; do
|
while read -r cat; do
|
||||||
echo " • $cat"
|
echo " • $cat"
|
||||||
done
|
done
|
||||||
|
|
||||||
echo -e "\n${YELLOW}Note: Only categories with recent activity are shown${NC}"
|
echo -e "\n${YELLOW}Note: Only categories with recent activity are shown${NC}"
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -230,29 +230,29 @@ fi
|
|||||||
if [[ "$STREAM_MODE" == true ]]; then
|
if [[ "$STREAM_MODE" == true ]]; then
|
||||||
# Streaming mode
|
# Streaming mode
|
||||||
CMD="sudo log stream --predicate '$PREDICATE' --level $LOG_LEVEL --info"
|
CMD="sudo log stream --predicate '$PREDICATE' --level $LOG_LEVEL --info"
|
||||||
|
|
||||||
echo -e "${GREEN}Streaming VibeTunnel logs continuously...${NC}"
|
echo -e "${GREEN}Streaming VibeTunnel logs continuously...${NC}"
|
||||||
echo -e "${YELLOW}Press Ctrl+C to stop${NC}\n"
|
echo -e "${YELLOW}Press Ctrl+C to stop${NC}\n"
|
||||||
else
|
else
|
||||||
# Show mode
|
# Show mode
|
||||||
CMD="sudo log show --predicate '$PREDICATE'"
|
CMD="sudo log show --predicate '$PREDICATE'"
|
||||||
|
|
||||||
# Add log level for show command
|
# Add log level for show command
|
||||||
if [[ "$LOG_LEVEL" == "debug" ]]; then
|
if [[ "$LOG_LEVEL" == "debug" ]]; then
|
||||||
CMD="$CMD --debug"
|
CMD="$CMD --debug"
|
||||||
else
|
else
|
||||||
CMD="$CMD --info"
|
CMD="$CMD --info"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Add time range
|
# Add time range
|
||||||
CMD="$CMD --last $TIME_RANGE"
|
CMD="$CMD --last $TIME_RANGE"
|
||||||
|
|
||||||
if [[ "$SHOW_TAIL" == true ]]; then
|
if [[ "$SHOW_TAIL" == true ]]; then
|
||||||
echo -e "${GREEN}Showing last $TAIL_LINES log lines from the past $TIME_RANGE${NC}"
|
echo -e "${GREEN}Showing last $TAIL_LINES log lines from the past $TIME_RANGE${NC}"
|
||||||
else
|
else
|
||||||
echo -e "${GREEN}Showing all logs from the past $TIME_RANGE${NC}"
|
echo -e "${GREEN}Showing all logs from the past $TIME_RANGE${NC}"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Show applied filters
|
# Show applied filters
|
||||||
if [[ "$ERRORS_ONLY" == true ]]; then
|
if [[ "$ERRORS_ONLY" == true ]]; then
|
||||||
echo -e "${RED}Filter: Errors only${NC}"
|
echo -e "${RED}Filter: Errors only${NC}"
|
||||||
@@ -277,14 +277,14 @@ if [[ -n "$OUTPUT_FILE" ]]; then
|
|||||||
if sudo -n /usr/bin/log show --last 1s 2>&1 | grep -q "password"; then
|
if sudo -n /usr/bin/log show --last 1s 2>&1 | grep -q "password"; then
|
||||||
handle_sudo_error
|
handle_sudo_error
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo -e "${BLUE}Exporting logs to: $OUTPUT_FILE${NC}\n"
|
echo -e "${BLUE}Exporting logs to: $OUTPUT_FILE${NC}\n"
|
||||||
if [[ "$SHOW_TAIL" == true ]] && [[ "$STREAM_MODE" == false ]]; then
|
if [[ "$SHOW_TAIL" == true ]] && [[ "$STREAM_MODE" == false ]]; then
|
||||||
eval "$CMD" 2>&1 | tail -n "$TAIL_LINES" > "$OUTPUT_FILE"
|
eval "$CMD" 2>&1 | tail -n "$TAIL_LINES" > "$OUTPUT_FILE"
|
||||||
else
|
else
|
||||||
eval "$CMD" > "$OUTPUT_FILE" 2>&1
|
eval "$CMD" > "$OUTPUT_FILE" 2>&1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Check if file was created and has content
|
# Check if file was created and has content
|
||||||
if [[ -s "$OUTPUT_FILE" ]]; then
|
if [[ -s "$OUTPUT_FILE" ]]; then
|
||||||
LINE_COUNT=$(wc -l < "$OUTPUT_FILE" | tr -d ' ')
|
LINE_COUNT=$(wc -l < "$OUTPUT_FILE" | tr -d ' ')
|
||||||
@@ -298,7 +298,7 @@ else
|
|||||||
if sudo -n /usr/bin/log show --last 1s 2>&1 | grep -q "password"; then
|
if sudo -n /usr/bin/log show --last 1s 2>&1 | grep -q "password"; then
|
||||||
handle_sudo_error
|
handle_sudo_error
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [[ "$SHOW_TAIL" == true ]] && [[ "$STREAM_MODE" == false ]]; then
|
if [[ "$SHOW_TAIL" == true ]] && [[ "$STREAM_MODE" == false ]]; then
|
||||||
# Apply tail for non-streaming mode
|
# Apply tail for non-streaming mode
|
||||||
eval "$CMD" 2>&1 | tail -n "$TAIL_LINES"
|
eval "$CMD" 2>&1 | tail -n "$TAIL_LINES"
|
||||||
|
|||||||
@@ -102,12 +102,12 @@ ws.send(
|
|||||||
);
|
);
|
||||||
const connectRes = await onceFrame((o) => o?.type === \"res\" && o?.id === \"c1\");
|
const connectRes = await onceFrame((o) => o?.type === \"res\" && o?.id === \"c1\");
|
||||||
if (!connectRes.ok) throw new Error(\"connect failed: \" + (connectRes.error?.message ?? \"unknown\"));
|
if (!connectRes.ok) throw new Error(\"connect failed: \" + (connectRes.error?.message ?? \"unknown\"));
|
||||||
|
|
||||||
ws.send(JSON.stringify({ type: \"req\", id: \"h1\", method: \"health\" }));
|
ws.send(JSON.stringify({ type: \"req\", id: \"h1\", method: \"health\" }));
|
||||||
const healthRes = await onceFrame((o) => o?.type === \"res\" && o?.id === \"h1\", 10000);
|
const healthRes = await onceFrame((o) => o?.type === \"res\" && o?.id === \"h1\", 10000);
|
||||||
if (!healthRes.ok) throw new Error(\"health failed: \" + (healthRes.error?.message ?? \"unknown\"));
|
if (!healthRes.ok) throw new Error(\"health failed: \" + (healthRes.error?.message ?? \"unknown\"));
|
||||||
if (healthRes.payload?.ok !== true) throw new Error(\"unexpected health payload\");
|
if (healthRes.payload?.ok !== true) throw new Error(\"unexpected health payload\");
|
||||||
|
|
||||||
ws.close();
|
ws.close();
|
||||||
console.log(\"ok\");
|
console.log(\"ok\");
|
||||||
NODE"
|
NODE"
|
||||||
|
|||||||
@@ -1,31 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
|
||||||
|
|
||||||
if [[ $# -lt 1 ]]; then
|
|
||||||
echo "usage: run-node-tool.sh <tool> [args...]" >&2
|
|
||||||
exit 2
|
|
||||||
fi
|
|
||||||
|
|
||||||
tool="$1"
|
|
||||||
shift
|
|
||||||
|
|
||||||
if [[ -f "$ROOT_DIR/pnpm-lock.yaml" ]] && command -v pnpm >/dev/null 2>&1; then
|
|
||||||
exec pnpm exec "$tool" "$@"
|
|
||||||
fi
|
|
||||||
|
|
||||||
if { [[ -f "$ROOT_DIR/bun.lockb" ]] || [[ -f "$ROOT_DIR/bun.lock" ]]; } && command -v bun >/dev/null 2>&1; then
|
|
||||||
exec bunx --bun "$tool" "$@"
|
|
||||||
fi
|
|
||||||
|
|
||||||
if command -v npm >/dev/null 2>&1; then
|
|
||||||
exec npm exec -- "$tool" "$@"
|
|
||||||
fi
|
|
||||||
|
|
||||||
if command -v npx >/dev/null 2>&1; then
|
|
||||||
exec npx "$tool" "$@"
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "Missing package manager: pnpm, bun, or npm required." >&2
|
|
||||||
exit 1
|
|
||||||
@@ -28,10 +28,9 @@ const overrideWorkers = Number.parseInt(process.env.CLAWDBOT_TEST_WORKERS ?? "",
|
|||||||
const resolvedOverride = Number.isFinite(overrideWorkers) && overrideWorkers > 0 ? overrideWorkers : null;
|
const resolvedOverride = Number.isFinite(overrideWorkers) && overrideWorkers > 0 ? overrideWorkers : null;
|
||||||
const localWorkers = Math.max(4, Math.min(16, os.cpus().length));
|
const localWorkers = Math.max(4, Math.min(16, os.cpus().length));
|
||||||
const perRunWorkers = Math.max(1, Math.floor(localWorkers / parallelRuns.length));
|
const perRunWorkers = Math.max(1, Math.floor(localWorkers / parallelRuns.length));
|
||||||
const macCiWorkers = isCI && isMacOS ? 1 : perRunWorkers;
|
// Keep worker counts predictable for local runs and for CI on macOS.
|
||||||
// Keep worker counts predictable for local runs; trim macOS CI workers to avoid worker crashes/OOM.
|
|
||||||
// In CI on linux/windows, prefer Vitest defaults to avoid cross-test interference from lower worker counts.
|
// In CI on linux/windows, prefer Vitest defaults to avoid cross-test interference from lower worker counts.
|
||||||
const maxWorkers = resolvedOverride ?? (isCI && !isMacOS ? null : macCiWorkers);
|
const maxWorkers = resolvedOverride ?? (isCI && !isMacOS ? null : perRunWorkers);
|
||||||
|
|
||||||
const WARNING_SUPPRESSION_FLAGS = [
|
const WARNING_SUPPRESSION_FLAGS = [
|
||||||
"--disable-warning=ExperimentalWarning",
|
"--disable-warning=ExperimentalWarning",
|
||||||
|
|||||||
@@ -30,3 +30,4 @@ export type Entry = {
|
|||||||
avatar_url: string;
|
avatar_url: string;
|
||||||
lines: number;
|
lines: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -84,7 +84,7 @@ curl http://127.0.0.1:8000/places/{place_id}
|
|||||||
"open_now": true
|
"open_now": true
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"next_page_token": "..."
|
"next_page_token": "..."
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
+229
-116
@@ -1,139 +1,252 @@
|
|||||||
import { describe, it, expect, vi } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import {
|
|
||||||
parseModelRef,
|
|
||||||
resolveModelRefFromString,
|
|
||||||
resolveConfiguredModelRef,
|
|
||||||
buildModelAliasIndex,
|
|
||||||
normalizeProviderId,
|
|
||||||
modelKey,
|
|
||||||
} from "./model-selection.js";
|
|
||||||
import type { ClawdbotConfig } from "../config/config.js";
|
import type { ClawdbotConfig } from "../config/config.js";
|
||||||
|
import { DEFAULT_PROVIDER } from "./defaults.js";
|
||||||
|
import {
|
||||||
|
buildAllowedModelSet,
|
||||||
|
modelKey,
|
||||||
|
parseModelRef,
|
||||||
|
resolveAllowedModelRef,
|
||||||
|
resolveHooksGmailModel,
|
||||||
|
} from "./model-selection.js";
|
||||||
|
|
||||||
describe("model-selection", () => {
|
const catalog = [
|
||||||
describe("normalizeProviderId", () => {
|
{
|
||||||
it("should normalize provider names", () => {
|
provider: "openai",
|
||||||
expect(normalizeProviderId("Anthropic")).toBe("anthropic");
|
id: "gpt-4",
|
||||||
expect(normalizeProviderId("Z.ai")).toBe("zai");
|
name: "GPT-4",
|
||||||
expect(normalizeProviderId("z-ai")).toBe("zai");
|
},
|
||||||
expect(normalizeProviderId("OpenCode-Zen")).toBe("opencode");
|
];
|
||||||
expect(normalizeProviderId("qwen")).toBe("qwen-portal");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("parseModelRef", () => {
|
describe("buildAllowedModelSet", () => {
|
||||||
it("should parse full model refs", () => {
|
it("always allows the configured default model", () => {
|
||||||
expect(parseModelRef("anthropic/claude-3-5-sonnet", "openai")).toEqual({
|
const cfg = {
|
||||||
provider: "anthropic",
|
agents: {
|
||||||
model: "claude-3-5-sonnet",
|
defaults: {
|
||||||
});
|
models: {
|
||||||
});
|
"openai/gpt-4": { alias: "gpt4" },
|
||||||
|
|
||||||
it("should use default provider if none specified", () => {
|
|
||||||
expect(parseModelRef("claude-3-5-sonnet", "anthropic")).toEqual({
|
|
||||||
provider: "anthropic",
|
|
||||||
model: "claude-3-5-sonnet",
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should return null for empty strings", () => {
|
|
||||||
expect(parseModelRef("", "anthropic")).toBeNull();
|
|
||||||
expect(parseModelRef(" ", "anthropic")).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should handle invalid slash usage", () => {
|
|
||||||
expect(parseModelRef("/", "anthropic")).toBeNull();
|
|
||||||
expect(parseModelRef("anthropic/", "anthropic")).toBeNull();
|
|
||||||
expect(parseModelRef("/model", "anthropic")).toBeNull();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("buildModelAliasIndex", () => {
|
|
||||||
it("should build alias index from config", () => {
|
|
||||||
const cfg: Partial<ClawdbotConfig> = {
|
|
||||||
agents: {
|
|
||||||
defaults: {
|
|
||||||
models: {
|
|
||||||
"anthropic/claude-3-5-sonnet": { alias: "fast" },
|
|
||||||
"openai/gpt-4o": { alias: "smart" },
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
},
|
||||||
|
} as ClawdbotConfig;
|
||||||
|
|
||||||
const index = buildModelAliasIndex({
|
const allowed = buildAllowedModelSet({
|
||||||
cfg: cfg as ClawdbotConfig,
|
cfg,
|
||||||
defaultProvider: "anthropic",
|
catalog,
|
||||||
});
|
defaultProvider: "claude-cli",
|
||||||
|
defaultModel: "opus-4.5",
|
||||||
expect(index.byAlias.get("fast")?.ref).toEqual({
|
|
||||||
provider: "anthropic",
|
|
||||||
model: "claude-3-5-sonnet",
|
|
||||||
});
|
|
||||||
expect(index.byAlias.get("smart")?.ref).toEqual({ provider: "openai", model: "gpt-4o" });
|
|
||||||
expect(index.byKey.get(modelKey("anthropic", "claude-3-5-sonnet"))).toEqual(["fast"]);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
expect(allowed.allowAny).toBe(false);
|
||||||
|
expect(allowed.allowedKeys.has(modelKey("openai", "gpt-4"))).toBe(true);
|
||||||
|
expect(allowed.allowedKeys.has(modelKey("claude-cli", "opus-4.5"))).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("resolveModelRefFromString", () => {
|
it("includes the default model when no allowlist is set", () => {
|
||||||
it("should resolve from string with alias", () => {
|
const cfg = {
|
||||||
const index = {
|
agents: { defaults: {} },
|
||||||
byAlias: new Map([
|
} as ClawdbotConfig;
|
||||||
["fast", { alias: "fast", ref: { provider: "anthropic", model: "sonnet" } }],
|
|
||||||
]),
|
|
||||||
byKey: new Map(),
|
|
||||||
};
|
|
||||||
|
|
||||||
const resolved = resolveModelRefFromString({
|
const allowed = buildAllowedModelSet({
|
||||||
raw: "fast",
|
cfg,
|
||||||
defaultProvider: "openai",
|
catalog,
|
||||||
aliasIndex: index,
|
defaultProvider: "claude-cli",
|
||||||
});
|
defaultModel: "opus-4.5",
|
||||||
|
|
||||||
expect(resolved?.ref).toEqual({ provider: "anthropic", model: "sonnet" });
|
|
||||||
expect(resolved?.alias).toBe("fast");
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should resolve direct ref if no alias match", () => {
|
expect(allowed.allowAny).toBe(true);
|
||||||
const resolved = resolveModelRefFromString({
|
expect(allowed.allowedKeys.has(modelKey("openai", "gpt-4"))).toBe(true);
|
||||||
raw: "openai/gpt-4",
|
expect(allowed.allowedKeys.has(modelKey("claude-cli", "opus-4.5"))).toBe(true);
|
||||||
defaultProvider: "anthropic",
|
|
||||||
});
|
|
||||||
expect(resolved?.ref).toEqual({ provider: "openai", model: "gpt-4" });
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("resolveConfiguredModelRef", () => {
|
it("allows explicit custom providers from models.providers", () => {
|
||||||
it("should fall back to anthropic and warn if provider is missing for non-alias", () => {
|
const cfg = {
|
||||||
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
agents: {
|
||||||
const cfg: Partial<ClawdbotConfig> = {
|
defaults: {
|
||||||
agents: {
|
models: {
|
||||||
defaults: {
|
"moonshot/kimi-k2-0905-preview": { alias: "kimi" },
|
||||||
model: "claude-3-5-sonnet",
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
},
|
||||||
|
models: {
|
||||||
|
mode: "merge",
|
||||||
|
providers: {
|
||||||
|
moonshot: {
|
||||||
|
baseUrl: "https://api.moonshot.ai/v1",
|
||||||
|
apiKey: "x",
|
||||||
|
api: "openai-completions",
|
||||||
|
models: [{ id: "kimi-k2-0905-preview", name: "Kimi" }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as ClawdbotConfig;
|
||||||
|
|
||||||
const result = resolveConfiguredModelRef({
|
const allowed = buildAllowedModelSet({
|
||||||
cfg: cfg as ClawdbotConfig,
|
cfg,
|
||||||
defaultProvider: "google",
|
catalog: [],
|
||||||
defaultModel: "gemini-pro",
|
defaultProvider: "anthropic",
|
||||||
});
|
defaultModel: "claude-opus-4-5",
|
||||||
|
|
||||||
expect(result).toEqual({ provider: "anthropic", model: "claude-3-5-sonnet" });
|
|
||||||
expect(warnSpy).toHaveBeenCalledWith(
|
|
||||||
expect.stringContaining('Falling back to "anthropic/claude-3-5-sonnet"'),
|
|
||||||
);
|
|
||||||
warnSpy.mockRestore();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should use default provider/model if config is empty", () => {
|
expect(allowed.allowAny).toBe(false);
|
||||||
const cfg: Partial<ClawdbotConfig> = {};
|
expect(allowed.allowedKeys.has(modelKey("moonshot", "kimi-k2-0905-preview"))).toBe(true);
|
||||||
const result = resolveConfiguredModelRef({
|
});
|
||||||
cfg: cfg as ClawdbotConfig,
|
});
|
||||||
defaultProvider: "openai",
|
|
||||||
defaultModel: "gpt-4",
|
describe("parseModelRef", () => {
|
||||||
|
it("normalizes anthropic/opus-4.5 to claude-opus-4-5", () => {
|
||||||
|
const ref = parseModelRef("anthropic/opus-4.5", "anthropic");
|
||||||
|
expect(ref).toEqual({
|
||||||
|
provider: "anthropic",
|
||||||
|
model: "claude-opus-4-5",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("normalizes google gemini 3 models to preview ids", () => {
|
||||||
|
expect(parseModelRef("google/gemini-3-pro", "anthropic")).toEqual({
|
||||||
|
provider: "google",
|
||||||
|
model: "gemini-3-pro-preview",
|
||||||
|
});
|
||||||
|
expect(parseModelRef("google/gemini-3-flash", "anthropic")).toEqual({
|
||||||
|
provider: "google",
|
||||||
|
model: "gemini-3-flash-preview",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("normalizes default-provider google models", () => {
|
||||||
|
expect(parseModelRef("gemini-3-pro", "google")).toEqual({
|
||||||
|
provider: "google",
|
||||||
|
model: "gemini-3-pro-preview",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("resolveHooksGmailModel", () => {
|
||||||
|
it("returns null when hooks.gmail.model is not set", () => {
|
||||||
|
const cfg = {} satisfies ClawdbotConfig;
|
||||||
|
const result = resolveHooksGmailModel({
|
||||||
|
cfg,
|
||||||
|
defaultProvider: DEFAULT_PROVIDER,
|
||||||
|
});
|
||||||
|
expect(result).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null when hooks.gmail.model is empty", () => {
|
||||||
|
const cfg = {
|
||||||
|
hooks: { gmail: { model: "" } },
|
||||||
|
} satisfies ClawdbotConfig;
|
||||||
|
const result = resolveHooksGmailModel({
|
||||||
|
cfg,
|
||||||
|
defaultProvider: DEFAULT_PROVIDER,
|
||||||
|
});
|
||||||
|
expect(result).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("parses provider/model from hooks.gmail.model", () => {
|
||||||
|
const cfg = {
|
||||||
|
hooks: { gmail: { model: "openrouter/meta-llama/llama-3.3-70b:free" } },
|
||||||
|
} satisfies ClawdbotConfig;
|
||||||
|
const result = resolveHooksGmailModel({
|
||||||
|
cfg,
|
||||||
|
defaultProvider: DEFAULT_PROVIDER,
|
||||||
|
});
|
||||||
|
expect(result).toEqual({
|
||||||
|
provider: "openrouter",
|
||||||
|
model: "meta-llama/llama-3.3-70b:free",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resolves alias from agent.models", () => {
|
||||||
|
const cfg = {
|
||||||
|
agents: {
|
||||||
|
defaults: {
|
||||||
|
models: {
|
||||||
|
"anthropic/claude-sonnet-4-1": { alias: "Sonnet" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
hooks: { gmail: { model: "Sonnet" } },
|
||||||
|
} satisfies ClawdbotConfig;
|
||||||
|
const result = resolveHooksGmailModel({
|
||||||
|
cfg,
|
||||||
|
defaultProvider: DEFAULT_PROVIDER,
|
||||||
|
});
|
||||||
|
expect(result).toEqual({
|
||||||
|
provider: "anthropic",
|
||||||
|
model: "claude-sonnet-4-1",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses default provider when model omits provider", () => {
|
||||||
|
const cfg = {
|
||||||
|
hooks: { gmail: { model: "claude-haiku-3-5" } },
|
||||||
|
} satisfies ClawdbotConfig;
|
||||||
|
const result = resolveHooksGmailModel({
|
||||||
|
cfg,
|
||||||
|
defaultProvider: "anthropic",
|
||||||
|
});
|
||||||
|
expect(result).toEqual({
|
||||||
|
provider: "anthropic",
|
||||||
|
model: "claude-haiku-3-5",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("resolveAllowedModelRef", () => {
|
||||||
|
it("resolves aliases when allowed", () => {
|
||||||
|
const cfg = {
|
||||||
|
agents: {
|
||||||
|
defaults: {
|
||||||
|
models: {
|
||||||
|
"anthropic/claude-sonnet-4-1": { alias: "Sonnet" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} satisfies ClawdbotConfig;
|
||||||
|
const resolved = resolveAllowedModelRef({
|
||||||
|
cfg,
|
||||||
|
catalog: [
|
||||||
|
{
|
||||||
|
provider: "anthropic",
|
||||||
|
id: "claude-sonnet-4-1",
|
||||||
|
name: "Sonnet",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
raw: "Sonnet",
|
||||||
|
defaultProvider: "anthropic",
|
||||||
|
defaultModel: "claude-opus-4-5",
|
||||||
|
});
|
||||||
|
expect("error" in resolved).toBe(false);
|
||||||
|
if ("ref" in resolved) {
|
||||||
|
expect(resolved.ref).toEqual({
|
||||||
|
provider: "anthropic",
|
||||||
|
model: "claude-sonnet-4-1",
|
||||||
});
|
});
|
||||||
expect(result).toEqual({ provider: "openai", model: "gpt-4" });
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects disallowed models", () => {
|
||||||
|
const cfg = {
|
||||||
|
agents: {
|
||||||
|
defaults: {
|
||||||
|
models: {
|
||||||
|
"openai/gpt-4": { alias: "GPT4" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} satisfies ClawdbotConfig;
|
||||||
|
const resolved = resolveAllowedModelRef({
|
||||||
|
cfg,
|
||||||
|
catalog: [
|
||||||
|
{ provider: "openai", id: "gpt-4", name: "GPT-4" },
|
||||||
|
{ provider: "anthropic", id: "claude-sonnet-4-1", name: "Sonnet" },
|
||||||
|
],
|
||||||
|
raw: "anthropic/claude-sonnet-4-1",
|
||||||
|
defaultProvider: "openai",
|
||||||
|
defaultModel: "gpt-4",
|
||||||
|
});
|
||||||
|
expect(resolved).toEqual({
|
||||||
|
error: "model not allowed: anthropic/claude-sonnet-4-1",
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -131,24 +131,14 @@ export function resolveConfiguredModelRef(params: {
|
|||||||
cfg: params.cfg,
|
cfg: params.cfg,
|
||||||
defaultProvider: params.defaultProvider,
|
defaultProvider: params.defaultProvider,
|
||||||
});
|
});
|
||||||
if (!trimmed.includes("/")) {
|
|
||||||
const aliasKey = normalizeAliasKey(trimmed);
|
|
||||||
const aliasMatch = aliasIndex.byAlias.get(aliasKey);
|
|
||||||
if (aliasMatch) return aliasMatch.ref;
|
|
||||||
|
|
||||||
// Default to anthropic if no provider is specified, but warn as this is deprecated.
|
|
||||||
console.warn(
|
|
||||||
`[clawdbot] Model "${trimmed}" specified without provider. Falling back to "anthropic/${trimmed}". Please use "anthropic/${trimmed}" in your config.`,
|
|
||||||
);
|
|
||||||
return { provider: "anthropic", model: trimmed };
|
|
||||||
}
|
|
||||||
|
|
||||||
const resolved = resolveModelRefFromString({
|
const resolved = resolveModelRefFromString({
|
||||||
raw: trimmed,
|
raw: trimmed,
|
||||||
defaultProvider: params.defaultProvider,
|
defaultProvider: params.defaultProvider,
|
||||||
aliasIndex,
|
aliasIndex,
|
||||||
});
|
});
|
||||||
if (resolved) return resolved.ref;
|
if (resolved) return resolved.ref;
|
||||||
|
// TODO(steipete): drop this fallback once provider-less agents.defaults.model is fully deprecated.
|
||||||
|
return { provider: "anthropic", model: trimmed };
|
||||||
}
|
}
|
||||||
return { provider: params.defaultProvider, model: params.defaultModel };
|
return { provider: params.defaultProvider, model: params.defaultModel };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,14 +27,4 @@ describe("sanitizeUserFacingText", () => {
|
|||||||
const raw = '{"type":"error","error":{"message":"Something exploded","type":"server_error"}}';
|
const raw = '{"type":"error","error":{"message":"Something exploded","type":"server_error"}}';
|
||||||
expect(sanitizeUserFacingText(raw)).toBe("LLM error server_error: Something exploded");
|
expect(sanitizeUserFacingText(raw)).toBe("LLM error server_error: Something exploded");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("collapses consecutive duplicate paragraphs", () => {
|
|
||||||
const text = "Hello there!\n\nHello there!";
|
|
||||||
expect(sanitizeUserFacingText(text)).toBe("Hello there!");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does not collapse distinct paragraphs", () => {
|
|
||||||
const text = "Hello there!\n\nDifferent line.";
|
|
||||||
expect(sanitizeUserFacingText(text)).toBe(text);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
@@ -77,29 +77,6 @@ function stripFinalTagsFromText(text: string): string {
|
|||||||
return text.replace(FINAL_TAG_RE, "");
|
return text.replace(FINAL_TAG_RE, "");
|
||||||
}
|
}
|
||||||
|
|
||||||
function collapseConsecutiveDuplicateBlocks(text: string): string {
|
|
||||||
const trimmed = text.trim();
|
|
||||||
if (!trimmed) return text;
|
|
||||||
const blocks = trimmed.split(/\n{2,}/);
|
|
||||||
if (blocks.length < 2) return text;
|
|
||||||
|
|
||||||
const normalizeBlock = (value: string) => value.trim().replace(/\s+/g, " ");
|
|
||||||
const result: string[] = [];
|
|
||||||
let lastNormalized: string | null = null;
|
|
||||||
|
|
||||||
for (const block of blocks) {
|
|
||||||
const normalized = normalizeBlock(block);
|
|
||||||
if (lastNormalized && normalized === lastNormalized) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
result.push(block.trim());
|
|
||||||
lastNormalized = normalized;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (result.length === blocks.length) return text;
|
|
||||||
return result.join("\n\n");
|
|
||||||
}
|
|
||||||
|
|
||||||
function isLikelyHttpErrorText(raw: string): boolean {
|
function isLikelyHttpErrorText(raw: string): boolean {
|
||||||
const match = raw.match(HTTP_STATUS_PREFIX_RE);
|
const match = raw.match(HTTP_STATUS_PREFIX_RE);
|
||||||
if (!match) return false;
|
if (!match) return false;
|
||||||
@@ -344,7 +321,7 @@ export function sanitizeUserFacingText(text: string): string {
|
|||||||
return formatRawAssistantErrorForUi(trimmed);
|
return formatRawAssistantErrorForUi(trimmed);
|
||||||
}
|
}
|
||||||
|
|
||||||
return collapseConsecutiveDuplicateBlocks(stripped);
|
return stripped;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isRateLimitAssistantError(msg: AssistantMessage | undefined): boolean {
|
export function isRateLimitAssistantError(msg: AssistantMessage | undefined): boolean {
|
||||||
|
|||||||
@@ -599,7 +599,7 @@ export async function runEmbeddedPiAgent(
|
|||||||
verboseLevel: params.verboseLevel,
|
verboseLevel: params.verboseLevel,
|
||||||
reasoningLevel: params.reasoningLevel,
|
reasoningLevel: params.reasoningLevel,
|
||||||
toolResultFormat: resolvedToolResultFormat,
|
toolResultFormat: resolvedToolResultFormat,
|
||||||
inlineToolResultsAllowed: false,
|
inlineToolResultsAllowed: !params.onPartialReply && !params.onToolResult,
|
||||||
});
|
});
|
||||||
|
|
||||||
log.debug(
|
log.debug(
|
||||||
|
|||||||
@@ -32,8 +32,6 @@ export function isHeartbeatContentEffectivelyEmpty(content: string | undefined |
|
|||||||
// This intentionally does NOT skip lines like "#TODO" or "#hashtag" which might be content
|
// This intentionally does NOT skip lines like "#TODO" or "#hashtag" which might be content
|
||||||
// (Those aren't valid markdown headers - ATX headers require space after #)
|
// (Those aren't valid markdown headers - ATX headers require space after #)
|
||||||
if (/^#+(\s|$)/.test(trimmed)) continue;
|
if (/^#+(\s|$)/.test(trimmed)) continue;
|
||||||
// Skip empty markdown list items like "- [ ]" or "* [ ]" or just "- "
|
|
||||||
if (/^[-*+]\s*(\[[\sXx]?\]\s*)?$/.test(trimmed)) continue;
|
|
||||||
// Found a non-empty, non-comment line - there's actionable content
|
// Found a non-empty, non-comment line - there's actionable content
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,20 +16,6 @@ import { resetBashChatCommandForTests } from "./bash-command.js";
|
|||||||
import { buildCommandContext, handleCommands } from "./commands.js";
|
import { buildCommandContext, handleCommands } from "./commands.js";
|
||||||
import { parseInlineDirectives } from "./directive-handling.js";
|
import { parseInlineDirectives } from "./directive-handling.js";
|
||||||
|
|
||||||
// Avoid expensive workspace scans during /context tests.
|
|
||||||
vi.mock("./commands-context-report.js", () => ({
|
|
||||||
buildContextReply: async (params: { command: { commandBodyNormalized: string } }) => {
|
|
||||||
const normalized = params.command.commandBodyNormalized;
|
|
||||||
if (normalized === "/context list") {
|
|
||||||
return { text: "Injected workspace files:\n- AGENTS.md" };
|
|
||||||
}
|
|
||||||
if (normalized === "/context detail") {
|
|
||||||
return { text: "Context breakdown (detailed)\nTop tools (schema size):" };
|
|
||||||
}
|
|
||||||
return { text: "/context\n- /context list\nInline shortcut" };
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
|
|
||||||
let testWorkspaceDir = os.tmpdir();
|
let testWorkspaceDir = os.tmpdir();
|
||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
|
|||||||
@@ -138,38 +138,6 @@ describe("dispatchReplyFromConfig", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("does not provide onToolResult when routing cross-provider", async () => {
|
|
||||||
mocks.tryFastAbortFromMessage.mockResolvedValue({
|
|
||||||
handled: false,
|
|
||||||
aborted: false,
|
|
||||||
});
|
|
||||||
mocks.routeReply.mockClear();
|
|
||||||
const cfg = {} as ClawdbotConfig;
|
|
||||||
const dispatcher = createDispatcher();
|
|
||||||
const ctx = buildTestCtx({
|
|
||||||
Provider: "slack",
|
|
||||||
OriginatingChannel: "telegram",
|
|
||||||
OriginatingTo: "telegram:999",
|
|
||||||
});
|
|
||||||
|
|
||||||
const replyResolver = async (
|
|
||||||
_ctx: MsgContext,
|
|
||||||
opts: GetReplyOptions | undefined,
|
|
||||||
_cfg: ClawdbotConfig,
|
|
||||||
) => {
|
|
||||||
expect(opts?.onToolResult).toBeUndefined();
|
|
||||||
return { text: "hi" } satisfies ReplyPayload;
|
|
||||||
};
|
|
||||||
|
|
||||||
await dispatchReplyFromConfig({ ctx, cfg, dispatcher, replyResolver });
|
|
||||||
|
|
||||||
expect(mocks.routeReply).toHaveBeenCalledWith(
|
|
||||||
expect.objectContaining({
|
|
||||||
payload: expect.objectContaining({ text: "hi" }),
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("fast-aborts without calling the reply resolver", async () => {
|
it("fast-aborts without calling the reply resolver", async () => {
|
||||||
mocks.tryFastAbortFromMessage.mockResolvedValue({
|
mocks.tryFastAbortFromMessage.mockResolvedValue({
|
||||||
handled: true,
|
handled: true,
|
||||||
|
|||||||
@@ -206,7 +206,6 @@ export async function dispatchReplyFromConfig(params: {
|
|||||||
const sendPayloadAsync = async (
|
const sendPayloadAsync = async (
|
||||||
payload: ReplyPayload,
|
payload: ReplyPayload,
|
||||||
abortSignal?: AbortSignal,
|
abortSignal?: AbortSignal,
|
||||||
mirror?: boolean,
|
|
||||||
): Promise<void> => {
|
): Promise<void> => {
|
||||||
// TypeScript doesn't narrow these from the shouldRouteToOriginating check,
|
// TypeScript doesn't narrow these from the shouldRouteToOriginating check,
|
||||||
// but they're guaranteed non-null when this function is called.
|
// but they're guaranteed non-null when this function is called.
|
||||||
@@ -221,7 +220,6 @@ export async function dispatchReplyFromConfig(params: {
|
|||||||
threadId: ctx.MessageThreadId,
|
threadId: ctx.MessageThreadId,
|
||||||
cfg,
|
cfg,
|
||||||
abortSignal,
|
abortSignal,
|
||||||
mirror,
|
|
||||||
});
|
});
|
||||||
if (!result.ok) {
|
if (!result.ok) {
|
||||||
logVerbose(`dispatch-from-config: route-reply failed: ${result.error ?? "unknown error"}`);
|
logVerbose(`dispatch-from-config: route-reply failed: ${result.error ?? "unknown error"}`);
|
||||||
@@ -270,6 +268,24 @@ export async function dispatchReplyFromConfig(params: {
|
|||||||
ctx,
|
ctx,
|
||||||
{
|
{
|
||||||
...params.replyOptions,
|
...params.replyOptions,
|
||||||
|
onToolResult: (payload: ReplyPayload) => {
|
||||||
|
const run = async () => {
|
||||||
|
const ttsPayload = await maybeApplyTtsToPayload({
|
||||||
|
payload,
|
||||||
|
cfg,
|
||||||
|
channel: ttsChannel,
|
||||||
|
kind: "tool",
|
||||||
|
inboundAudio,
|
||||||
|
ttsAuto: sessionTtsAuto,
|
||||||
|
});
|
||||||
|
if (shouldRouteToOriginating) {
|
||||||
|
await sendPayloadAsync(ttsPayload);
|
||||||
|
} else {
|
||||||
|
dispatcher.sendToolResult(ttsPayload);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
return run();
|
||||||
|
},
|
||||||
onBlockReply: (payload: ReplyPayload, context) => {
|
onBlockReply: (payload: ReplyPayload, context) => {
|
||||||
const run = async () => {
|
const run = async () => {
|
||||||
const ttsPayload = await maybeApplyTtsToPayload({
|
const ttsPayload = await maybeApplyTtsToPayload({
|
||||||
@@ -281,7 +297,7 @@ export async function dispatchReplyFromConfig(params: {
|
|||||||
ttsAuto: sessionTtsAuto,
|
ttsAuto: sessionTtsAuto,
|
||||||
});
|
});
|
||||||
if (shouldRouteToOriginating) {
|
if (shouldRouteToOriginating) {
|
||||||
await sendPayloadAsync(ttsPayload, context?.abortSignal, false);
|
await sendPayloadAsync(ttsPayload, context?.abortSignal);
|
||||||
} else {
|
} else {
|
||||||
dispatcher.sendBlockReply(ttsPayload);
|
dispatcher.sendBlockReply(ttsPayload);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -380,23 +380,6 @@ describe("routeReply", () => {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("skips mirror data when mirror is false", async () => {
|
|
||||||
mocks.deliverOutboundPayloads.mockResolvedValue([]);
|
|
||||||
await routeReply({
|
|
||||||
payload: { text: "hi" },
|
|
||||||
channel: "slack",
|
|
||||||
to: "channel:C123",
|
|
||||||
sessionKey: "agent:main:main",
|
|
||||||
mirror: false,
|
|
||||||
cfg: {} as never,
|
|
||||||
});
|
|
||||||
expect(mocks.deliverOutboundPayloads).toHaveBeenCalledWith(
|
|
||||||
expect.objectContaining({
|
|
||||||
mirror: undefined,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const emptyRegistry = createRegistry([]);
|
const emptyRegistry = createRegistry([]);
|
||||||
|
|||||||
@@ -33,8 +33,6 @@ export type RouteReplyParams = {
|
|||||||
cfg: ClawdbotConfig;
|
cfg: ClawdbotConfig;
|
||||||
/** Optional abort signal for cooperative cancellation. */
|
/** Optional abort signal for cooperative cancellation. */
|
||||||
abortSignal?: AbortSignal;
|
abortSignal?: AbortSignal;
|
||||||
/** Mirror reply into session transcript (default: true when sessionKey is set). */
|
|
||||||
mirror?: boolean;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export type RouteReplyResult = {
|
export type RouteReplyResult = {
|
||||||
@@ -120,15 +118,14 @@ export async function routeReply(params: RouteReplyParams): Promise<RouteReplyRe
|
|||||||
replyToId: resolvedReplyToId ?? null,
|
replyToId: resolvedReplyToId ?? null,
|
||||||
threadId: resolvedThreadId,
|
threadId: resolvedThreadId,
|
||||||
abortSignal,
|
abortSignal,
|
||||||
mirror:
|
mirror: params.sessionKey
|
||||||
params.mirror !== false && params.sessionKey
|
? {
|
||||||
? {
|
sessionKey: params.sessionKey,
|
||||||
sessionKey: params.sessionKey,
|
agentId: resolveSessionAgentId({ sessionKey: params.sessionKey, config: cfg }),
|
||||||
agentId: resolveSessionAgentId({ sessionKey: params.sessionKey, config: cfg }),
|
text,
|
||||||
text,
|
mediaUrls,
|
||||||
mediaUrls,
|
}
|
||||||
}
|
: undefined,
|
||||||
: undefined,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const last = results.at(-1);
|
const last = results.at(-1);
|
||||||
|
|||||||
@@ -100,7 +100,6 @@ export async function runDaemonInstall(opts: DaemonInstallOptions) {
|
|||||||
if (json) warnings.push(message);
|
if (json) warnings.push(message);
|
||||||
else defaultRuntime.log(message);
|
else defaultRuntime.log(message);
|
||||||
},
|
},
|
||||||
config: cfg,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ import {
|
|||||||
} from "./daemon-runtime.js";
|
} from "./daemon-runtime.js";
|
||||||
import { guardCancel } from "./onboard-helpers.js";
|
import { guardCancel } from "./onboard-helpers.js";
|
||||||
import { ensureSystemdUserLingerInteractive } from "./systemd-linger.js";
|
import { ensureSystemdUserLingerInteractive } from "./systemd-linger.js";
|
||||||
import { loadConfig } from "../config/config.js";
|
|
||||||
|
|
||||||
export async function maybeInstallDaemon(params: {
|
export async function maybeInstallDaemon(params: {
|
||||||
runtime: RuntimeEnv;
|
runtime: RuntimeEnv;
|
||||||
@@ -82,14 +81,12 @@ export async function maybeInstallDaemon(params: {
|
|||||||
|
|
||||||
progress.setLabel("Preparing Gateway service…");
|
progress.setLabel("Preparing Gateway service…");
|
||||||
|
|
||||||
const cfg = loadConfig();
|
|
||||||
const { programArguments, workingDirectory, environment } = await buildGatewayInstallPlan({
|
const { programArguments, workingDirectory, environment } = await buildGatewayInstallPlan({
|
||||||
env: process.env,
|
env: process.env,
|
||||||
port: params.port,
|
port: params.port,
|
||||||
token: params.gatewayToken,
|
token: params.gatewayToken,
|
||||||
runtime: daemonRuntime,
|
runtime: daemonRuntime,
|
||||||
warn: (message, title) => note(message, title),
|
warn: (message, title) => note(message, title),
|
||||||
config: cfg,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
progress.setLabel("Installing Gateway service…");
|
progress.setLabel("Installing Gateway service…");
|
||||||
|
|||||||
@@ -95,140 +95,6 @@ describe("buildGatewayInstallPlan", () => {
|
|||||||
expect(warn).toHaveBeenCalledWith("Node too old", "Gateway runtime");
|
expect(warn).toHaveBeenCalledWith("Node too old", "Gateway runtime");
|
||||||
expect(mocks.resolvePreferredNodePath).toHaveBeenCalled();
|
expect(mocks.resolvePreferredNodePath).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("merges config env vars into the environment", async () => {
|
|
||||||
mocks.resolvePreferredNodePath.mockResolvedValue("/opt/node");
|
|
||||||
mocks.resolveGatewayProgramArguments.mockResolvedValue({
|
|
||||||
programArguments: ["node", "gateway"],
|
|
||||||
workingDirectory: "/Users/me",
|
|
||||||
});
|
|
||||||
mocks.resolveSystemNodeInfo.mockResolvedValue({
|
|
||||||
path: "/opt/node",
|
|
||||||
version: "22.0.0",
|
|
||||||
supported: true,
|
|
||||||
});
|
|
||||||
mocks.buildServiceEnvironment.mockReturnValue({
|
|
||||||
CLAWDBOT_PORT: "3000",
|
|
||||||
HOME: "/Users/me",
|
|
||||||
});
|
|
||||||
|
|
||||||
const plan = await buildGatewayInstallPlan({
|
|
||||||
env: {},
|
|
||||||
port: 3000,
|
|
||||||
runtime: "node",
|
|
||||||
config: {
|
|
||||||
env: {
|
|
||||||
vars: {
|
|
||||||
GOOGLE_API_KEY: "test-key",
|
|
||||||
},
|
|
||||||
CUSTOM_VAR: "custom-value",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// Config env vars should be present
|
|
||||||
expect(plan.environment.GOOGLE_API_KEY).toBe("test-key");
|
|
||||||
expect(plan.environment.CUSTOM_VAR).toBe("custom-value");
|
|
||||||
// Service environment vars should take precedence
|
|
||||||
expect(plan.environment.CLAWDBOT_PORT).toBe("3000");
|
|
||||||
expect(plan.environment.HOME).toBe("/Users/me");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does not include empty config env values", async () => {
|
|
||||||
mocks.resolvePreferredNodePath.mockResolvedValue("/opt/node");
|
|
||||||
mocks.resolveGatewayProgramArguments.mockResolvedValue({
|
|
||||||
programArguments: ["node", "gateway"],
|
|
||||||
workingDirectory: "/Users/me",
|
|
||||||
});
|
|
||||||
mocks.resolveSystemNodeInfo.mockResolvedValue({
|
|
||||||
path: "/opt/node",
|
|
||||||
version: "22.0.0",
|
|
||||||
supported: true,
|
|
||||||
});
|
|
||||||
mocks.buildServiceEnvironment.mockReturnValue({ CLAWDBOT_PORT: "3000" });
|
|
||||||
|
|
||||||
const plan = await buildGatewayInstallPlan({
|
|
||||||
env: {},
|
|
||||||
port: 3000,
|
|
||||||
runtime: "node",
|
|
||||||
config: {
|
|
||||||
env: {
|
|
||||||
vars: {
|
|
||||||
VALID_KEY: "valid",
|
|
||||||
EMPTY_KEY: "",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(plan.environment.VALID_KEY).toBe("valid");
|
|
||||||
expect(plan.environment.EMPTY_KEY).toBeUndefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("drops whitespace-only config env values", async () => {
|
|
||||||
mocks.resolvePreferredNodePath.mockResolvedValue("/opt/node");
|
|
||||||
mocks.resolveGatewayProgramArguments.mockResolvedValue({
|
|
||||||
programArguments: ["node", "gateway"],
|
|
||||||
workingDirectory: "/Users/me",
|
|
||||||
});
|
|
||||||
mocks.resolveSystemNodeInfo.mockResolvedValue({
|
|
||||||
path: "/opt/node",
|
|
||||||
version: "22.0.0",
|
|
||||||
supported: true,
|
|
||||||
});
|
|
||||||
mocks.buildServiceEnvironment.mockReturnValue({});
|
|
||||||
|
|
||||||
const plan = await buildGatewayInstallPlan({
|
|
||||||
env: {},
|
|
||||||
port: 3000,
|
|
||||||
runtime: "node",
|
|
||||||
config: {
|
|
||||||
env: {
|
|
||||||
vars: {
|
|
||||||
VALID_KEY: "valid",
|
|
||||||
},
|
|
||||||
TRIMMED_KEY: " ",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(plan.environment.VALID_KEY).toBe("valid");
|
|
||||||
expect(plan.environment.TRIMMED_KEY).toBeUndefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("keeps service env values over config env vars", async () => {
|
|
||||||
mocks.resolvePreferredNodePath.mockResolvedValue("/opt/node");
|
|
||||||
mocks.resolveGatewayProgramArguments.mockResolvedValue({
|
|
||||||
programArguments: ["node", "gateway"],
|
|
||||||
workingDirectory: "/Users/me",
|
|
||||||
});
|
|
||||||
mocks.resolveSystemNodeInfo.mockResolvedValue({
|
|
||||||
path: "/opt/node",
|
|
||||||
version: "22.0.0",
|
|
||||||
supported: true,
|
|
||||||
});
|
|
||||||
mocks.buildServiceEnvironment.mockReturnValue({
|
|
||||||
HOME: "/Users/service",
|
|
||||||
CLAWDBOT_PORT: "3000",
|
|
||||||
});
|
|
||||||
|
|
||||||
const plan = await buildGatewayInstallPlan({
|
|
||||||
env: {},
|
|
||||||
port: 3000,
|
|
||||||
runtime: "node",
|
|
||||||
config: {
|
|
||||||
env: {
|
|
||||||
HOME: "/Users/config",
|
|
||||||
vars: {
|
|
||||||
CLAWDBOT_PORT: "9999",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(plan.environment.HOME).toBe("/Users/service");
|
|
||||||
expect(plan.environment.CLAWDBOT_PORT).toBe("3000");
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("gatewayInstallErrorHint", () => {
|
describe("gatewayInstallErrorHint", () => {
|
||||||
|
|||||||
@@ -7,8 +7,6 @@ import {
|
|||||||
} from "../daemon/runtime-paths.js";
|
} from "../daemon/runtime-paths.js";
|
||||||
import { buildServiceEnvironment } from "../daemon/service-env.js";
|
import { buildServiceEnvironment } from "../daemon/service-env.js";
|
||||||
import { formatCliCommand } from "../cli/command-format.js";
|
import { formatCliCommand } from "../cli/command-format.js";
|
||||||
import { collectConfigEnvVars } from "../config/env-vars.js";
|
|
||||||
import type { ClawdbotConfig } from "../config/types.js";
|
|
||||||
import type { GatewayDaemonRuntime } from "./daemon-runtime.js";
|
import type { GatewayDaemonRuntime } from "./daemon-runtime.js";
|
||||||
|
|
||||||
type WarnFn = (message: string, title?: string) => void;
|
type WarnFn = (message: string, title?: string) => void;
|
||||||
@@ -33,8 +31,6 @@ export async function buildGatewayInstallPlan(params: {
|
|||||||
devMode?: boolean;
|
devMode?: boolean;
|
||||||
nodePath?: string;
|
nodePath?: string;
|
||||||
warn?: WarnFn;
|
warn?: WarnFn;
|
||||||
/** Full config to extract env vars from (env vars + inline env keys). */
|
|
||||||
config?: ClawdbotConfig;
|
|
||||||
}): Promise<GatewayInstallPlan> {
|
}): Promise<GatewayInstallPlan> {
|
||||||
const devMode = params.devMode ?? resolveGatewayDevMode();
|
const devMode = params.devMode ?? resolveGatewayDevMode();
|
||||||
const nodePath =
|
const nodePath =
|
||||||
@@ -54,7 +50,7 @@ export async function buildGatewayInstallPlan(params: {
|
|||||||
const warning = renderSystemNodeWarning(systemNode, programArguments[0]);
|
const warning = renderSystemNodeWarning(systemNode, programArguments[0]);
|
||||||
if (warning) params.warn?.(warning, "Gateway runtime");
|
if (warning) params.warn?.(warning, "Gateway runtime");
|
||||||
}
|
}
|
||||||
const serviceEnvironment = buildServiceEnvironment({
|
const environment = buildServiceEnvironment({
|
||||||
env: params.env,
|
env: params.env,
|
||||||
port: params.port,
|
port: params.port,
|
||||||
token: params.token,
|
token: params.token,
|
||||||
@@ -64,13 +60,6 @@ export async function buildGatewayInstallPlan(params: {
|
|||||||
: undefined,
|
: undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Merge config env vars into the service environment (vars + inline env keys).
|
|
||||||
// Config env vars are added first so service-specific vars take precedence.
|
|
||||||
const environment: Record<string, string | undefined> = {
|
|
||||||
...collectConfigEnvVars(params.config),
|
|
||||||
};
|
|
||||||
Object.assign(environment, serviceEnvironment);
|
|
||||||
|
|
||||||
return { programArguments, workingDirectory, environment };
|
return { programArguments, workingDirectory, environment };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Loaded 100 of 167 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user