Compare commits

..
Author SHA1 Message Date
Peter Steinberger 984499197d fix: drop stale ts-expect-error in pdf input parsing 2026-01-20 08:51:32 +00:00
Peter Steinberger f78c6fb785 fix: enforce tool-dispatch policy + media (#1235) (thanks @dougvk) 2026-01-20 08:46:11 +00:00
Doug von Kohorn 872911a176 feat: add tool_result_persist hook 2026-01-20 08:45:06 +00:00
Doug von Kohorn 1e37efb13a feat: tool-dispatch skill commands 2026-01-20 08:45:06 +00:00
Peter SteinbergerandAaron 074db1905a fix: refactor TUI stream assembly (#1202, thanks @aaronveklabs)
Co-authored-by: Aaron <aaron@vektor-labs.com>
2026-01-20 08:36:54 +00:00
Aaron 9609a3af40 fix: check for error before early return in extractContentFromMessage
Addresses Codex review feedback - ensures error messages are surfaced
even when content is missing/undefined
2026-01-20 08:36:36 +00:00
Aaron 476087f879 fix(tui): buffer streaming messages by runId to prevent render ordering issues
Fixes #1172

- Add per-runId message buffering in ChatLog
- Separate thinking stream from content stream handling
- Ensure proper sequencing (thinking always before content)
- Model-agnostic: works with or without thinking tokens
2026-01-20 08:36:36 +00:00
Peter Steinberger 41b696fa83 chore: remove Peekaboo submodule 2026-01-20 08:31:06 +00:00
Peter Steinberger a5adedea91 refactor: add aws-sdk auth mode and tighten provider auth 2026-01-20 08:28:40 +00:00
Peter Steinberger 9266e542ab chore: remove peekaboo submodule references 2026-01-20 08:26:56 +00:00
Peter Steinberger f06ad4502b refactor: share responses input handling 2026-01-20 08:21:57 +00:00
Peter Steinberger e26c647828 fix: defer pdf deps and profile flag detection 2026-01-20 08:20:07 +00:00
Peter Steinberger bee72f1ae0 fix: guard systemd errors in doctor 2026-01-20 07:56:22 +00:00
Peter Steinberger 509bc81e28 Merge pull request #1288 from bradleypriest/pr/chat-session-url
ui(chat): persist session in URL and stabilize picker
2026-01-20 07:44:33 +00:00
Peter Steinberger 6d5195c890 refactor: normalize cli command hints 2026-01-20 07:43:00 +00:00
Peter Steinberger 11b9b6dba5 Merge pull request #1229 from RyanLisse/main
feat(gateway): add OpenResponses /v1/responses endpoint
2026-01-20 07:38:18 +00:00
Peter Steinberger bbc67f3754 fix: expand /v1/responses inputs (#1229) (thanks @RyanLisse) 2026-01-20 07:37:30 +00:00
Ryan Lisse 4f02c74dca test(gateway): add OpenResponses parity E2E tests
- Add schema validation tests for input_image, input_file, client tools
- Add buildAgentPrompt tests for turn-based tool flow
2026-01-20 07:37:01 +00:00
Ryan Lisse a5afe7bc2b feat(gateway): implement OpenResponses /v1/responses endpoint phase 2
- Add input_image and input_file support with SSRF protection
- Add client-side tools (Hosted Tools) support
- Add turn-based tool flow with function_call_output handling
- Export buildAgentPrompt for testing
2026-01-20 07:37:01 +00:00
Ryan LisseandClaude Opus 4.5 f4b03599f0 feat(gateway): add OpenResponses /v1/responses endpoint
Add a new `/v1/responses` endpoint implementing the OpenResponses API
standard for agentic workflows. This provides:

- Item-based input (messages, function_call_output, reasoning)
- Semantic streaming events (response.created, response.output_text.delta,
  response.completed, etc.)
- Full SSE event support with both event: and data: lines
- Configuration via gateway.http.endpoints.responses.enabled

The endpoint is disabled by default and can be enabled independently
from the existing Chat Completions endpoint.

Phase 1 implementation supports:
- String or ItemParam[] input
- system/developer/user/assistant message roles
- function_call_output items
- instructions parameter
- Agent routing via headers or model parameter
- Session key management

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-20 07:37:01 +00:00
Ryan Lisse 7f6e87e918 build: update bundle hash and register Peekaboo submodule 2026-01-20 07:37:01 +00:00
Ryan Lisse e14ff8f407 fix(ios): replace FileManager.default with FileManager() for Swift 6.2 compatibility 2026-01-20 07:37:01 +00:00
Ryan Lisse 87d995bcde refactor(macos): replace FileManager.default for Swift 6 2026-01-20 07:37:01 +00:00
Peter Steinberger 44d55667de docs: optimize PR review calls 2026-01-20 07:34:15 +00:00
Peter Steinberger 3e546e691d fix: infer perplexity baseUrl from api key 2026-01-20 07:27:32 +00:00
Peter Steinberger 52e9450a79 docs: require git pull before review 2026-01-20 07:18:26 +00:00
Peter Steinberger aa7c291ad6 Merge pull request #1289 from clawdbot/docs/bedrock-docs
docs: add Bedrock provider links
2026-01-20 07:14:37 +00:00
Peter Steinberger 3f44f4167b fix: update Bedrock provider docs (#1289) (thanks @steipete) 2026-01-20 07:14:03 +00:00
Peter Steinberger 90a6ec12cc docs: update bedrock provider docs 2026-01-20 07:08:12 +00:00
Peter Steinberger a91c653d8a Merge pull request #1286 from alauppe/feat/bedrock-converse-stream-api
feat(models): Add AWS Bedrock Converse Stream API support
2026-01-20 07:03:27 +00:00
Peter Steinberger 7f6fcbf637 fix: stabilize update.run test 2026-01-20 06:56:10 +00:00
Andrew Lauppe 2dee177c9b fix(models): attach provider to inline model definitions
When resolving models from custom provider configurations, ensure the
provider name is attached to each inline model entry. This fixes model
resolution for custom providers where the model definition exists in
the config but lacks an explicit provider field.

Without this fix, inline models from custom providers (like amazon-bedrock)
would fail to resolve because the provider context was lost during the
flatMap operation.
2026-01-20 01:28:13 -05:00
Andrew Lauppe a793523b74 feat(models): add bedrock-converse-stream API type
Add AWS Bedrock Converse Stream API to the list of supported model APIs,
enabling custom provider configurations for Amazon Bedrock endpoints.

This allows users to configure Bedrock models in their clawdbot.json:

  "models": {
    "providers": {
      "amazon-bedrock": {
        "baseUrl": "https://bedrock-runtime.us-east-1.amazonaws.com",
        "api": "bedrock-converse-stream",
        "models": [...]
      }
    }
  }

The underlying adapter already exists; this change exposes it as a valid
configuration option.
2026-01-20 01:28:07 -05:00
Bradley Priest 5b0684ebcf ui(chat): persist session in URL and stabilize picker
- Keep the selected chat session in ?session=... for deep links and reloads.\n- Only apply the query param on the Chat tab (avoid leaking it across navigation).\n- Render session <option> entries with stable keys to prevent label glitches.
2026-01-20 19:17:03 +13:00
249 changed files with 6704 additions and 790 deletions

No files matched your search

BIN
View File
Binary file not shown.
+366
View File
@@ -0,0 +1,366 @@
---
description: Update Clawdbot from upstream when branch has diverged (ahead/behind)
---
# Clawdbot Upstream Sync Workflow
Use this workflow when your fork has diverged from upstream (e.g., "18 commits ahead, 29 commits behind").
## Quick Reference
```bash
# Check divergence status
git fetch upstream && git rev-list --left-right --count main...upstream/main
# Full sync (rebase preferred)
git fetch upstream && git rebase upstream/main && pnpm install && pnpm build && ./scripts/restart-mac.sh
# Check for Swift 6.2 issues after sync
grep -r "FileManager\.default\|Thread\.isMainThread" src/ apps/ --include="*.swift"
```
---
## Step 1: Assess Divergence
```bash
git fetch upstream
git log --oneline --left-right main...upstream/main | head -20
```
This shows:
- `<` = your local commits (ahead)
- `>` = upstream commits you're missing (behind)
**Decision point:**
- Few local commits, many upstream → **Rebase** (cleaner history)
- Many local commits or shared branch → **Merge** (preserves history)
---
## Step 2A: Rebase Strategy (Preferred)
Replays your commits on top of upstream. Results in linear history.
```bash
# Ensure working tree is clean
git status
# Rebase onto upstream
git rebase upstream/main
```
### Handling Rebase Conflicts
```bash
# When conflicts occur:
# 1. Fix conflicts in the listed files
# 2. Stage resolved files
git add <resolved-files>
# 3. Continue rebase
git rebase --continue
# If a commit is no longer needed (already in upstream):
git rebase --skip
# To abort and return to original state:
git rebase --abort
```
### Common Conflict Patterns
| File | Resolution |
|------|------------|
| `package.json` | Take upstream deps, keep local scripts if needed |
| `pnpm-lock.yaml` | Accept upstream, regenerate with `pnpm install` |
| `*.patch` files | Usually take upstream version |
| Source files | Merge logic carefully, prefer upstream structure |
---
## Step 2B: Merge Strategy (Alternative)
Preserves all history with a merge commit.
```bash
git merge upstream/main --no-edit
```
Resolve conflicts same as rebase, then:
```bash
git add <resolved-files>
git commit
```
---
## Step 3: Rebuild Everything
After sync completes:
```bash
# Install dependencies (regenerates lock if needed)
pnpm install
# Build TypeScript
pnpm build
# Build UI assets
pnpm ui:build
# Run diagnostics
pnpm clawdbot doctor
```
---
## Step 4: Rebuild macOS App
```bash
# Full rebuild, sign, and launch
./scripts/restart-mac.sh
# Or just package without restart
pnpm mac:package
```
### Install to /Applications
```bash
# Kill running app
pkill -x "Clawdbot" || true
# Move old version
mv /Applications/Clawdbot.app /tmp/Clawdbot-backup.app
# Install new build
cp -R dist/Clawdbot.app /Applications/
# Launch
open /Applications/Clawdbot.app
```
---
## Step 4A: Verify macOS App & Agent
After rebuilding the macOS app, always verify it works correctly:
```bash
# Check gateway health
pnpm clawdbot health
# Verify no zombie processes
ps aux | grep -E "(clawdbot|gateway)" | grep -v grep
# Test agent functionality by sending a verification message
pnpm clawdbot agent --message "Verification: macOS app rebuild successful - agent is responding." --session-id YOUR_TELEGRAM_SESSION_ID
# Confirm the message was received on Telegram
# (Check your Telegram chat with the bot)
```
**Important:** Always wait for the Telegram verification message before proceeding. If the agent doesn't respond, troubleshoot the gateway or model configuration before pushing.
---
## Step 5: Handle Swift/macOS Build Issues (Common After Upstream Sync)
Upstream updates may introduce Swift 6.2 / macOS 26 SDK incompatibilities. Use analyze-mode for systematic debugging:
### Analyze-Mode Investigation
```bash
# Gather context with parallel agents
morph-mcp_warpgrep_codebase_search search_string="Find deprecated FileManager.default and Thread.isMainThread usages in Swift files" repo_path="/Volumes/Main SSD/Developer/clawdis"
morph-mcp_warpgrep_codebase_search search_string="Locate Peekaboo submodule and macOS app Swift files with concurrency issues" repo_path="/Volumes/Main SSD/Developer/clawdis"
```
### Common Swift 6.2 Fixes
**FileManager.default Deprecation:**
```bash
# Search for deprecated usage
grep -r "FileManager\.default" src/ apps/ --include="*.swift"
# Replace with proper initialization
# OLD: FileManager.default
# NEW: FileManager()
```
**Thread.isMainThread Deprecation:**
```bash
# Search for deprecated usage
grep -r "Thread\.isMainThread" src/ apps/ --include="*.swift"
# Replace with modern concurrency check
# OLD: Thread.isMainThread
# NEW: await MainActor.run { ... } or DispatchQueue.main.sync { ... }
```
### Peekaboo Submodule Fixes
```bash
# Check Peekaboo for concurrency issues
cd src/canvas-host/a2ui
grep -r "Thread\.isMainThread\|FileManager\.default" . --include="*.swift"
# Fix and rebuild submodule
cd /Volumes/Main SSD/Developer/clawdis
pnpm canvas:a2ui:bundle
```
### macOS App Concurrency Fixes
```bash
# Check macOS app for issues
grep -r "Thread\.isMainThread\|FileManager\.default" apps/macos/ --include="*.swift"
# Clean and rebuild after fixes
cd apps/macos && rm -rf .build .swiftpm
./scripts/restart-mac.sh
```
### Model Configuration Updates
If upstream introduced new model configurations:
```bash
# Check for OpenRouter API key requirements
grep -r "openrouter\|OPENROUTER" src/ --include="*.ts" --include="*.js"
# Update clawdbot.json with fallback chains
# Add model fallback configurations as needed
```
---
## Step 6: Verify & Push
```bash
# Verify everything works
pnpm clawdbot health
pnpm test
# Push (force required after rebase)
git push origin main --force-with-lease
# Or regular push after merge
git push origin main
```
---
## Troubleshooting
### Build Fails After Sync
```bash
# Clean and rebuild
rm -rf node_modules dist
pnpm install
pnpm build
```
### Type Errors (Bun/Node Incompatibility)
Common issue: `fetch.preconnect` type mismatch. Fix by using `FetchLike` type instead of `typeof fetch`.
### macOS App Crashes on Launch
Usually resource bundle mismatch. Full rebuild required:
```bash
cd apps/macos && rm -rf .build .swiftpm
./scripts/restart-mac.sh
```
### Patch Failures
```bash
# Check patch status
pnpm install 2>&1 | grep -i patch
# If patches fail, they may need updating for new dep versions
# Check patches/ directory against package.json patchedDependencies
```
### Swift 6.2 / macOS 26 SDK Build Failures
**Symptoms:** Build fails with deprecation warnings about `FileManager.default` or `Thread.isMainThread`
**Search-Mode Investigation:**
```bash
# Exhaustive search for deprecated APIs
morph-mcp_warpgrep_codebase_search search_string="Find all Swift files using deprecated FileManager.default or Thread.isMainThread" repo_path="/Volumes/Main SSD/Developer/clawdis"
```
**Quick Fix Commands:**
```bash
# Find all affected files
find . -name "*.swift" -exec grep -l "FileManager\.default\|Thread\.isMainThread" {} \;
# Replace FileManager.default with FileManager()
find . -name "*.swift" -exec sed -i '' 's/FileManager\.default/FileManager()/g' {} \;
# For Thread.isMainThread, need manual review of each usage
grep -rn "Thread\.isMainThread" --include="*.swift" .
```
**Rebuild After Fixes:**
```bash
# Clean all build artifacts
rm -rf apps/macos/.build apps/macos/.swiftpm
rm -rf src/canvas-host/a2ui/.build
# Rebuild Peekaboo bundle
pnpm canvas:a2ui:bundle
# Full macOS rebuild
./scripts/restart-mac.sh
```
---
## Automation Script
Save as `scripts/sync-upstream.sh`:
```bash
#!/usr/bin/env bash
set -euo pipefail
echo "==> Fetching upstream..."
git fetch upstream
echo "==> Current divergence:"
git rev-list --left-right --count main...upstream/main
echo "==> Rebasing onto upstream/main..."
git rebase upstream/main
echo "==> Installing dependencies..."
pnpm install
echo "==> Building..."
pnpm build
pnpm ui:build
echo "==> Running doctor..."
pnpm clawdbot doctor
echo "==> Rebuilding macOS app..."
./scripts/restart-mac.sh
echo "==> Verifying gateway health..."
pnpm clawdbot health
echo "==> Checking for Swift 6.2 compatibility issues..."
if grep -r "FileManager\.default\|Thread\.isMainThread" src/ apps/ --include="*.swift" --quiet; then
echo "⚠️ Found potential Swift 6.2 deprecated API usage"
echo " Run manual fixes or use analyze-mode investigation"
else
echo "✅ No obvious Swift deprecation issues found"
fi
echo "==> Testing agent functionality..."
# Note: Update YOUR_TELEGRAM_SESSION_ID with actual session ID
pnpm clawdbot agent --message "Verification: Upstream sync and macOS rebuild completed successfully." --session-id YOUR_TELEGRAM_SESSION_ID || echo "Warning: Agent test failed - check Telegram for verification message"
echo "==> Done! Check Telegram for verification message, then run 'git push --force-with-lease' when ready."
```
+1
View File
@@ -0,0 +1 @@
/cache
Binary file not shown.
Binary file not shown.
+87
View File
@@ -0,0 +1,87 @@
# list of languages for which language servers are started; choose from:
# al bash clojure cpp csharp csharp_omnisharp
# dart elixir elm erlang fortran fsharp
# go groovy haskell java julia kotlin
# lua markdown nix pascal perl php
# powershell python python_jedi r rego ruby
# ruby_solargraph rust scala swift terraform toml
# typescript typescript_vts yaml zig
# Note:
# - For C, use cpp
# - For JavaScript, use typescript
# - For Free Pascal / Lazarus, use pascal
# Special requirements:
# - csharp: Requires the presence of a .sln file in the project folder.
# - pascal: Requires Free Pascal Compiler (fpc) and optionally Lazarus.
# When using multiple languages, the first language server that supports a given file will be used for that file.
# The first language is the default language and the respective language server will be used as a fallback.
# Note that when using the JetBrains backend, language servers are not used and this list is correspondingly ignored.
languages:
- typescript
# the encoding used by text files in the project
# For a list of possible encodings, see https://docs.python.org/3.11/library/codecs.html#standard-encodings
encoding: "utf-8"
# whether to use the project's gitignore file to ignore files
# Added on 2025-04-07
ignore_all_files_in_gitignore: true
# list of additional paths to ignore
# same syntax as gitignore, so you can use * and **
# Was previously called `ignored_dirs`, please update your config if you are using that.
# Added (renamed) on 2025-04-07
ignored_paths: []
# whether the project is in read-only mode
# If set to true, all editing tools will be disabled and attempts to use them will result in an error
# Added on 2025-04-18
read_only: false
# list of tool names to exclude. We recommend not excluding any tools, see the readme for more details.
# Below is the complete list of tools for convenience.
# To make sure you have the latest list of tools, and to view their descriptions,
# execute `uv run scripts/print_tool_overview.py`.
#
# * `activate_project`: Activates a project by name.
# * `check_onboarding_performed`: Checks whether project onboarding was already performed.
# * `create_text_file`: Creates/overwrites a file in the project directory.
# * `delete_lines`: Deletes a range of lines within a file.
# * `delete_memory`: Deletes a memory from Serena's project-specific memory store.
# * `execute_shell_command`: Executes a shell command.
# * `find_referencing_code_snippets`: Finds code snippets in which the symbol at the given location is referenced.
# * `find_referencing_symbols`: Finds symbols that reference the symbol at the given location (optionally filtered by type).
# * `find_symbol`: Performs a global (or local) search for symbols with/containing a given name/substring (optionally filtered by type).
# * `get_current_config`: Prints the current configuration of the agent, including the active and available projects, tools, contexts, and modes.
# * `get_symbols_overview`: Gets an overview of the top-level symbols defined in a given file.
# * `initial_instructions`: Gets the initial instructions for the current project.
# Should only be used in settings where the system prompt cannot be set,
# e.g. in clients you have no control over, like Claude Desktop.
# * `insert_after_symbol`: Inserts content after the end of the definition of a given symbol.
# * `insert_at_line`: Inserts content at a given line in a file.
# * `insert_before_symbol`: Inserts content before the beginning of the definition of a given symbol.
# * `list_dir`: Lists files and directories in the given directory (optionally with recursion).
# * `list_memories`: Lists memories in Serena's project-specific memory store.
# * `onboarding`: Performs onboarding (identifying the project structure and essential tasks, e.g. for testing or building).
# * `prepare_for_new_conversation`: Provides instructions for preparing for a new conversation (in order to continue with the necessary context).
# * `read_file`: Reads a file within the project directory.
# * `read_memory`: Reads the memory with the given name from Serena's project-specific memory store.
# * `remove_project`: Removes a project from the Serena configuration.
# * `replace_lines`: Replaces a range of lines within a file with new content.
# * `replace_symbol_body`: Replaces the full definition of a symbol.
# * `restart_language_server`: Restarts the language server, may be necessary when edits not through Serena happen.
# * `search_for_pattern`: Performs a search for a pattern in the project.
# * `summarize_changes`: Provides instructions for summarizing the changes made to the codebase.
# * `switch_modes`: Activates modes by providing a list of their names
# * `think_about_collected_information`: Thinking tool for pondering the completeness of collected information.
# * `think_about_task_adherence`: Thinking tool for determining whether the agent is still on track with the current task.
# * `think_about_whether_you_are_done`: Thinking tool for determining whether the task is truly completed.
# * `write_memory`: Writes a named memory (for future reference) to Serena's project-specific memory store.
excluded_tools: []
# initial prompt for the project. It will always be given to the LLM upon activating the project
# (contrary to the memories, which are loaded on demand).
initial_prompt: ""
project_name: "clawdbot"
included_optional_tools: []
+2
View File
@@ -57,6 +57,8 @@
- Changelog workflow: keep latest released version at top (no `Unreleased`); after publishing, bump version and start a new top section.
- PRs should summarize scope, note testing performed, and mention any user-facing changes or new flags.
- PR review flow: when given a PR link, review via `gh pr view`/`gh pr diff` and do **not** change branches.
- PR review calls: prefer a single `gh pr view --json ...` to batch metadata/comments; run `gh pr diff` only when needed.
- Before starting a review when a GH Issue/PR is pasted: run `git pull`; if there are local changes or unpushed commits, stop and alert the user before reviewing.
- PR merge flow: create a temp branch from `main`, merge the PR branch into it (prefer squash unless commit history is important; use rebase/merge when it is). Always try to merge the PR unless it’s truly difficult, then use another approach. If we squash, add the PR author as a co-contributor. Apply fixes, add changelog entry (include PR # + thanks), run full gate before the final commit, commit, merge back to `main`, delete the temp branch, and end on `main`.
- If you review a PR and later do work on it, land via merge/squash (no direct-main commits) and always add the PR author as a co-contributor.
- When working on a PR: add a changelog entry with the PR number and thank the contributor.
+16 -3
View File
@@ -2,16 +2,28 @@
Docs: https://docs.clawd.bot
## 2026.1.20-1
### Changes
- Repo: remove the Peekaboo git submodule now that the SPM release is used.
### Fixes
- Web search: infer Perplexity base URL from API key source (direct vs OpenRouter).
- TUI: keep thinking blocks ordered before content during streaming and isolate per-run assembly. (#1202) — thanks @aaronveklabs.
- CLI: avoid duplicating --profile/--dev flags when formatting commands.
- Agents: enforce tool policy/sandbox rules for tool-dispatched skill commands and forward tool media outputs. (#1235) — thanks @dougvk.
## 2026.1.19-3
### Changes
- Android: remove legacy bridge transport code now that nodes use the gateway protocol.
- Android: send structured payloads in node events/invokes and include user-agent metadata in gateway connects.
- Gateway: expand `/v1/responses` to support file/image inputs, tool_choice, usage, and output limits. (#1229) — thanks @RyanLisse.
- Docs: surface Amazon Bedrock in provider lists and clarify Bedrock auth env vars. (#1289) — thanks @steipete.
### Fixes
- Gateway: strip inbound envelope headers from chat history messages to keep clients clean.
- UI: prevent double-scroll in Control UI chat by locking chat layout to the viewport. (#1283) — thanks @bradleypriest.
- Models: allow Bedrock custom providers without API keys and preserve inline provider IDs. (#1286) — thanks @alauppe.
## 2026.1.19-2
@@ -30,6 +42,7 @@ Docs: https://docs.clawd.bot
- **BREAKING:** Reject invalid/unknown config entries and refuse to start the gateway for safety; run `clawdbot doctor --fix` to repair.
### Changes
- Gateway: add `/v1/responses` endpoint (OpenResponses API) for agentic workflows with item-based input and semantic streaming events. Enable via `gateway.http.endpoints.responses.enabled: true`.
- Usage: add `/usage cost` summaries and macOS menu cost submenu with daily charting.
- Agents: clarify node_modules read-only guidance in agent instructions.
- TUI: add syntax highlighting for code blocks. (#1200) — thanks @vignesh07.
@@ -70,8 +83,8 @@ Docs: https://docs.clawd.bot
## 2026.1.18-4
### Changes
- macOS: switch PeekabooBridge integration to the tagged Swift Package Manager release (no submodule).
- macOS: stop syncing Peekaboo as a git submodule in postinstall.
- macOS: switch PeekabooBridge integration to the tagged Swift Package Manager release.
- macOS: stop syncing Peekaboo in postinstall.
- Swabble: use the tagged Commander Swift package release.
- CLI: add `clawdbot acp client` interactive ACP harness for debugging.
- Plugins: route command detection/text chunking helpers through the plugin runtime and drop runtime exports from the SDK.
+1 -6
View File
@@ -258,12 +258,7 @@ Send these in WhatsApp/Telegram/Slack/Microsoft Teams/WebChat (group commands ar
The Gateway alone delivers a great experience. All apps are optional and add extra features.
If you plan to build/run companion apps, initialize submodules first:
```bash
git submodule update --init --recursive
./scripts/restart-mac.sh
```
If you plan to build/run companion apps, follow the platform runbooks below.
### macOS (Clawdbot.app) (optional)
+2 -2
View File
@@ -101,8 +101,8 @@ Environment variables:
- Authorization requested at first start; requires macOS 26 + new Speech.framework APIs.
## Development
- Format: `./scripts/format.sh` (uses ../peekaboo/.swiftformat if present)
- Lint: `./scripts/lint.sh` (uses ../peekaboo/.swiftlint.yml if present)
- Format: `./scripts/format.sh` (uses local `.swiftformat`)
- Lint: `./scripts/lint.sh` (uses local `.swiftlint.yml`)
- Tests: `swift test` (uses swift-testing package)
## Roadmap
+1 -6
View File
@@ -1,10 +1,5 @@
#!/bin/bash
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
PEEKABOO_ROOT="${ROOT}/../peekaboo"
if [ -f "${PEEKABOO_ROOT}/.swiftformat" ]; then
CONFIG="${PEEKABOO_ROOT}/.swiftformat"
else
CONFIG="${ROOT}/.swiftformat"
fi
CONFIG="${ROOT}/.swiftformat"
swiftformat --config "$CONFIG" "$ROOT/Sources"
+1 -6
View File
@@ -1,12 +1,7 @@
#!/bin/bash
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
PEEKABOO_ROOT="${ROOT}/../peekaboo"
if [ -f "${PEEKABOO_ROOT}/.swiftlint.yml" ]; then
CONFIG="${PEEKABOO_ROOT}/.swiftlint.yml"
else
CONFIG="$ROOT/.swiftlint.yml"
fi
CONFIG="${ROOT}/.swiftlint.yml"
if ! command -v swiftlint >/dev/null; then
echo "swiftlint not installed" >&2
exit 1
@@ -160,14 +160,14 @@ actor CameraController {
defer { session.stopRunning() }
await Self.warmUpCaptureSession()
let movURL = FileManager.default.temporaryDirectory
let movURL = FileManager().temporaryDirectory
.appendingPathComponent("clawdbot-camera-\(UUID().uuidString).mov")
let mp4URL = FileManager.default.temporaryDirectory
let mp4URL = FileManager().temporaryDirectory
.appendingPathComponent("clawdbot-camera-\(UUID().uuidString).mp4")
defer {
try? FileManager.default.removeItem(at: movURL)
try? FileManager.default.removeItem(at: mp4URL)
try? FileManager().removeItem(at: movURL)
try? FileManager().removeItem(at: mp4URL)
}
var delegate: MovieFileDelegate?
+1 -1
View File
@@ -837,7 +837,7 @@ final class NodeAppModel {
fps: params.fps,
includeAudio: params.includeAudio,
outPath: nil)
defer { try? FileManager.default.removeItem(atPath: path) }
defer { try? FileManager().removeItem(atPath: path) }
let data = try Data(contentsOf: URL(fileURLWithPath: path))
struct Payload: Codable {
var format: String
@@ -91,7 +91,7 @@ final class ScreenRecordService: @unchecked Sendable {
let includeAudio = includeAudio ?? true
let outURL = self.makeOutputURL(outPath: outPath)
try? FileManager.default.removeItem(at: outURL)
try? FileManager().removeItem(at: outURL)
return RecordConfig(
durationMs: durationMs,
@@ -104,7 +104,7 @@ final class ScreenRecordService: @unchecked Sendable {
if let outPath, !outPath.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
return URL(fileURLWithPath: outPath)
}
return FileManager.default.temporaryDirectory
return FileManager().temporaryDirectory
.appendingPathComponent("clawdbot-screen-record-\(UUID().uuidString).mp4")
}
@@ -23,7 +23,7 @@ enum AgentWorkspace {
}
static func displayPath(for url: URL) -> String {
let home = FileManager.default.homeDirectoryForCurrentUser.path
let home = FileManager().homeDirectoryForCurrentUser.path
let path = url.path
if path == home { return "~" }
if path.hasPrefix(home + "/") {
@@ -44,12 +44,12 @@ enum AgentWorkspace {
}
static func workspaceEntries(workspaceURL: URL) throws -> [String] {
let contents = try FileManager.default.contentsOfDirectory(atPath: workspaceURL.path)
let contents = try FileManager().contentsOfDirectory(atPath: workspaceURL.path)
return contents.filter { !self.ignoredEntries.contains($0) }
}
static func isWorkspaceEmpty(workspaceURL: URL) -> Bool {
let fm = FileManager.default
let fm = FileManager()
var isDir: ObjCBool = false
if !fm.fileExists(atPath: workspaceURL.path, isDirectory: &isDir) {
return true
@@ -66,7 +66,7 @@ enum AgentWorkspace {
}
static func bootstrapSafety(for workspaceURL: URL) -> BootstrapSafety {
let fm = FileManager.default
let fm = FileManager()
var isDir: ObjCBool = false
if !fm.fileExists(atPath: workspaceURL.path, isDirectory: &isDir) {
return .safe
@@ -90,29 +90,29 @@ enum AgentWorkspace {
static func bootstrap(workspaceURL: URL) throws -> URL {
let shouldSeedBootstrap = self.isWorkspaceEmpty(workspaceURL: workspaceURL)
try FileManager.default.createDirectory(at: workspaceURL, withIntermediateDirectories: true)
try FileManager().createDirectory(at: workspaceURL, withIntermediateDirectories: true)
let agentsURL = self.agentsURL(workspaceURL: workspaceURL)
if !FileManager.default.fileExists(atPath: agentsURL.path) {
if !FileManager().fileExists(atPath: agentsURL.path) {
try self.defaultTemplate().write(to: agentsURL, atomically: true, encoding: .utf8)
self.logger.info("Created AGENTS.md at \(agentsURL.path, privacy: .public)")
}
let soulURL = workspaceURL.appendingPathComponent(self.soulFilename)
if !FileManager.default.fileExists(atPath: soulURL.path) {
if !FileManager().fileExists(atPath: soulURL.path) {
try self.defaultSoulTemplate().write(to: soulURL, atomically: true, encoding: .utf8)
self.logger.info("Created SOUL.md at \(soulURL.path, privacy: .public)")
}
let identityURL = workspaceURL.appendingPathComponent(self.identityFilename)
if !FileManager.default.fileExists(atPath: identityURL.path) {
if !FileManager().fileExists(atPath: identityURL.path) {
try self.defaultIdentityTemplate().write(to: identityURL, atomically: true, encoding: .utf8)
self.logger.info("Created IDENTITY.md at \(identityURL.path, privacy: .public)")
}
let userURL = workspaceURL.appendingPathComponent(self.userFilename)
if !FileManager.default.fileExists(atPath: userURL.path) {
if !FileManager().fileExists(atPath: userURL.path) {
try self.defaultUserTemplate().write(to: userURL, atomically: true, encoding: .utf8)
self.logger.info("Created USER.md at \(userURL.path, privacy: .public)")
}
let bootstrapURL = workspaceURL.appendingPathComponent(self.bootstrapFilename)
if shouldSeedBootstrap, !FileManager.default.fileExists(atPath: bootstrapURL.path) {
if shouldSeedBootstrap, !FileManager().fileExists(atPath: bootstrapURL.path) {
try self.defaultBootstrapTemplate().write(to: bootstrapURL, atomically: true, encoding: .utf8)
self.logger.info("Created BOOTSTRAP.md at \(bootstrapURL.path, privacy: .public)")
}
@@ -120,7 +120,7 @@ enum AgentWorkspace {
}
static func needsBootstrap(workspaceURL: URL) -> Bool {
let fm = FileManager.default
let fm = FileManager()
var isDir: ObjCBool = false
if !fm.fileExists(atPath: workspaceURL.path, isDirectory: &isDir) {
return true
@@ -305,7 +305,7 @@ enum AgentWorkspace {
if let dev = self.devTemplateURL(named: named) {
urls.append(dev)
}
let cwd = URL(fileURLWithPath: FileManager.default.currentDirectoryPath)
let cwd = URL(fileURLWithPath: FileManager().currentDirectoryPath)
urls.append(cwd.appendingPathComponent("docs")
.appendingPathComponent(self.templateDirname)
.appendingPathComponent(named))
@@ -45,7 +45,7 @@ struct AnthropicAuthControls: View {
NSWorkspace.shared.activateFileViewerSelecting([ClawdbotOAuthStore.oauthURL()])
}
.buttonStyle(.bordered)
.disabled(!FileManager.default.fileExists(atPath: ClawdbotOAuthStore.oauthURL().path))
.disabled(!FileManager().fileExists(atPath: ClawdbotOAuthStore.oauthURL().path))
Button("Refresh") {
self.refresh()
@@ -234,7 +234,7 @@ enum ClawdbotOAuthStore {
return URL(fileURLWithPath: expanded, isDirectory: true)
}
return FileManager.default.homeDirectoryForCurrentUser
return FileManager().homeDirectoryForCurrentUser
.appendingPathComponent(".clawdbot", isDirectory: true)
.appendingPathComponent("credentials", isDirectory: true)
}
@@ -253,7 +253,7 @@ enum ClawdbotOAuthStore {
urls.append(URL(fileURLWithPath: expanded, isDirectory: true).appendingPathComponent(self.oauthFilename))
}
let home = FileManager.default.homeDirectoryForCurrentUser
let home = FileManager().homeDirectoryForCurrentUser
urls.append(home.appendingPathComponent(".pi/agent/\(self.oauthFilename)"))
urls.append(home.appendingPathComponent(".claude/\(self.oauthFilename)"))
urls.append(home.appendingPathComponent(".config/claude/\(self.oauthFilename)"))
@@ -270,10 +270,10 @@ enum ClawdbotOAuthStore {
static func importLegacyAnthropicOAuthIfNeeded() -> URL? {
let dest = self.oauthURL()
guard !FileManager.default.fileExists(atPath: dest.path) else { return nil }
guard !FileManager().fileExists(atPath: dest.path) else { return nil }
for url in self.legacyOAuthURLs() {
guard FileManager.default.fileExists(atPath: url.path) else { continue }
guard FileManager().fileExists(atPath: url.path) else { continue }
guard self.anthropicOAuthStatus(at: url).isConnected else { continue }
guard let storage = self.loadStorage(at: url) else { continue }
do {
@@ -296,7 +296,7 @@ enum ClawdbotOAuthStore {
}
static func anthropicOAuthStatus(at url: URL) -> AnthropicOAuthStatus {
guard FileManager.default.fileExists(atPath: url.path) else { return .missingFile }
guard FileManager().fileExists(atPath: url.path) else { return .missingFile }
guard let data = try? Data(contentsOf: url) else { return .unreadableFile }
guard let json = try? JSONSerialization.jsonObject(with: data, options: []) else { return .invalidJSON }
@@ -360,7 +360,7 @@ enum ClawdbotOAuthStore {
private static func saveStorage(_ storage: [String: Any]) throws {
let dir = self.oauthDir()
try FileManager.default.createDirectory(
try FileManager().createDirectory(
at: dir,
withIntermediateDirectories: true,
attributes: [.posixPermissions: 0o700])
@@ -370,7 +370,7 @@ enum ClawdbotOAuthStore {
withJSONObject: storage,
options: [.prettyPrinted, .sortedKeys])
try data.write(to: url, options: [.atomic])
try FileManager.default.setAttributes([.posixPermissions: 0o600], ofItemAtPath: url.path)
try FileManager().setAttributes([.posixPermissions: 0o600], ofItemAtPath: url.path)
}
}
@@ -61,7 +61,7 @@ enum CLIInstaller {
}
private static func installPrefix() -> String {
FileManager.default.homeDirectoryForCurrentUser
FileManager().homeDirectoryForCurrentUser
.appendingPathComponent(".clawdbot")
.path
}
@@ -167,20 +167,20 @@ actor CameraCaptureService {
defer { session.stopRunning() }
await Self.warmUpCaptureSession()
let tmpMovURL = FileManager.default.temporaryDirectory
let tmpMovURL = FileManager().temporaryDirectory
.appendingPathComponent("clawdbot-camera-\(UUID().uuidString).mov")
defer { try? FileManager.default.removeItem(at: tmpMovURL) }
defer { try? FileManager().removeItem(at: tmpMovURL) }
let outputURL: URL = {
if let outPath, !outPath.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
return URL(fileURLWithPath: outPath)
}
return FileManager.default.temporaryDirectory
return FileManager().temporaryDirectory
.appendingPathComponent("clawdbot-camera-\(UUID().uuidString).mp4")
}()
// Ensure we don't fail exporting due to an existing file.
try? FileManager.default.removeItem(at: outputURL)
try? FileManager().removeItem(at: outputURL)
let logger = self.logger
var delegate: MovieFileDelegate?
@@ -25,7 +25,7 @@ final class CanvasManager {
var defaultAnchorProvider: (() -> NSRect?)?
private nonisolated static let canvasRoot: URL = {
let base = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first!
let base = FileManager().urls(for: .applicationSupportDirectory, in: .userDomainMask).first!
return base.appendingPathComponent("Clawdbot/canvas", isDirectory: true)
}()
@@ -83,7 +83,7 @@ final class CanvasManager {
self.panelSessionKey = nil
Self.logger.debug("showDetailed ensure canvas root dir")
try FileManager.default.createDirectory(at: Self.canvasRoot, withIntermediateDirectories: true)
try FileManager().createDirectory(at: Self.canvasRoot, withIntermediateDirectories: true)
Self.logger.debug("showDetailed init CanvasWindowController")
let controller = try CanvasWindowController(
sessionKey: session,
@@ -258,7 +258,7 @@ final class CanvasManager {
// (Avoid treating Canvas routes like "/" as filesystem paths.)
if trimmed.hasPrefix("/") {
var isDir: ObjCBool = false
if FileManager.default.fileExists(atPath: trimmed, isDirectory: &isDir), !isDir.boolValue {
if FileManager().fileExists(atPath: trimmed, isDirectory: &isDir), !isDir.boolValue {
return URL(fileURLWithPath: trimmed)
}
}
@@ -293,7 +293,7 @@ final class CanvasManager {
}
private static func localStatus(sessionDir: URL, target: String) -> CanvasShowStatus {
let fm = FileManager.default
let fm = FileManager()
let trimmed = target.trimmingCharacters(in: .whitespacesAndNewlines)
let withoutQuery = trimmed.split(separator: "?", maxSplits: 1, omittingEmptySubsequences: false).first
.map(String.init) ?? trimmed
@@ -331,7 +331,7 @@ final class CanvasManager {
}
private static func indexExists(in dir: URL) -> Bool {
let fm = FileManager.default
let fm = FileManager()
let a = dir.appendingPathComponent("index.html", isDirectory: false)
if fm.fileExists(atPath: a.path) { return true }
let b = dir.appendingPathComponent("index.htm", isDirectory: false)
@@ -69,8 +69,8 @@ final class CanvasSchemeHandler: NSObject, WKURLSchemeHandler {
if path.isEmpty {
let indexA = sessionRoot.appendingPathComponent("index.html", isDirectory: false)
let indexB = sessionRoot.appendingPathComponent("index.htm", isDirectory: false)
if !FileManager.default.fileExists(atPath: indexA.path),
!FileManager.default.fileExists(atPath: indexB.path)
if !FileManager().fileExists(atPath: indexA.path),
!FileManager().fileExists(atPath: indexB.path)
{
return self.scaffoldPage(sessionRoot: sessionRoot)
}
@@ -106,7 +106,7 @@ final class CanvasSchemeHandler: NSObject, WKURLSchemeHandler {
}
private func resolveFileURL(sessionRoot: URL, requestPath: String) -> URL? {
let fm = FileManager.default
let fm = FileManager()
var candidate = sessionRoot.appendingPathComponent(requestPath, isDirectory: false)
var isDir: ObjCBool = false
@@ -137,7 +137,7 @@ final class CanvasSchemeHandler: NSObject, WKURLSchemeHandler {
}
private func resolveIndex(in dir: URL) -> URL? {
let fm = FileManager.default
let fm = FileManager()
let a = dir.appendingPathComponent("index.html", isDirectory: false)
if fm.fileExists(atPath: a.path) { return a }
let b = dir.appendingPathComponent("index.htm", isDirectory: false)
@@ -32,7 +32,7 @@ final class CanvasWindowController: NSWindowController, WKNavigationDelegate, NS
let safeSessionKey = CanvasWindowController.sanitizeSessionKey(sessionKey)
canvasWindowLogger.debug("CanvasWindowController init sanitized session=\(safeSessionKey, privacy: .public)")
self.sessionDir = root.appendingPathComponent(safeSessionKey, isDirectory: true)
try FileManager.default.createDirectory(at: self.sessionDir, withIntermediateDirectories: true)
try FileManager().createDirectory(at: self.sessionDir, withIntermediateDirectories: true)
canvasWindowLogger.debug("CanvasWindowController init session dir ready")
self.schemeHandler = CanvasSchemeHandler(root: root)
@@ -143,8 +143,8 @@ final class CanvasWindowController: NSWindowController, WKNavigationDelegate, NS
if path == "/" || path.isEmpty {
let indexA = sessionDir.appendingPathComponent("index.html", isDirectory: false)
let indexB = sessionDir.appendingPathComponent("index.htm", isDirectory: false)
if !FileManager.default.fileExists(atPath: indexA.path),
!FileManager.default.fileExists(atPath: indexB.path)
if !FileManager().fileExists(atPath: indexA.path),
!FileManager().fileExists(atPath: indexB.path)
{
return
}
@@ -233,7 +233,7 @@ final class CanvasWindowController: NSWindowController, WKNavigationDelegate, NS
// (Avoid treating Canvas routes like "/" as filesystem paths.)
if trimmed.hasPrefix("/") {
var isDir: ObjCBool = false
if FileManager.default.fileExists(atPath: trimmed, isDirectory: &isDir), !isDir.boolValue {
if FileManager().fileExists(atPath: trimmed, isDirectory: &isDir), !isDir.boolValue {
let url = URL(fileURLWithPath: trimmed)
canvasWindowLogger.debug("canvas load file \(url.absoluteString, privacy: .public)")
self.loadFile(url)
@@ -18,7 +18,7 @@ enum ClawdbotConfigFile {
static func loadDict() -> [String: Any] {
let url = self.url()
guard FileManager.default.fileExists(atPath: url.path) else { return [:] }
guard FileManager().fileExists(atPath: url.path) else { return [:] }
do {
let data = try Data(contentsOf: url)
guard let root = self.parseConfigData(data) else {
@@ -38,7 +38,7 @@ enum ClawdbotConfigFile {
do {
let data = try JSONSerialization.data(withJSONObject: dict, options: [.prettyPrinted, .sortedKeys])
let url = self.url()
try FileManager.default.createDirectory(
try FileManager().createDirectory(
at: url.deletingLastPathComponent(),
withIntermediateDirectories: true)
try data.write(to: url, options: [.atomic])
@@ -21,7 +21,7 @@ enum ClawdbotPaths {
if let override = ClawdbotEnv.path(self.stateDirEnv) {
return URL(fileURLWithPath: override, isDirectory: true)
}
return FileManager.default.homeDirectoryForCurrentUser
return FileManager().homeDirectoryForCurrentUser
.appendingPathComponent(".clawdbot", isDirectory: true)
}
@@ -6,9 +6,9 @@ enum CommandResolver {
static func gatewayEntrypoint(in root: URL) -> String? {
let distEntry = root.appendingPathComponent("dist/index.js").path
if FileManager.default.isReadableFile(atPath: distEntry) { return distEntry }
if FileManager().isReadableFile(atPath: distEntry) { return distEntry }
let binEntry = root.appendingPathComponent("bin/clawdbot.js").path
if FileManager.default.isReadableFile(atPath: binEntry) { return binEntry }
if FileManager().isReadableFile(atPath: binEntry) { return binEntry }
return nil
}
@@ -47,16 +47,16 @@ enum CommandResolver {
static func projectRoot() -> URL {
if let stored = UserDefaults.standard.string(forKey: self.projectRootDefaultsKey),
let url = self.expandPath(stored),
FileManager.default.fileExists(atPath: url.path)
FileManager().fileExists(atPath: url.path)
{
return url
}
let fallback = FileManager.default.homeDirectoryForCurrentUser
let fallback = FileManager().homeDirectoryForCurrentUser
.appendingPathComponent("Projects/clawdbot")
if FileManager.default.fileExists(atPath: fallback.path) {
if FileManager().fileExists(atPath: fallback.path) {
return fallback
}
return FileManager.default.homeDirectoryForCurrentUser
return FileManager().homeDirectoryForCurrentUser
}
static func setProjectRoot(_ path: String) {
@@ -70,7 +70,7 @@ enum CommandResolver {
static func preferredPaths() -> [String] {
let current = ProcessInfo.processInfo.environment["PATH"]?
.split(separator: ":").map(String.init) ?? []
let home = FileManager.default.homeDirectoryForCurrentUser
let home = FileManager().homeDirectoryForCurrentUser
let projectRoot = self.projectRoot()
return self.preferredPaths(home: home, current: current, projectRoot: projectRoot)
}
@@ -99,10 +99,10 @@ enum CommandResolver {
let bin = base.appendingPathComponent("bin")
let nodeBin = base.appendingPathComponent("tools/node/bin")
var paths: [String] = []
if FileManager.default.fileExists(atPath: bin.path) {
if FileManager().fileExists(atPath: bin.path) {
paths.append(bin.path)
}
if FileManager.default.fileExists(atPath: nodeBin.path) {
if FileManager().fileExists(atPath: nodeBin.path) {
paths.append(nodeBin.path)
}
return paths
@@ -113,13 +113,13 @@ enum CommandResolver {
// Volta
let volta = home.appendingPathComponent(".volta/bin")
if FileManager.default.fileExists(atPath: volta.path) {
if FileManager().fileExists(atPath: volta.path) {
bins.append(volta.path)
}
// asdf
let asdf = home.appendingPathComponent(".asdf/shims")
if FileManager.default.fileExists(atPath: asdf.path) {
if FileManager().fileExists(atPath: asdf.path) {
bins.append(asdf.path)
}
@@ -137,10 +137,10 @@ enum CommandResolver {
}
private static func versionedNodeBinPaths(base: URL, suffix: String) -> [String] {
guard FileManager.default.fileExists(atPath: base.path) else { return [] }
guard FileManager().fileExists(atPath: base.path) else { return [] }
let entries: [String]
do {
entries = try FileManager.default.contentsOfDirectory(atPath: base.path)
entries = try FileManager().contentsOfDirectory(atPath: base.path)
} catch {
return []
}
@@ -167,7 +167,7 @@ enum CommandResolver {
for entry in sorted {
let binDir = base.appendingPathComponent(entry).appendingPathComponent(suffix)
let node = binDir.appendingPathComponent("node")
if FileManager.default.isExecutableFile(atPath: node.path) {
if FileManager().isExecutableFile(atPath: node.path) {
paths.append(binDir.path)
}
}
@@ -177,7 +177,7 @@ enum CommandResolver {
static func findExecutable(named name: String, searchPaths: [String]? = nil) -> String? {
for dir in searchPaths ?? self.preferredPaths() {
let candidate = (dir as NSString).appendingPathComponent(name)
if FileManager.default.isExecutableFile(atPath: candidate) {
if FileManager().isExecutableFile(atPath: candidate) {
return candidate
}
}
@@ -191,12 +191,12 @@ enum CommandResolver {
static func projectClawdbotExecutable(projectRoot: URL? = nil) -> String? {
let root = projectRoot ?? self.projectRoot()
let candidate = root.appendingPathComponent("node_modules/.bin").appendingPathComponent(self.helperName).path
return FileManager.default.isExecutableFile(atPath: candidate) ? candidate : nil
return FileManager().isExecutableFile(atPath: candidate) ? candidate : nil
}
static func nodeCliPath() -> String? {
let candidate = self.projectRoot().appendingPathComponent("bin/clawdbot.js").path
return FileManager.default.isReadableFile(atPath: candidate) ? candidate : nil
return FileManager().isReadableFile(atPath: candidate) ? candidate : nil
}
static func hasAnyClawdbotInvoker(searchPaths: [String]? = nil) -> Bool {
@@ -459,7 +459,7 @@ enum CommandResolver {
private static func expandPath(_ path: String) -> URL? {
var expanded = path
if expanded.hasPrefix("~") {
let home = FileManager.default.homeDirectoryForCurrentUser.path
let home = FileManager().homeDirectoryForCurrentUser.path
expanded.replaceSubrange(expanded.startIndex...expanded.startIndex, with: home)
}
return URL(fileURLWithPath: expanded)
@@ -26,7 +26,7 @@ enum DebugActions {
static func openLog() {
let path = self.pinoLogPath()
let url = URL(fileURLWithPath: path)
guard FileManager.default.fileExists(atPath: path) else {
guard FileManager().fileExists(atPath: path) else {
let alert = NSAlert()
alert.messageText = "Log file not found"
alert.informativeText = path
@@ -38,7 +38,7 @@ enum DebugActions {
@MainActor
static func openConfigFolder() {
let url = FileManager.default
let url = FileManager()
.homeDirectoryForCurrentUser
.appendingPathComponent(".clawdbot", isDirectory: true)
NSWorkspace.shared.activateFileViewerSelecting([url])
@@ -55,7 +55,7 @@ enum DebugActions {
}
let path = self.resolveSessionStorePath()
let url = URL(fileURLWithPath: path)
if FileManager.default.fileExists(atPath: path) {
if FileManager().fileExists(atPath: path) {
NSWorkspace.shared.activateFileViewerSelecting([url])
} else {
NSWorkspace.shared.open(url.deletingLastPathComponent())
@@ -195,7 +195,7 @@ enum DebugActions {
@MainActor
private static func resolveSessionStorePath() -> String {
let defaultPath = SessionLoader.defaultStorePath
let configURL = FileManager.default.homeDirectoryForCurrentUser
let configURL = FileManager().homeDirectoryForCurrentUser
.appendingPathComponent(".clawdbot/clawdbot.json")
guard
let data = try? Data(contentsOf: configURL),
@@ -354,7 +354,7 @@ struct DebugSettings: View {
Button("Save") { self.saveRelayRoot() }
.buttonStyle(.borderedProminent)
Button("Reset") {
let def = FileManager.default.homeDirectoryForCurrentUser
let def = FileManager().homeDirectoryForCurrentUser
.appendingPathComponent("Projects/clawdbot").path
self.gatewayRootInput = def
self.saveRelayRoot()
@@ -743,7 +743,7 @@ struct DebugSettings: View {
do {
let data = try JSONSerialization.data(withJSONObject: root, options: [.prettyPrinted, .sortedKeys])
try FileManager.default.createDirectory(
try FileManager().createDirectory(
at: url.deletingLastPathComponent(),
withIntermediateDirectories: true)
try data.write(to: url, options: [.atomic])
@@ -776,7 +776,7 @@ struct DebugSettings: View {
}
private func configURL() -> URL {
FileManager.default.homeDirectoryForCurrentUser
FileManager().homeDirectoryForCurrentUser
.appendingPathComponent(".clawdbot")
.appendingPathComponent("clawdbot.json")
}
@@ -20,8 +20,8 @@ actor DiagnosticsFileLog {
}
nonisolated static func logDirectoryURL() -> URL {
let library = FileManager.default.urls(for: .libraryDirectory, in: .userDomainMask).first
?? FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent("Library", isDirectory: true)
let library = FileManager().urls(for: .libraryDirectory, in: .userDomainMask).first
?? FileManager().homeDirectoryForCurrentUser.appendingPathComponent("Library", isDirectory: true)
return library
.appendingPathComponent("Logs", isDirectory: true)
.appendingPathComponent("Clawdbot", isDirectory: true)
@@ -43,7 +43,7 @@ actor DiagnosticsFileLog {
}
func clear() throws {
let fm = FileManager.default
let fm = FileManager()
let base = Self.logFileURL()
if fm.fileExists(atPath: base.path) {
try fm.removeItem(at: base)
@@ -67,7 +67,7 @@ actor DiagnosticsFileLog {
}
private func ensureDirectory() throws {
try FileManager.default.createDirectory(
try FileManager().createDirectory(
at: Self.logDirectoryURL(),
withIntermediateDirectories: true)
}
@@ -79,7 +79,7 @@ actor DiagnosticsFileLog {
line.append(data)
line.append(0x0A) // newline
let fm = FileManager.default
let fm = FileManager()
if !fm.fileExists(atPath: url.path) {
fm.createFile(atPath: url.path, contents: nil)
}
@@ -92,13 +92,13 @@ actor DiagnosticsFileLog {
private func rotateIfNeeded() throws {
let url = Self.logFileURL()
guard let attrs = try? FileManager.default.attributesOfItem(atPath: url.path),
guard let attrs = try? FileManager().attributesOfItem(atPath: url.path),
let size = attrs[.size] as? NSNumber
else { return }
if size.int64Value < self.maxBytes { return }
let fm = FileManager.default
let fm = FileManager()
let oldest = self.rotatedURL(index: self.maxBackups)
if fm.fileExists(atPath: oldest.path) {
@@ -176,7 +176,7 @@ enum ExecApprovalsStore {
static func readSnapshot() -> ExecApprovalsSnapshot {
let url = self.fileURL()
guard FileManager.default.fileExists(atPath: url.path) else {
guard FileManager().fileExists(atPath: url.path) else {
return ExecApprovalsSnapshot(
path: url.path,
exists: false,
@@ -216,7 +216,7 @@ enum ExecApprovalsStore {
static func loadFile() -> ExecApprovalsFile {
let url = self.fileURL()
guard FileManager.default.fileExists(atPath: url.path) else {
guard FileManager().fileExists(atPath: url.path) else {
return ExecApprovalsFile(version: 1, socket: nil, defaults: nil, agents: [:])
}
do {
@@ -238,11 +238,11 @@ enum ExecApprovalsStore {
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
let data = try encoder.encode(file)
let url = self.fileURL()
try FileManager.default.createDirectory(
try FileManager().createDirectory(
at: url.deletingLastPathComponent(),
withIntermediateDirectories: true)
try data.write(to: url, options: [.atomic])
try? FileManager.default.setAttributes([.posixPermissions: 0o600], ofItemAtPath: url.path)
try? FileManager().setAttributes([.posixPermissions: 0o600], ofItemAtPath: url.path)
} catch {
self.logger.error("exec approvals save failed: \(error.localizedDescription, privacy: .public)")
}
@@ -442,11 +442,11 @@ enum ExecApprovalsStore {
private static func expandPath(_ raw: String) -> String {
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
if trimmed == "~" {
return FileManager.default.homeDirectoryForCurrentUser.path
return FileManager().homeDirectoryForCurrentUser.path
}
if trimmed.hasPrefix("~/") {
let suffix = trimmed.dropFirst(2)
return FileManager.default.homeDirectoryForCurrentUser
return FileManager().homeDirectoryForCurrentUser
.appendingPathComponent(String(suffix)).path
}
return trimmed
@@ -497,7 +497,7 @@ struct ExecCommandResolution: Sendable {
return expanded
}
let base = cwd?.trimmingCharacters(in: .whitespacesAndNewlines)
let root = (base?.isEmpty == false) ? base! : FileManager.default.currentDirectoryPath
let root = (base?.isEmpty == false) ? base! : FileManager().currentDirectoryPath
return URL(fileURLWithPath: root).appendingPathComponent(expanded).path
}
let searchPaths = self.searchPaths(from: env)
@@ -155,7 +155,8 @@ actor GatewayEndpointStore {
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
if !trimmed.isEmpty {
if let configToken = self.resolveConfigToken(isRemote: isRemote, root: root),
!configToken.isEmpty
!configToken.isEmpty,
configToken != trimmed
{
self.warnEnvOverrideOnce(
kind: .token,
@@ -164,32 +165,19 @@ actor GatewayEndpointStore {
}
return trimmed
}
if isRemote {
if let gateway = root["gateway"] as? [String: Any],
let remote = gateway["remote"] as? [String: Any],
let token = remote["token"] as? String
{
let value = token.trimmingCharacters(in: .whitespacesAndNewlines)
if !value.isEmpty {
return value
}
}
return nil
}
if let gateway = root["gateway"] as? [String: Any],
let auth = gateway["auth"] as? [String: Any],
let token = auth["token"] as? String
if let configToken = self.resolveConfigToken(isRemote: isRemote, root: root),
!configToken.isEmpty
{
let value = token.trimmingCharacters(in: .whitespacesAndNewlines)
if !value.isEmpty {
return value
}
return configToken
}
if let token = launchdSnapshot?.token?.trimmingCharacters(in: .whitespacesAndNewlines),
!token.isEmpty
{
return token
}
return nil
}
@@ -5,7 +5,7 @@ enum GatewayLaunchAgentManager {
private static let disableLaunchAgentMarker = ".clawdbot/disable-launchagent"
private static var plistURL: URL {
FileManager.default.homeDirectoryForCurrentUser
FileManager().homeDirectoryForCurrentUser
.appendingPathComponent("Library/LaunchAgents/\(gatewayLaunchdLabel).plist")
}
@@ -67,9 +67,9 @@ enum GatewayLaunchAgentManager {
extension GatewayLaunchAgentManager {
private static func isLaunchAgentWriteDisabled() -> Bool {
let marker = FileManager.default.homeDirectoryForCurrentUser
let marker = FileManager().homeDirectoryForCurrentUser
.appendingPathComponent(self.disableLaunchAgentMarker)
return FileManager.default.fileExists(atPath: marker.path)
return FileManager().fileExists(atPath: marker.path)
}
private static func readDaemonLoaded() async -> Bool? {
@@ -365,7 +365,7 @@ final class GatewayProcessManager {
func clearLog() {
self.log = ""
try? FileManager.default.removeItem(atPath: GatewayLaunchAgentManager.launchdGatewayLogPath())
try? FileManager().removeItem(atPath: GatewayLaunchAgentManager.launchdGatewayLogPath())
self.logger.debug("gateway log cleared")
}
@@ -378,7 +378,7 @@ final class GatewayProcessManager {
}
private nonisolated static func readGatewayLog(path: String, limit: Int) -> String {
guard FileManager.default.fileExists(atPath: path) else { return "" }
guard FileManager().fileExists(atPath: path) else { return "" }
guard let data = try? Data(contentsOf: URL(fileURLWithPath: path)) else { return "" }
let text = String(data: data, encoding: .utf8) ?? ""
if text.count <= limit { return text }
@@ -3,17 +3,17 @@ import Foundation
enum LaunchAgentManager {
private static let legacyLaunchdLabel = "com.steipete.clawdbot"
private static var plistURL: URL {
FileManager.default.homeDirectoryForCurrentUser
FileManager().homeDirectoryForCurrentUser
.appendingPathComponent("Library/LaunchAgents/com.clawdbot.mac.plist")
}
private static var legacyPlistURL: URL {
FileManager.default.homeDirectoryForCurrentUser
FileManager().homeDirectoryForCurrentUser
.appendingPathComponent("Library/LaunchAgents/\(legacyLaunchdLabel).plist")
}
static func status() async -> Bool {
guard FileManager.default.fileExists(atPath: self.plistURL.path) else { return false }
guard FileManager().fileExists(atPath: self.plistURL.path) else { return false }
let result = await self.runLaunchctl(["print", "gui/\(getuid())/\(launchdLabel)"])
return result == 0
}
@@ -21,7 +21,7 @@ enum LaunchAgentManager {
static func set(enabled: Bool, bundlePath: String) async {
if enabled {
_ = await self.runLaunchctl(["bootout", "gui/\(getuid())/\(self.legacyLaunchdLabel)"])
try? FileManager.default.removeItem(at: self.legacyPlistURL)
try? FileManager().removeItem(at: self.legacyPlistURL)
self.writePlist(bundlePath: bundlePath)
_ = await self.runLaunchctl(["bootout", "gui/\(getuid())/\(launchdLabel)"])
_ = await self.runLaunchctl(["bootstrap", "gui/\(getuid())", self.plistURL.path])
@@ -29,7 +29,7 @@ enum LaunchAgentManager {
} else {
// Disable autostart going forward but leave the current app running.
// bootout would terminate the launchd job immediately (and crash the app if launched via agent).
try? FileManager.default.removeItem(at: self.plistURL)
try? FileManager().removeItem(at: self.plistURL)
}
}
@@ -46,7 +46,7 @@ enum LaunchAgentManager {
<string>\(bundlePath)/Contents/MacOS/Clawdbot</string>
</array>
<key>WorkingDirectory</key>
<string>\(FileManager.default.homeDirectoryForCurrentUser.path)</string>
<string>\(FileManager().homeDirectoryForCurrentUser.path)</string>
<key>RunAtLoad</key>
<true/>
<key>KeepAlive</key>
+2 -2
View File
@@ -18,7 +18,7 @@ enum LogLocator {
}
private static func ensureLogDirExists() {
try? FileManager.default.createDirectory(at: self.logDir, withIntermediateDirectories: true)
try? FileManager().createDirectory(at: self.logDir, withIntermediateDirectories: true)
}
private static func modificationDate(for url: URL) -> Date {
@@ -28,7 +28,7 @@ enum LogLocator {
/// Returns the newest log file under /tmp/clawdbot/ (rolling or stdout), or nil if none exist.
static func bestLogFile() -> URL? {
self.ensureLogDirExists()
let fm = FileManager.default
let fm = FileManager()
let files = (try? fm.contentsOfDirectory(
at: self.logDir,
includingPropertiesForKeys: [.contentModificationDateKey],
@@ -2,7 +2,7 @@ import Foundation
import JavaScriptCore
enum ModelCatalogLoader {
static let defaultPath: String = FileManager.default.homeDirectoryForCurrentUser
static let defaultPath: String = FileManager().homeDirectoryForCurrentUser
.appendingPathComponent("Projects/pi-mono/packages/ai/src/models.generated.ts").path
private static let logger = Logger(subsystem: "com.clawdbot", category: "models")
@@ -133,7 +133,7 @@ actor MacNodeRuntime {
let sessionKey = self.mainSessionKey
let path = try await CanvasManager.shared.snapshot(sessionKey: sessionKey, outPath: nil)
defer { try? FileManager.default.removeItem(atPath: path) }
defer { try? FileManager().removeItem(atPath: path) }
let data = try Data(contentsOf: URL(fileURLWithPath: path))
guard let image = NSImage(data: data) else {
return Self.errorResponse(req, code: .unavailable, message: "canvas snapshot decode failed")
@@ -206,7 +206,7 @@ actor MacNodeRuntime {
includeAudio: params.includeAudio ?? true,
deviceId: params.deviceId,
outPath: nil)
defer { try? FileManager.default.removeItem(atPath: res.path) }
defer { try? FileManager().removeItem(atPath: res.path) }
let data = try Data(contentsOf: URL(fileURLWithPath: res.path))
struct ClipPayload: Encodable {
var format: String
@@ -312,7 +312,7 @@ actor MacNodeRuntime {
fps: params.fps,
includeAudio: params.includeAudio,
outPath: nil)
defer { try? FileManager.default.removeItem(atPath: res.path) }
defer { try? FileManager().removeItem(atPath: res.path) }
let data = try Data(contentsOf: URL(fileURLWithPath: res.path))
struct ScreenPayload: Encodable {
var format: String
@@ -24,7 +24,7 @@ actor PortGuardian {
private var records: [Record] = []
private let logger = Logger(subsystem: "com.clawdbot", category: "portguard")
private nonisolated static let appSupportDir: URL = {
let base = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first!
let base = FileManager().urls(for: .applicationSupportDirectory, in: .userDomainMask).first!
return base.appendingPathComponent("Clawdbot", isDirectory: true)
}()
@@ -71,7 +71,7 @@ actor PortGuardian {
}
func record(port: Int, pid: Int32, command: String, mode: AppState.ConnectionMode) async {
try? FileManager.default.createDirectory(at: Self.appSupportDir, withIntermediateDirectories: true)
try? FileManager().createDirectory(at: Self.appSupportDir, withIntermediateDirectories: true)
self.records.removeAll { $0.pid == pid }
self.records.append(
Record(
@@ -111,7 +111,7 @@ enum RuntimeLocator {
// MARK: - Internals
private static func findExecutable(named name: String, searchPaths: [String]) -> String? {
let fm = FileManager.default
let fm = FileManager()
for dir in searchPaths {
let candidate = (dir as NSString).appendingPathComponent(name)
if fm.isExecutableFile(atPath: candidate) {
@@ -42,10 +42,10 @@ final class ScreenRecordService {
if let outPath, !outPath.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
return URL(fileURLWithPath: outPath)
}
return FileManager.default.temporaryDirectory
return FileManager().temporaryDirectory
.appendingPathComponent("clawdbot-screen-record-\(UUID().uuidString).mp4")
}()
try? FileManager.default.removeItem(at: outURL)
try? FileManager().removeItem(at: outURL)
let content = try await SCShareableContent.current
let displays = content.displays.sorted { $0.displayID < $1.displayID }
@@ -66,12 +66,12 @@ enum SessionActions {
let dir = URL(fileURLWithPath: storePath).deletingLastPathComponent()
urls.append(dir.appendingPathComponent("\(sessionId).jsonl"))
}
let home = FileManager.default.homeDirectoryForCurrentUser
let home = FileManager().homeDirectoryForCurrentUser
urls.append(home.appendingPathComponent(".clawdbot/sessions/\(sessionId).jsonl"))
return urls
}()
let existing = candidates.first(where: { FileManager.default.fileExists(atPath: $0.path) })
let existing = candidates.first(where: { FileManager().fileExists(atPath: $0.path) })
guard let url = existing else {
let alert = NSAlert()
alert.messageText = "Session log not found"
@@ -246,7 +246,7 @@ enum SessionLoader {
static let fallbackContextTokens = 200_000
static let defaultStorePath = standardize(
FileManager.default.homeDirectoryForCurrentUser
FileManager().homeDirectoryForCurrentUser
.appendingPathComponent(".clawdbot/sessions/sessions.json").path)
static func loadSnapshot(
@@ -44,7 +44,7 @@ enum SoundEffectCatalog {
]
private static let searchRoots: [URL] = [
FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent("Library/Sounds"),
FileManager().homeDirectoryForCurrentUser.appendingPathComponent("Library/Sounds"),
URL(fileURLWithPath: "/Library/Sounds"),
URL(fileURLWithPath: "/System/Applications/Mail.app/Contents/Resources"), // Mail “swoosh”
URL(fileURLWithPath: "/System/Library/Sounds"),
@@ -53,7 +53,7 @@ enum SoundEffectCatalog {
private static let discoveredSoundMap: [String: URL] = {
var map: [String: URL] = [:]
for root in Self.searchRoots {
guard let contents = try? FileManager.default.contentsOfDirectory(
guard let contents = try? FileManager().contentsOfDirectory(
at: root,
includingPropertiesForKeys: nil,
options: [.skipsHiddenFiles])
@@ -53,7 +53,7 @@ final class TailscaleService {
#endif
func checkAppInstallation() -> Bool {
let installed = FileManager.default.fileExists(atPath: "/Applications/Tailscale.app")
let installed = FileManager().fileExists(atPath: "/Applications/Tailscale.app")
self.logger.info("Tailscale app installed: \(installed)")
return installed
}
@@ -37,7 +37,7 @@ final class VoicePushToTalkHotkey: @unchecked Sendable {
}
private func startMonitoring() {
assert(Thread.isMainThread)
// assert(Thread.isMainThread) - Removed for Swift 6
guard self.globalMonitor == nil, self.localMonitor == nil else { return }
// Listen-only global monitor; we rely on Input Monitoring permission to receive events.
self.globalMonitor = NSEvent.addGlobalMonitorForEvents(matching: .flagsChanged) { [weak self] event in
@@ -55,7 +55,7 @@ final class VoicePushToTalkHotkey: @unchecked Sendable {
}
private func stopMonitoring() {
assert(Thread.isMainThread)
// assert(Thread.isMainThread) - Removed for Swift 6
if let globalMonitor {
NSEvent.removeMonitor(globalMonitor)
self.globalMonitor = nil
@@ -75,15 +75,11 @@ final class VoicePushToTalkHotkey: @unchecked Sendable {
}
private func withMainThread(_ block: @escaping @Sendable () -> Void) {
if Thread.isMainThread {
block()
} else {
DispatchQueue.main.async(execute: block)
}
DispatchQueue.main.async(execute: block)
}
private func updateModifierState(keyCode: UInt16, modifierFlags: NSEvent.ModifierFlags) {
assert(Thread.isMainThread)
// assert(Thread.isMainThread) - Removed for Swift 6
// Right Option (keyCode 61) acts as a hold-to-talk modifier.
if keyCode == 61 {
self.optionDown = modifierFlags.contains(.option)
+1 -1
View File
@@ -409,7 +409,7 @@ extension Request: Codable {
// Shared transport settings
public let controlSocketPath =
FileManager.default
FileManager()
.homeDirectoryForCurrentUser
.appendingPathComponent("Library/Application Support/clawdbot/control.sock")
.path
@@ -187,7 +187,7 @@ private func resolvedPassword(opts: WizardCliOptions, config: GatewayConfig) ->
}
private func loadGatewayConfig() -> GatewayConfig {
let url = FileManager.default.homeDirectoryForCurrentUser
let url = FileManager().homeDirectoryForCurrentUser
.appendingPathComponent(".clawdbot")
.appendingPathComponent("clawdbot.json")
guard let data = try? Data(contentsOf: url) else { return GatewayConfig() }
@@ -6,7 +6,7 @@ import Testing
struct AgentWorkspaceTests {
@Test
func displayPathUsesTildeForHome() {
let home = FileManager.default.homeDirectoryForCurrentUser
let home = FileManager().homeDirectoryForCurrentUser
#expect(AgentWorkspace.displayPath(for: home) == "~")
let inside = home.appendingPathComponent("Projects", isDirectory: true)
@@ -28,12 +28,12 @@ struct AgentWorkspaceTests {
@Test
func bootstrapCreatesAgentsFileWhenMissing() throws {
let tmp = FileManager.default.temporaryDirectory
let tmp = FileManager().temporaryDirectory
.appendingPathComponent("clawdbot-ws-\(UUID().uuidString)", isDirectory: true)
defer { try? FileManager.default.removeItem(at: tmp) }
defer { try? FileManager().removeItem(at: tmp) }
let agentsURL = try AgentWorkspace.bootstrap(workspaceURL: tmp)
#expect(FileManager.default.fileExists(atPath: agentsURL.path))
#expect(FileManager().fileExists(atPath: agentsURL.path))
let contents = try String(contentsOf: agentsURL, encoding: .utf8)
#expect(contents.contains("# AGENTS.md"))
@@ -41,9 +41,9 @@ struct AgentWorkspaceTests {
let identityURL = tmp.appendingPathComponent(AgentWorkspace.identityFilename)
let userURL = tmp.appendingPathComponent(AgentWorkspace.userFilename)
let bootstrapURL = tmp.appendingPathComponent(AgentWorkspace.bootstrapFilename)
#expect(FileManager.default.fileExists(atPath: identityURL.path))
#expect(FileManager.default.fileExists(atPath: userURL.path))
#expect(FileManager.default.fileExists(atPath: bootstrapURL.path))
#expect(FileManager().fileExists(atPath: identityURL.path))
#expect(FileManager().fileExists(atPath: userURL.path))
#expect(FileManager().fileExists(atPath: bootstrapURL.path))
let second = try AgentWorkspace.bootstrap(workspaceURL: tmp)
#expect(second == agentsURL)
@@ -51,10 +51,10 @@ struct AgentWorkspaceTests {
@Test
func bootstrapSafetyRejectsNonEmptyFolderWithoutAgents() throws {
let tmp = FileManager.default.temporaryDirectory
let tmp = FileManager().temporaryDirectory
.appendingPathComponent("clawdbot-ws-\(UUID().uuidString)", isDirectory: true)
defer { try? FileManager.default.removeItem(at: tmp) }
try FileManager.default.createDirectory(at: tmp, withIntermediateDirectories: true)
defer { try? FileManager().removeItem(at: tmp) }
try FileManager().createDirectory(at: tmp, withIntermediateDirectories: true)
let marker = tmp.appendingPathComponent("notes.txt")
try "hello".write(to: marker, atomically: true, encoding: .utf8)
@@ -69,10 +69,10 @@ struct AgentWorkspaceTests {
@Test
func bootstrapSafetyAllowsExistingAgentsFile() throws {
let tmp = FileManager.default.temporaryDirectory
let tmp = FileManager().temporaryDirectory
.appendingPathComponent("clawdbot-ws-\(UUID().uuidString)", isDirectory: true)
defer { try? FileManager.default.removeItem(at: tmp) }
try FileManager.default.createDirectory(at: tmp, withIntermediateDirectories: true)
defer { try? FileManager().removeItem(at: tmp) }
try FileManager().createDirectory(at: tmp, withIntermediateDirectories: true)
let agents = tmp.appendingPathComponent(AgentWorkspace.agentsFilename)
try "# AGENTS.md".write(to: agents, atomically: true, encoding: .utf8)
@@ -87,25 +87,25 @@ struct AgentWorkspaceTests {
@Test
func bootstrapSkipsBootstrapFileWhenWorkspaceHasContent() throws {
let tmp = FileManager.default.temporaryDirectory
let tmp = FileManager().temporaryDirectory
.appendingPathComponent("clawdbot-ws-\(UUID().uuidString)", isDirectory: true)
defer { try? FileManager.default.removeItem(at: tmp) }
try FileManager.default.createDirectory(at: tmp, withIntermediateDirectories: true)
defer { try? FileManager().removeItem(at: tmp) }
try FileManager().createDirectory(at: tmp, withIntermediateDirectories: true)
let marker = tmp.appendingPathComponent("notes.txt")
try "hello".write(to: marker, atomically: true, encoding: .utf8)
_ = try AgentWorkspace.bootstrap(workspaceURL: tmp)
let bootstrapURL = tmp.appendingPathComponent(AgentWorkspace.bootstrapFilename)
#expect(!FileManager.default.fileExists(atPath: bootstrapURL.path))
#expect(!FileManager().fileExists(atPath: bootstrapURL.path))
}
@Test
func needsBootstrapFalseWhenIdentityAlreadySet() throws {
let tmp = FileManager.default.temporaryDirectory
let tmp = FileManager().temporaryDirectory
.appendingPathComponent("clawdbot-ws-\(UUID().uuidString)", isDirectory: true)
defer { try? FileManager.default.removeItem(at: tmp) }
try FileManager.default.createDirectory(at: tmp, withIntermediateDirectories: true)
defer { try? FileManager().removeItem(at: tmp) }
try FileManager().createDirectory(at: tmp, withIntermediateDirectories: true)
let identityURL = tmp.appendingPathComponent(AgentWorkspace.identityFilename)
try """
# IDENTITY.md - Agent Identity
@@ -6,9 +6,9 @@ import Testing
struct AnthropicAuthResolverTests {
@Test
func prefersOAuthFileOverEnv() throws {
let dir = FileManager.default.temporaryDirectory
let dir = FileManager().temporaryDirectory
.appendingPathComponent("clawdbot-oauth-\(UUID().uuidString)", isDirectory: true)
try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
try FileManager().createDirectory(at: dir, withIntermediateDirectories: true)
let oauthFile = dir.appendingPathComponent("oauth.json")
let payload = [
"anthropic": [
@@ -6,7 +6,7 @@ import Testing
@MainActor
struct CLIInstallerTests {
@Test func installedLocationFindsExecutable() throws {
let fm = FileManager.default
let fm = FileManager()
let root = fm.temporaryDirectory.appendingPathComponent(
"clawdbot-cli-installer-\(UUID().uuidString)")
defer { try? fm.removeItem(at: root) }
@@ -7,13 +7,13 @@ import Testing
private func makeTempDir() throws -> URL {
let base = URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true)
let dir = base.appendingPathComponent("clawdbot-canvaswatch-\(UUID().uuidString)", isDirectory: true)
try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
try FileManager().createDirectory(at: dir, withIntermediateDirectories: true)
return dir
}
@Test func detectsInPlaceFileWrites() async throws {
let dir = try self.makeTempDir()
defer { try? FileManager.default.removeItem(at: dir) }
defer { try? FileManager().removeItem(at: dir) }
let file = dir.appendingPathComponent("index.html")
try "hello".write(to: file, atomically: false, encoding: .utf8)
@@ -8,10 +8,10 @@ import Testing
@MainActor
struct CanvasWindowSmokeTests {
@Test func panelControllerShowsAndHides() async throws {
let root = FileManager.default.temporaryDirectory
let root = FileManager().temporaryDirectory
.appendingPathComponent("clawdbot-canvas-test-\(UUID().uuidString)")
try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true)
defer { try? FileManager.default.removeItem(at: root) }
try FileManager().createDirectory(at: root, withIntermediateDirectories: true)
defer { try? FileManager().removeItem(at: root) }
let anchor = { NSRect(x: 200, y: 400, width: 40, height: 40) }
let controller = try CanvasWindowController(
@@ -31,10 +31,10 @@ struct CanvasWindowSmokeTests {
}
@Test func windowControllerShowsAndCloses() async throws {
let root = FileManager.default.temporaryDirectory
let root = FileManager().temporaryDirectory
.appendingPathComponent("clawdbot-canvas-test-\(UUID().uuidString)")
try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true)
defer { try? FileManager.default.removeItem(at: root) }
try FileManager().createDirectory(at: root, withIntermediateDirectories: true)
defer { try? FileManager().removeItem(at: root) }
let controller = try CanvasWindowController(
sessionKey: "main",
@@ -6,7 +6,7 @@ import Testing
struct ClawdbotConfigFileTests {
@Test
func configPathRespectsEnvOverride() async {
let override = FileManager.default.temporaryDirectory
let override = FileManager().temporaryDirectory
.appendingPathComponent("clawdbot-config-\(UUID().uuidString)")
.appendingPathComponent("clawdbot.json")
.path
@@ -19,7 +19,7 @@ struct ClawdbotConfigFileTests {
@MainActor
@Test
func remoteGatewayPortParsesAndMatchesHost() async {
let override = FileManager.default.temporaryDirectory
let override = FileManager().temporaryDirectory
.appendingPathComponent("clawdbot-config-\(UUID().uuidString)")
.appendingPathComponent("clawdbot.json")
.path
@@ -42,7 +42,7 @@ struct ClawdbotConfigFileTests {
@MainActor
@Test
func setRemoteGatewayUrlPreservesScheme() async {
let override = FileManager.default.temporaryDirectory
let override = FileManager().temporaryDirectory
.appendingPathComponent("clawdbot-config-\(UUID().uuidString)")
.appendingPathComponent("clawdbot.json")
.path
@@ -64,7 +64,7 @@ struct ClawdbotConfigFileTests {
@Test
func stateDirOverrideSetsConfigPath() async {
let dir = FileManager.default.temporaryDirectory
let dir = FileManager().temporaryDirectory
.appendingPathComponent("clawdbot-state-\(UUID().uuidString)", isDirectory: true)
.path
@@ -6,7 +6,7 @@ import Testing
struct ClawdbotOAuthStoreTests {
@Test
func returnsMissingWhenFileAbsent() {
let url = FileManager.default.temporaryDirectory
let url = FileManager().temporaryDirectory
.appendingPathComponent("clawdbot-oauth-\(UUID().uuidString)")
.appendingPathComponent("oauth.json")
#expect(ClawdbotOAuthStore.anthropicOAuthStatus(at: url) == .missingFile)
@@ -24,7 +24,7 @@ struct ClawdbotOAuthStoreTests {
}
}
let dir = FileManager.default.temporaryDirectory
let dir = FileManager().temporaryDirectory
.appendingPathComponent("clawdbot-oauth-\(UUID().uuidString)", isDirectory: true)
setenv(key, dir.path, 1)
@@ -85,9 +85,9 @@ struct ClawdbotOAuthStoreTests {
}
private func writeOAuthFile(_ json: [String: Any]) throws -> URL {
let dir = FileManager.default.temporaryDirectory
let dir = FileManager().temporaryDirectory
.appendingPathComponent("clawdbot-oauth-\(UUID().uuidString)", isDirectory: true)
try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
try FileManager().createDirectory(at: dir, withIntermediateDirectories: true)
let url = dir.appendingPathComponent("oauth.json")
let data = try JSONSerialization.data(withJSONObject: json, options: [.prettyPrinted, .sortedKeys])
@@ -12,16 +12,16 @@ import Testing
private func makeTempDir() throws -> URL {
let base = URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true)
let dir = base.appendingPathComponent(UUID().uuidString, isDirectory: true)
try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
try FileManager().createDirectory(at: dir, withIntermediateDirectories: true)
return dir
}
private func makeExec(at path: URL) throws {
try FileManager.default.createDirectory(
try FileManager().createDirectory(
at: path.deletingLastPathComponent(),
withIntermediateDirectories: true)
FileManager.default.createFile(atPath: path.path, contents: Data("echo ok\n".utf8))
try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: path.path)
FileManager().createFile(atPath: path.path, contents: Data("echo ok\n".utf8))
try FileManager().setAttributes([.posixPermissions: 0o755], ofItemAtPath: path.path)
}
@Test func prefersClawdbotBinary() async throws {
@@ -49,7 +49,7 @@ import Testing
let scriptPath = tmp.appendingPathComponent("bin/clawdbot.js")
try self.makeExec(at: nodePath)
try "#!/bin/sh\necho v22.0.0\n".write(to: nodePath, atomically: true, encoding: .utf8)
try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: nodePath.path)
try FileManager().setAttributes([.posixPermissions: 0o755], ofItemAtPath: nodePath.path)
try self.makeExec(at: scriptPath)
let cmd = CommandResolver.clawdbotCommand(
@@ -32,7 +32,7 @@ import Testing
}
private static func swiftFiles(under root: URL) throws -> [URL] {
let fm = FileManager.default
let fm = FileManager()
guard let enumerator = fm.enumerator(at: root, includingPropertiesForKeys: [.isRegularFileKey]) else {
return []
}
@@ -4,7 +4,7 @@ import Testing
@Suite struct GatewayLaunchAgentManagerTests {
@Test func launchAgentPlistSnapshotParsesArgsAndEnv() throws {
let url = FileManager.default.temporaryDirectory
let url = FileManager().temporaryDirectory
.appendingPathComponent("clawdbot-launchd-\(UUID().uuidString).plist")
let plist: [String: Any] = [
"ProgramArguments": ["clawdbot", "gateway-daemon", "--port", "18789", "--bind", "loopback"],
@@ -15,7 +15,7 @@ import Testing
]
let data = try PropertyListSerialization.data(fromPropertyList: plist, format: .xml, options: 0)
try data.write(to: url, options: [.atomic])
defer { try? FileManager.default.removeItem(at: url) }
defer { try? FileManager().removeItem(at: url) }
let snapshot = try #require(LaunchAgentPlist.snapshot(url: url))
#expect(snapshot.port == 18789)
@@ -25,14 +25,14 @@ import Testing
}
@Test func launchAgentPlistSnapshotAllowsMissingBind() throws {
let url = FileManager.default.temporaryDirectory
let url = FileManager().temporaryDirectory
.appendingPathComponent("clawdbot-launchd-\(UUID().uuidString).plist")
let plist: [String: Any] = [
"ProgramArguments": ["clawdbot", "gateway-daemon", "--port", "18789"],
]
let data = try PropertyListSerialization.data(fromPropertyList: plist, format: .xml, options: 0)
try data.write(to: url, options: [.atomic])
defer { try? FileManager.default.removeItem(at: url) }
defer { try? FileManager().removeItem(at: url) }
let snapshot = try #require(LaunchAgentPlist.snapshot(url: url))
#expect(snapshot.port == 18789)
@@ -5,7 +5,7 @@ import Testing
@Suite struct LogLocatorTests {
@Test func launchdGatewayLogPathEnsuresTmpDirExists() throws {
let fm = FileManager.default
let fm = FileManager()
let baseDir = URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true)
let logDir = baseDir.appendingPathComponent("clawdbot-tests-\(UUID().uuidString)")
@@ -77,9 +77,9 @@ struct LowCoverageHelperTests {
}
@Test func pairedNodesStorePersists() async throws {
let dir = FileManager.default.temporaryDirectory
let dir = FileManager().temporaryDirectory
.appendingPathComponent("paired-\(UUID().uuidString)", isDirectory: true)
try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
try FileManager().createDirectory(at: dir, withIntermediateDirectories: true)
let url = dir.appendingPathComponent("nodes.json")
let store = PairedNodesStore(fileURL: url)
await store.load()
@@ -143,12 +143,12 @@ struct LowCoverageHelperTests {
}
@Test @MainActor func canvasSchemeHandlerResolvesFilesAndErrors() throws {
let root = FileManager.default.temporaryDirectory
let root = FileManager().temporaryDirectory
.appendingPathComponent("canvas-\(UUID().uuidString)", isDirectory: true)
defer { try? FileManager.default.removeItem(at: root) }
try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true)
defer { try? FileManager().removeItem(at: root) }
try FileManager().createDirectory(at: root, withIntermediateDirectories: true)
let session = root.appendingPathComponent("main", isDirectory: true)
try FileManager.default.createDirectory(at: session, withIntermediateDirectories: true)
try FileManager().createDirectory(at: session, withIntermediateDirectories: true)
let index = session.appendingPathComponent("index.html")
try "<h1>Hello</h1>".write(to: index, atomically: true, encoding: .utf8)
@@ -59,7 +59,7 @@ struct MacNodeRuntimeTests {
includeAudio: Bool?,
outPath: String?) async throws -> (path: String, hasAudio: Bool)
{
let url = FileManager.default.temporaryDirectory
let url = FileManager().temporaryDirectory
.appendingPathComponent("clawdbot-test-screen-record-\(UUID().uuidString).mp4")
try Data("ok".utf8).write(to: url)
return (path: url.path, hasAudio: false)
@@ -19,9 +19,9 @@ struct ModelCatalogLoaderTests {
};
"""
let tmp = FileManager.default.temporaryDirectory
let tmp = FileManager().temporaryDirectory
.appendingPathComponent("models-\(UUID().uuidString).ts")
defer { try? FileManager.default.removeItem(at: tmp) }
defer { try? FileManager().removeItem(at: tmp) }
try src.write(to: tmp, atomically: true, encoding: .utf8)
let choices = try await ModelCatalogLoader.load(from: tmp.path)
@@ -42,9 +42,9 @@ struct ModelCatalogLoaderTests {
@Test
func loadWithNoExportReturnsEmptyChoices() async throws {
let src = "const NOPE = 1;"
let tmp = FileManager.default.temporaryDirectory
let tmp = FileManager().temporaryDirectory
.appendingPathComponent("models-\(UUID().uuidString).ts")
defer { try? FileManager.default.removeItem(at: tmp) }
defer { try? FileManager().removeItem(at: tmp) }
try src.write(to: tmp, atomically: true, encoding: .utf8)
let choices = try await ModelCatalogLoader.load(from: tmp.path)
@@ -6,16 +6,16 @@ import Testing
private func makeTempDir() throws -> URL {
let base = URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true)
let dir = base.appendingPathComponent(UUID().uuidString, isDirectory: true)
try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
try FileManager().createDirectory(at: dir, withIntermediateDirectories: true)
return dir
}
private func makeExec(at path: URL) throws {
try FileManager.default.createDirectory(
try FileManager().createDirectory(
at: path.deletingLastPathComponent(),
withIntermediateDirectories: true)
FileManager.default.createFile(atPath: path.path, contents: Data("echo ok\n".utf8))
try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: path.path)
FileManager().createFile(atPath: path.path, contents: Data("echo ok\n".utf8))
try FileManager().setAttributes([.posixPermissions: 0o755], ofItemAtPath: path.path)
}
@Test func fnmNodeBinsPreferNewestInstalledVersion() throws {
@@ -37,7 +37,7 @@ import Testing
let home = try self.makeTempDir()
let missingNodeBin = home
.appendingPathComponent(".local/share/fnm/node-versions/v99.0.0/installation/bin")
try FileManager.default.createDirectory(at: missingNodeBin, withIntermediateDirectories: true)
try FileManager().createDirectory(at: missingNodeBin, withIntermediateDirectories: true)
let bins = CommandResolver._testNodeManagerBinPaths(home: home)
#expect(!bins.contains(missingNodeBin.path))
@@ -6,10 +6,10 @@ import Testing
private func makeTempExecutable(contents: String) throws -> URL {
let dir = URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true)
.appendingPathComponent(UUID().uuidString, isDirectory: true)
try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
try FileManager().createDirectory(at: dir, withIntermediateDirectories: true)
let path = dir.appendingPathComponent("node")
try contents.write(to: path, atomically: true, encoding: .utf8)
try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: path.path)
try FileManager().setAttributes([.posixPermissions: 0o755], ofItemAtPath: path.path)
return path
}
@@ -109,7 +109,7 @@ enum TestIsolation {
}
nonisolated static func tempConfigPath() -> String {
FileManager.default.temporaryDirectory
FileManager().temporaryDirectory
.appendingPathComponent("clawdbot-test-config-\(UUID().uuidString).json")
.path
}
@@ -47,17 +47,17 @@ import Testing
.appendingPathComponent(UUID().uuidString, isDirectory: true)
let dist = tmp.appendingPathComponent("dist/index.js")
let bin = tmp.appendingPathComponent("bin/clawdbot.js")
try FileManager.default.createDirectory(at: dist.deletingLastPathComponent(), withIntermediateDirectories: true)
try FileManager.default.createDirectory(at: bin.deletingLastPathComponent(), withIntermediateDirectories: true)
FileManager.default.createFile(atPath: dist.path, contents: Data())
FileManager.default.createFile(atPath: bin.path, contents: Data())
try FileManager().createDirectory(at: dist.deletingLastPathComponent(), withIntermediateDirectories: true)
try FileManager().createDirectory(at: bin.deletingLastPathComponent(), withIntermediateDirectories: true)
FileManager().createFile(atPath: dist.path, contents: Data())
FileManager().createFile(atPath: bin.path, contents: Data())
let entry = CommandResolver.gatewayEntrypoint(in: tmp)
#expect(entry == dist.path)
}
@Test func logLocatorPicksNewestLogFile() throws {
let fm = FileManager.default
let fm = FileManager()
let dir = URL(fileURLWithPath: "/tmp/clawdbot", isDirectory: true)
try? fm.createDirectory(at: dir, withIntermediateDirectories: true)
@@ -2,7 +2,7 @@ import Foundation
public enum ClawdbotNodeStorage {
public static func appSupportDir() throws -> URL {
let base = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first
let base = FileManager().urls(for: .applicationSupportDirectory, in: .userDomainMask).first
guard let base else {
throw NSError(domain: "ClawdbotNodeStorage", code: 1, userInfo: [
NSLocalizedDescriptionKey: "Application Support directory unavailable",
@@ -19,7 +19,7 @@ public enum ClawdbotNodeStorage {
}
public static func cachesDir() throws -> URL {
let base = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first
let base = FileManager().urls(for: .cachesDirectory, in: .userDomainMask).first
guard let base else {
throw NSError(domain: "ClawdbotNodeStorage", code: 2, userInfo: [
NSLocalizedDescriptionKey: "Caches directory unavailable",
+7 -1
View File
@@ -28,9 +28,11 @@ export AWS_REGION="us-east-1"
# Optional:
export AWS_SESSION_TOKEN="..."
export AWS_PROFILE="your-profile"
# Optional (Bedrock API key/bearer token):
export AWS_BEARER_TOKEN_BEDROCK="..."
```
2) Add a Bedrock provider and model to your config:
2) Add a Bedrock provider and model to your config (no `apiKey` required):
```json5
{
@@ -39,6 +41,7 @@ export AWS_PROFILE="your-profile"
"amazon-bedrock": {
baseUrl: "https://bedrock-runtime.us-east-1.amazonaws.com",
api: "bedrock-converse-stream",
auth: "aws-sdk",
models: [
{
id: "anthropic.claude-3-7-sonnet-20250219-v1:0",
@@ -65,6 +68,9 @@ export AWS_PROFILE="your-profile"
- Bedrock requires **model access** enabled in your AWS account/region.
- If you use profiles, set `AWS_PROFILE` on the gateway host.
- Clawdbot surfaces the credential source in this order: `AWS_BEARER_TOKEN_BEDROCK`,
then `AWS_ACCESS_KEY_ID` + `AWS_SECRET_ACCESS_KEY`, then `AWS_PROFILE`, then the
default AWS SDK chain.
- Reasoning support depends on the model; check the Bedrock model card for
current capabilities.
- If you prefer a managed key flow, you can also place an OpenAI‑compatible
+1
View File
@@ -73,6 +73,7 @@ These run inside the agent loop or gateway pipeline:
- **`agent_end`**: inspect the final message list and run metadata after completion.
- **`before_compaction` / `after_compaction`**: observe or annotate compaction cycles.
- **`before_tool_call` / `after_tool_call`**: intercept tool params/results.
- **`tool_result_persist`**: synchronously transform tool results before they are written to the session transcript.
- **`message_received` / `message_sending` / `message_sent`**: inbound + outbound message hooks.
- **`session_start` / `session_end`**: session lifecycle boundaries.
- **`gateway_start` / `gateway_stop`**: gateway lifecycle events.
+2 -2
View File
@@ -117,8 +117,8 @@ Send these as standalone messages so they register.
```
## Inspecting
- `pnpm clawdbot status` — shows store path and recent sessions.
- `pnpm clawdbot sessions --json` — dumps every entry (filter with `--active <minutes>`).
- `clawdbot status` — shows store path and recent sessions.
- `clawdbot sessions --json` — dumps every entry (filter with `--active <minutes>`).
- `clawdbot gateway call sessions.list --params '{}'` — fetch sessions from the running gateway (use `--url`/`--token` for remote gateway access).
- Send `/status` as a standalone message in chat to see whether the agent is reachable, how much of the session context is used, current thinking/verbose toggles, and when your WhatsApp web creds were last refreshed (helps spot relink needs).
- Send `/context list` or `/context detail` to see what’s in the system prompt and injected workspace files (and the biggest context contributors).
+1 -1
View File
@@ -48,7 +48,7 @@ node --import tsx scripts/repro/tsx-name-repro.ts
## Regression history
- `2871657e` (2026-01-06): scripts changed from Bun to tsx to make Bun optional.
- Before that (Bun path), `pnpm clawdbot status` and `gateway:watch` worked.
- Before that (Bun path), `clawdbot status` and `gateway:watch` worked.
## Workarounds
- Use Bun for dev scripts (current temporary revert).
+4 -2
View File
@@ -59,9 +59,11 @@ Recommended flow (dev profile + dev bootstrap):
```bash
pnpm gateway:dev
CLAWDBOT_PROFILE=dev pnpm clawdbot tui
CLAWDBOT_PROFILE=dev clawdbot tui
```
If you don’t have a global install yet, run the CLI via `pnpm clawdbot ...`.
What this does:
1) **Profile isolation** (global `--dev`)
@@ -89,7 +91,7 @@ Note: `--dev` is a **global** profile flag and gets eaten by some runners.
If you need to spell it out, use the env var form:
```bash
CLAWDBOT_PROFILE=dev pnpm clawdbot gateway --dev --reset
CLAWDBOT_PROFILE=dev clawdbot gateway --dev --reset
```
`--reset` wipes config, credentials, sessions, and the dev workspace (using
@@ -0,0 +1,122 @@
---
summary: "Plan: Add OpenResponses /v1/responses endpoint and deprecate chat completions cleanly"
owner: "clawdbot"
status: "draft"
last_updated: "2026-01-19"
---
# OpenResponses Gateway Integration Plan
## Context
Clawdbot Gateway currently exposes a minimal OpenAI-compatible Chat Completions endpoint at
`/v1/chat/completions` (see [OpenAI Chat Completions](/gateway/openai-http-api)).
Open Responses is an open inference standard based on the OpenAI Responses API. It is designed
for agentic workflows and uses item-based inputs plus semantic streaming events. The OpenResponses
spec defines `/v1/responses`, not `/v1/chat/completions`.
## Goals
- Add a `/v1/responses` endpoint that adheres to OpenResponses semantics.
- Keep Chat Completions as a compatibility layer that is easy to disable and eventually remove.
- Standardize validation and parsing with isolated, reusable schemas.
## Non-goals
- Full OpenResponses feature parity in the first pass (images, files, hosted tools).
- Replacing internal agent execution logic or tool orchestration.
- Changing the existing `/v1/chat/completions` behavior during the first phase.
## Research Summary
Sources: OpenResponses OpenAPI, OpenResponses specification site, and the Hugging Face blog post.
Key points extracted:
- `POST /v1/responses` accepts `CreateResponseBody` fields like `model`, `input` (string or
`ItemParam[]`), `instructions`, `tools`, `tool_choice`, `stream`, `max_output_tokens`, and
`max_tool_calls`.
- `ItemParam` is a discriminated union of:
- `message` items with roles `system`, `developer`, `user`, `assistant`
- `function_call` and `function_call_output`
- `reasoning`
- `item_reference`
- Successful responses return a `ResponseResource` with `object: "response"`, `status`, and
`output` items.
- Streaming uses semantic events such as:
- `response.created`, `response.in_progress`, `response.completed`, `response.failed`
- `response.output_item.added`, `response.output_item.done`
- `response.content_part.added`, `response.content_part.done`
- `response.output_text.delta`, `response.output_text.done`
- The spec requires:
- `Content-Type: text/event-stream`
- `event:` must match the JSON `type` field
- terminal event must be literal `[DONE]`
- Reasoning items may expose `content`, `encrypted_content`, and `summary`.
- HF examples include `OpenResponses-Version: latest` in requests (optional header).
## Proposed Architecture
- Add `src/gateway/open-responses.schema.ts` containing Zod schemas only (no gateway imports).
- Add `src/gateway/openresponses-http.ts` (or `open-responses-http.ts`) for `/v1/responses`.
- Keep `src/gateway/openai-http.ts` intact as a legacy compatibility adapter.
- Add config `gateway.http.endpoints.responses.enabled` (default `false`).
- Keep `gateway.http.endpoints.chatCompletions.enabled` independent; allow both endpoints to be
toggled separately.
- Emit a startup warning when Chat Completions is enabled to signal legacy status.
## Deprecation Path for Chat Completions
- Maintain strict module boundaries: no shared schema types between responses and chat completions.
- Make Chat Completions opt-in by config so it can be disabled without code changes.
- Update docs to label Chat Completions as legacy once `/v1/responses` is stable.
- Optional future step: map Chat Completions requests to the Responses handler for a simpler
removal path.
## Phase 1 Support Subset
- Accept `input` as string or `ItemParam[]` with message roles and `function_call_output`.
- Extract system and developer messages into `extraSystemPrompt`.
- Use the most recent `user` or `function_call_output` as the current message for agent runs.
- Reject unsupported content parts (image/file) with `invalid_request_error`.
- Return a single assistant message with `output_text` content.
- Return `usage` with zeroed values until token accounting is wired.
## Validation Strategy (No SDK)
- Implement Zod schemas for the supported subset of:
- `CreateResponseBody`
- `ItemParam` + message content part unions
- `ResponseResource`
- Streaming event shapes used by the gateway
- Keep schemas in a single, isolated module to avoid drift and allow future codegen.
## Streaming Implementation (Phase 1)
- SSE lines with both `event:` and `data:`.
- Required sequence (minimum viable):
- `response.created`
- `response.output_item.added`
- `response.content_part.added`
- `response.output_text.delta` (repeat as needed)
- `response.output_text.done`
- `response.content_part.done`
- `response.completed`
- `[DONE]`
## Tests and Verification Plan
- Add e2e coverage for `/v1/responses`:
- Auth required
- Non-stream response shape
- Stream event ordering and `[DONE]`
- Session routing with headers and `user`
- Keep `src/gateway/openai-http.e2e.test.ts` unchanged.
- Manual: curl to `/v1/responses` with `stream: true` and verify event ordering and terminal
`[DONE]`.
## Doc Updates (Follow-up)
- Add a new docs page for `/v1/responses` usage and examples.
- Update `/gateway/openai-http-api` with a legacy note and pointer to `/v1/responses`.
+1
View File
@@ -2669,6 +2669,7 @@ Notes:
- `clawdbot gateway` refuses to start unless `gateway.mode` is set to `local` (or you pass the override flag).
- `gateway.port` controls the single multiplexed port used for WebSocket + HTTP (control UI, hooks, A2UI).
- OpenAI Chat Completions endpoint: **disabled by default**; enable with `gateway.http.endpoints.chatCompletions.enabled: true`.
- OpenResponses endpoint: **disabled by default**; enable with `gateway.http.endpoints.responses.enabled: true`.
- Precedence: `--port` > `CLAWDBOT_GATEWAY_PORT` > `gateway.port` > default `18789`.
- Non-loopback binds (`lan`/`tailnet`/`auto`) require auth. Use `gateway.auth.token` (or `CLAWDBOT_GATEWAY_TOKEN`).
- The onboarding wizard generates a gateway token by default (even on loopback).
+1
View File
@@ -29,6 +29,7 @@ pnpm gateway:watch
- Binds WebSocket control plane to `127.0.0.1:<port>` (default 18789).
- The same port also serves HTTP (control UI, hooks, A2UI). Single-port multiplex.
- OpenAI Chat Completions (HTTP): [`/v1/chat/completions`](/gateway/openai-http-api).
- OpenResponses (HTTP): [`/v1/responses`](/gateway/openresponses-http-api).
- Starts a Canvas file server by default on `canvasHost.port` (default `18793`), serving `http://<gateway-host>:18793/__clawdbot__/canvas/` from `~/clawd/canvas`. Disable with `canvasHost.enabled=false` or `CLAWDBOT_SKIP_CANVAS_HOST=1`.
- Logs to stdout; use launchd/systemd to keep it alive and rotate logs.
- Pass `--verbose` to mirror debug logging (handshakes, req/res, events) from the log file into stdio when troubleshooting.
+298
View File
@@ -0,0 +1,298 @@
---
summary: "Expose an OpenResponses-compatible /v1/responses HTTP endpoint from the Gateway"
read_when:
- Integrating clients that speak the OpenResponses API
- You want item-based inputs, client tool calls, or SSE events
---
# OpenResponses API (HTTP)
Clawdbot’s Gateway can serve an OpenResponses-compatible `POST /v1/responses` endpoint.
This endpoint is **disabled by default**. Enable it in config first.
- `POST /v1/responses`
- Same port as the Gateway (WS + HTTP multiplex): `http://<gateway-host>:<port>/v1/responses`
Under the hood, requests are executed as a normal Gateway agent run (same codepath as
`clawdbot agent`), so routing/permissions/config match your Gateway.
## Authentication
Uses the Gateway auth configuration. Send a bearer token:
- `Authorization: Bearer <token>`
Notes:
- When `gateway.auth.mode="token"`, use `gateway.auth.token` (or `CLAWDBOT_GATEWAY_TOKEN`).
- When `gateway.auth.mode="password"`, use `gateway.auth.password` (or `CLAWDBOT_GATEWAY_PASSWORD`).
## Choosing an agent
No custom headers required: encode the agent id in the OpenResponses `model` field:
- `model: "clawdbot:<agentId>"` (example: `"clawdbot:main"`, `"clawdbot:beta"`)
- `model: "agent:<agentId>"` (alias)
Or target a specific Clawdbot agent by header:
- `x-clawdbot-agent-id: <agentId>` (default: `main`)
Advanced:
- `x-clawdbot-session-key: <sessionKey>` to fully control session routing.
## Enabling the endpoint
Set `gateway.http.endpoints.responses.enabled` to `true`:
```json5
{
gateway: {
http: {
endpoints: {
responses: { enabled: true }
}
}
}
}
```
## Disabling the endpoint
Set `gateway.http.endpoints.responses.enabled` to `false`:
```json5
{
gateway: {
http: {
endpoints: {
responses: { enabled: false }
}
}
}
}
```
## Session behavior
By default the endpoint is **stateless per request** (a new session key is generated each call).
If the request includes an OpenResponses `user` string, the Gateway derives a stable session key
from it, so repeated calls can share an agent session.
## Request shape (supported)
The request follows the OpenResponses API with item-based input. Current support:
- `input`: string or array of item objects.
- `instructions`: merged into the system prompt.
- `tools`: client tool definitions (function tools).
- `tool_choice`: filter or require client tools.
- `stream`: enables SSE streaming.
- `max_output_tokens`: best-effort output limit (provider dependent).
- `user`: stable session routing.
Accepted but **currently ignored**:
- `max_tool_calls`
- `reasoning`
- `metadata`
- `store`
- `previous_response_id`
- `truncation`
## Items (input)
### `message`
Roles: `system`, `developer`, `user`, `assistant`.
- `system` and `developer` are appended to the system prompt.
- The most recent `user` or `function_call_output` item becomes the “current message.”
- Earlier user/assistant messages are included as history for context.
### `function_call_output` (turn-based tools)
Send tool results back to the model:
```json
{
"type": "function_call_output",
"call_id": "call_123",
"output": "{\"temperature\": \"72F\"}"
}
```
### `reasoning` and `item_reference`
Accepted for schema compatibility but ignored when building the prompt.
## Tools (client-side function tools)
Provide tools with `tools: [{ type: "function", function: { name, description?, parameters? } }]`.
If the agent decides to call a tool, the response returns a `function_call` output item.
You then send a follow-up request with `function_call_output` to continue the turn.
## Images (`input_image`)
Supports base64 or URL sources:
```json
{
"type": "input_image",
"source": { "type": "url", "url": "https://example.com/image.png" }
}
```
Allowed MIME types (current): `image/jpeg`, `image/png`, `image/gif`, `image/webp`.
Max size (current): 10MB.
## Files (`input_file`)
Supports base64 or URL sources:
```json
{
"type": "input_file",
"source": {
"type": "base64",
"media_type": "text/plain",
"data": "SGVsbG8gV29ybGQh",
"filename": "hello.txt"
}
}
```
Allowed MIME types (current): `text/plain`, `text/markdown`, `text/html`, `text/csv`,
`application/json`, `application/pdf`.
Max size (current): 5MB.
Current behavior:
- File content is decoded and added to the **system prompt**, not the user message,
so it stays ephemeral (not persisted in session history).
- PDFs are parsed for text. If little text is found, the first pages are rasterized
into images and passed to the model.
PDF parsing uses the Node-friendly `pdfjs-dist` legacy build (no worker). The modern
PDF.js build expects browser workers/DOM globals, so it is not used in the Gateway.
URL fetch defaults:
- `files.allowUrl`: `true`
- `images.allowUrl`: `true`
- Requests are guarded (DNS resolution, private IP blocking, redirect caps, timeouts).
## File + image limits (config)
Defaults can be tuned under `gateway.http.endpoints.responses`:
```json5
{
gateway: {
http: {
endpoints: {
responses: {
enabled: true,
maxBodyBytes: 20000000,
files: {
allowUrl: true,
allowedMimes: ["text/plain", "text/markdown", "text/html", "text/csv", "application/json", "application/pdf"],
maxBytes: 5242880,
maxChars: 200000,
maxRedirects: 3,
timeoutMs: 10000,
pdf: {
maxPages: 4,
maxPixels: 4000000,
minTextChars: 200
}
},
images: {
allowUrl: true,
allowedMimes: ["image/jpeg", "image/png", "image/gif", "image/webp"],
maxBytes: 10485760,
maxRedirects: 3,
timeoutMs: 10000
}
}
}
}
}
}
```
Defaults when omitted:
- `maxBodyBytes`: 20MB
- `files.maxBytes`: 5MB
- `files.maxChars`: 200k
- `files.maxRedirects`: 3
- `files.timeoutMs`: 10s
- `files.pdf.maxPages`: 4
- `files.pdf.maxPixels`: 4,000,000
- `files.pdf.minTextChars`: 200
- `images.maxBytes`: 10MB
- `images.maxRedirects`: 3
- `images.timeoutMs`: 10s
## Streaming (SSE)
Set `stream: true` to receive Server-Sent Events (SSE):
- `Content-Type: text/event-stream`
- Each event line is `event: <type>` and `data: <json>`
- Stream ends with `data: [DONE]`
Event types currently emitted:
- `response.created`
- `response.in_progress`
- `response.output_item.added`
- `response.content_part.added`
- `response.output_text.delta`
- `response.output_text.done`
- `response.content_part.done`
- `response.output_item.done`
- `response.completed`
- `response.failed` (on error)
## Usage
`usage` is populated when the underlying provider reports token counts.
## Errors
Errors use a JSON object like:
```json
{ "error": { "message": "...", "type": "invalid_request_error" } }
```
Common cases:
- `401` missing/invalid auth
- `400` invalid request body
- `405` wrong method
## Examples
Non-streaming:
```bash
curl -sS http://127.0.0.1:18789/v1/responses \
-H 'Authorization: Bearer YOUR_TOKEN' \
-H 'Content-Type: application/json' \
-H 'x-clawdbot-agent-id: main' \
-d '{
"model": "clawdbot",
"input": "hi"
}'
```
Streaming:
```bash
curl -N http://127.0.0.1:18789/v1/responses \
-H 'Authorization: Bearer YOUR_TOKEN' \
-H 'Content-Type: application/json' \
-H 'x-clawdbot-agent-id: main' \
-d '{
"model": "clawdbot",
"stream": true,
"input": "hi"
}'
```
+2 -2
View File
@@ -378,7 +378,7 @@ clawdbot channels login
### Build errors on `main` — what’s the standard fix path?
1) `git pull origin main && pnpm install`
2) `pnpm clawdbot doctor`
2) `clawdbot doctor`
3) Check GitHub issues or Discord
4) Temporary workaround: check out an older commit
@@ -392,7 +392,7 @@ Typical recovery:
git status # ensure you’re in the repo root
pnpm install
pnpm build
pnpm clawdbot doctor
clawdbot doctor
clawdbot daemon restart
```
+3 -1
View File
@@ -123,9 +123,11 @@ cd clawdbot
pnpm install
pnpm ui:build # auto-installs UI deps on first run
pnpm build
pnpm clawdbot onboard --install-daemon
clawdbot onboard --install-daemon
```
If you don’t have a global install yet, run the onboarding step via `pnpm clawdbot ...` from the repo.
Multi-instance quickstart (optional):
```bash
+98 -42
View File
@@ -7,40 +7,43 @@ read_when:
# Install
Runtime baseline: **Node >=22**.
Use the installer unless you have a reason not to. It sets up the CLI and runs onboarding.
If the installer says it succeeded but you later see `clawdbot: command not found`, it’s usually a Node/npm PATH issue (global npm bin dir not on PATH). See the section below.
## Node.js + npm (PATH sanity)
Quick diagnosis:
```bash
node -v
npm -v
npm bin -g
echo "$PATH"
```
If the output of `npm bin -g` is **not** present inside `echo "$PATH"`, your shell can’t find global npm binaries (including `clawdbot`).
Fix: add it to your shell startup file (zsh: `~/.zshrc`, bash: `~/.bashrc`):
```bash
export PATH="/path/from/npm/bin/-g:$PATH"
```
Then open a new terminal (or `rehash` in zsh / `hash -r` in bash).
## Recommended (installer script)
## Quick install (recommended)
```bash
curl -fsSL https://clawd.bot/install.sh | bash
```
This installs the `clawdbot` CLI globally via npm and then starts onboarding.
Windows (PowerShell):
See installer flags:
```powershell
iwr -useb https://clawd.bot/install.ps1 | iex
```
Next step (if you skipped onboarding):
```bash
clawdbot onboard --install-daemon
```
## System requirements
- **Node >=22**
- macOS, Linux, or Windows via WSL2
- `pnpm` only if you build from source
## Choose your install path
### 1) Installer script (recommended)
Installs `clawdbot` globally via npm and runs onboarding.
```bash
curl -fsSL https://clawd.bot/install.sh | bash
```
Installer flags:
```bash
curl -fsSL https://clawd.bot/install.sh | bash -s -- --help
@@ -54,7 +57,60 @@ Non-interactive (skip onboarding):
curl -fsSL https://clawd.bot/install.sh | bash -s -- --no-onboard
```
## Install method: npm vs git
### 2) Global install (manual)
If you already have Node:
```bash
npm install -g clawdbot@latest
```
If you have libvips installed globally (common on macOS via Homebrew) and `sharp` fails to install, force prebuilt binaries:
```bash
SHARP_IGNORE_GLOBAL_LIBVIPS=1 npm install -g clawdbot@latest
```
Or:
```bash
pnpm add -g clawdbot@latest
```
Then:
```bash
clawdbot onboard --install-daemon
```
### 3) From source (contributors/dev)
```bash
git clone https://github.com/clawdbot/clawdbot.git
cd clawdbot
pnpm install
pnpm ui:build # auto-installs UI deps on first run
pnpm build
clawdbot onboard --install-daemon
```
Tip: if you don’t have a global install yet, run repo commands via `pnpm clawdbot ...`.
### 4) Other install options
- Docker: [Docker](/install/docker)
- Nix: [Nix](/install/nix)
- Ansible: [Ansible](/install/ansible)
- Bun (CLI only): [Bun](/install/bun)
## After install
- Run onboarding: `clawdbot onboard --install-daemon`
- Quick check: `clawdbot doctor`
- Check gateway health: `clawdbot status` + `clawdbot health`
- Open the dashboard: `clawdbot dashboard`
## Install method: npm vs git (installer)
The installer supports two methods:
@@ -92,28 +148,28 @@ Equivalent env vars (useful for automation):
- `CLAWDBOT_NO_ONBOARD=1`
- `SHARP_IGNORE_GLOBAL_LIBVIPS=0|1` (default: `1`; avoids `sharp` building against system libvips)
## Global install (manual)
## Troubleshooting: `clawdbot` not found (PATH)
If you already have Node:
Quick diagnosis:
```bash
npm install -g clawdbot@latest
node -v
npm -v
npm bin -g
echo "$PATH"
```
If you have libvips installed globally (common on macOS via Homebrew) and `sharp` fails to install, force prebuilt binaries:
If the output of `npm bin -g` is **not** present inside `echo "$PATH"`, your shell can’t find global npm binaries (including `clawdbot`).
Fix: add it to your shell startup file (zsh: `~/.zshrc`, bash: `~/.bashrc`):
```bash
SHARP_IGNORE_GLOBAL_LIBVIPS=1 npm install -g clawdbot@latest
export PATH="/path/from/npm/bin/-g:$PATH"
```
Or:
Then open a new terminal (or `rehash` in zsh / `hash -r` in bash).
```bash
pnpm add -g clawdbot@latest
```
## Update / uninstall
Then:
```bash
clawdbot onboard --install-daemon
```
- Updates: [Updating](/install/updating)
- Uninstall: [Uninstall](/install/uninstall)
+1 -1
View File
@@ -118,7 +118,7 @@ Remove it with `npm rm -g clawdbot` (or `pnpm remove -g` / `bun remove -g` if yo
### Source checkout (git clone)
If you run from a repo checkout (`git clone` + `pnpm clawdbot ...` / `bun run clawdbot ...`):
If you run from a repo checkout (`git clone` + `clawdbot ...` / `bun run clawdbot ...`):
1) Uninstall the gateway service **before** deleting the repo (use the easy path above or manual service removal).
2) Delete the repo directory.
+3 -2
View File
@@ -119,12 +119,13 @@ git pull
pnpm install
pnpm build
pnpm ui:build # auto-installs UI deps on first run
pnpm clawdbot doctor
pnpm clawdbot health
clawdbot doctor
clawdbot health
```
Notes:
- `pnpm build` matters when you run the packaged `clawdbot` binary ([`dist/entry.js`](https://github.com/clawdbot/clawdbot/blob/main/dist/entry.js)) or use Node to run `dist/`.
- If you run from a repo checkout without a global install, use `pnpm clawdbot ...` for CLI commands.
- If you run directly from TypeScript (`pnpm clawdbot ...`), a rebuild is usually unnecessary, but **config migrations still apply** → run doctor.
- Switching between global and git installs is easy: install the other flavor, then run `clawdbot doctor` so the gateway service entrypoint is rewritten to the current install.
+5 -2
View File
@@ -64,8 +64,11 @@ If both `PERPLEXITY_API_KEY` and `OPENROUTER_API_KEY` are set, set
`tools.web.search.perplexity.baseUrl` (or `tools.web.search.perplexity.apiKey`)
to disambiguate.
If `PERPLEXITY_API_KEY` is used from the environment and no base URL is set,
Clawdbot defaults to the direct Perplexity endpoint. Set `baseUrl` to override.
If no base URL is set, Clawdbot chooses a default based on the API key source:
- `PERPLEXITY_API_KEY` or `pplx-...` → direct Perplexity (`https://api.perplexity.ai`)
- `OPENROUTER_API_KEY` or `sk-or-...` → OpenRouter (`https://openrouter.ai/api/v1`)
- Unknown key formats → OpenRouter (safe fallback)
## Models
+1 -1
View File
@@ -155,7 +155,7 @@ Options:
- `--timeout <ms>`: overall discovery window (default `2000`)
- `--json`: structured output for diffing
Tip: compare against `pnpm clawdbot gateway discover --json` to see whether the
Tip: compare against `clawdbot gateway discover --json` to see whether the
macOS app’s discovery pipeline (NWBrowser + tailnet DNS‑SD fallback) differs from
the Node CLI’s `dns-sd` based discovery.
+1 -1
View File
@@ -97,7 +97,7 @@ cd clawdbot
pnpm install
pnpm ui:build # auto-installs UI deps on first run
pnpm build
pnpm clawdbot onboard
clawdbot onboard
```
Full guide: [Getting Started](/start/getting-started)
+1
View File
@@ -31,6 +31,7 @@ Looking for chat channel docs (WhatsApp/Telegram/Discord/Slack/etc.)? See [Chann
- [Vercel AI Gateway](/providers/vercel-ai-gateway)
- [Moonshot AI (Kimi + Kimi Code)](/providers/moonshot)
- [OpenCode Zen](/providers/opencode)
- [Amazon Bedrock](/bedrock)
- [Z.AI](/providers/zai)
- [GLM models](/providers/glm)
- [MiniMax](/providers/minimax)
+16 -13
View File
@@ -184,22 +184,25 @@ Clawdbot is a personal AI assistant you run on your own devices. It replies on t
The repo recommends running from source and using the onboarding wizard:
```bash
git clone https://github.com/clawdbot/clawdbot.git
cd clawdbot
pnpm install
# Optional if you want built output / global linking:
pnpm build
# If the Control UI assets are missing or you want the dashboard:
pnpm ui:build # auto-installs UI deps on first run
pnpm clawdbot onboard
curl -fsSL https://clawd.bot/install.sh | bash
clawdbot onboard --install-daemon
```
The wizard can also build UI assets automatically. After onboarding, you typically run the Gateway on port **18789**.
From source (contributors/dev):
```bash
git clone https://github.com/clawdbot/clawdbot.git
cd clawdbot
pnpm install
pnpm build
pnpm ui:build # auto-installs UI deps on first run
clawdbot onboard
```
If you don’t have a global install yet, run it via `pnpm clawdbot onboard`.
### How do I open the dashboard after onboarding?
The wizard now opens your browser with a tokenized dashboard URL right after onboarding and also prints the full link (with token) in the summary. Keep that tab open; if it didn’t launch, copy/paste the printed URL on the same machine. Tokens stay local to your host—nothing is fetched from the browser.
@@ -330,7 +333,7 @@ git clone https://github.com/clawdbot/clawdbot.git
cd clawdbot
pnpm install
pnpm build
pnpm clawdbot doctor
clawdbot doctor
clawdbot daemon restart
```
+12 -5
View File
@@ -116,6 +116,13 @@ If a token is configured, paste it into the Control UI settings (stored as `conn
⚠️ **Bun warning (WhatsApp + Telegram):** Bun has known issues with these
channels. If you use WhatsApp or Telegram, run the Gateway with **Node**.
## 3.5) Quick verify (2 min)
```bash
clawdbot status
clawdbot health
```
## 4) Pair + connect your first chat surface
### WhatsApp (QR login)
@@ -158,9 +165,11 @@ cd clawdbot
pnpm install
pnpm ui:build # auto-installs UI deps on first run
pnpm build
pnpm clawdbot onboard --install-daemon
clawdbot onboard --install-daemon
```
If you don’t have a global install yet, run the onboarding step via `pnpm clawdbot ...` from the repo.
Gateway (from this repo):
```bash
@@ -169,15 +178,13 @@ node dist/entry.js gateway --port 18789 --verbose
## 7) Verify end-to-end
In a new terminal:
In a new terminal, send a test message:
```bash
clawdbot status
clawdbot health
clawdbot message send --target +15555550123 --message "Hello from Clawdbot"
```
If `health` shows “no auth configured”, go back to the wizard and set OAuth/key auth — the agent won’t be able to respond without it.
If `clawdbot health` shows “no auth configured”, go back to the wizard and set OAuth/key auth — the agent won’t be able to respond without it.
Tip: `clawdbot status --all` is the best pasteable, read-only debug report.
Health probes: `clawdbot health` (or `clawdbot status --deep`) asks the running gateway for a health snapshot.
+4 -2
View File
@@ -35,9 +35,11 @@ clawdbot setup
From inside this repo, use the local CLI entry:
```bash
pnpm clawdbot setup
clawdbot setup
```
If you don’t have a global install yet, run it via `pnpm clawdbot setup`.
## Stable workflow (macOS app first)
1) Install + launch **Clawdbot.app** (menu bar).
@@ -92,7 +94,7 @@ The app will attach to the running gateway on the configured port.
- Or via CLI:
```bash
pnpm clawdbot health
clawdbot health
```
### Common footguns
+2 -2
View File
@@ -150,8 +150,8 @@ Live tests are split into two layers so we can isolate failures:
Tip: to see what you can test on your machine (and the exact `provider/model` ids), run:
```bash
pnpm clawdbot models list
pnpm clawdbot models list --json
clawdbot models list
clawdbot models list --json
```
## Live: Anthropic setup-token smoke
+8
View File
@@ -82,6 +82,14 @@ Notes:
- `homepage` — URL surfaced as “Website” in the macOS Skills UI (also supported via `metadata.clawdbot.homepage`).
- `user-invocable` — `true|false` (default: `true`). When `true`, the skill is exposed as a user slash command.
- `disable-model-invocation` — `true|false` (default: `false`). When `true`, the skill is excluded from the model prompt (still available via user invocation).
- `command-dispatch` — `tool` (optional). When set to `tool`, the slash command bypasses the model and dispatches directly to a tool.
- `command-tool` — tool name to invoke when `command-dispatch: tool` is set.
- `command-arg-mode` — `raw` (default). For tool dispatch, forwards the raw args string to the tool (no core parsing).
The tool is invoked with params:
`{ command: "<raw args>", commandName: "<slash command>", skillName: "<skill name>" }`.
Tool-dispatch commands still respect tool policies/sandbox rules (same as normal model tools).
Replies are derived from tool results; include `MEDIA:` tokens or media URLs in tool output to send attachments.
## Gating (load-time filters)
+3
View File
@@ -102,6 +102,9 @@ Notes:
- Currently: `/help`, `/commands`, `/status`, `/whoami` (`/id`).
- Unauthorized command-only messages are silently ignored, and inline `/...` tokens are treated as plain text.
- **Skill commands:** `user-invocable` skills are exposed as slash commands. Names are sanitized to `a-z0-9_` (max 32 chars); collisions get numeric suffixes (e.g. `_2`).
- By default, skill commands are forwarded to the model as a normal request.
- Skills may optionally declare `command-dispatch: tool` to route the command directly to a tool (deterministic, no model).
- Tool-dispatch still respects tool policies/sandbox rules; replies use the tool result text/media (including `MEDIA:` tokens).
- **Native command arguments:** Discord uses autocomplete for dynamic options (and button menus when you omit required args). Telegram and Slack show a button menu when a command supports choices and you omit the arg.
## Usage surfaces (what shows where)
+8 -5
View File
@@ -10,7 +10,7 @@ read_when:
Clawdbot ships two lightweight web tools:
- `web_search` — Search the web via Brave Search API (default) or Perplexity Sonar (via OpenRouter).
- `web_search` — Search the web via Brave Search API (default) or Perplexity Sonar (direct or via OpenRouter).
- `web_fetch` — HTTP fetch + readable extraction (HTML → markdown/text).
These are **not** browser automation. For JS-heavy sites or logins, use the
@@ -31,7 +31,7 @@ These are **not** browser automation. For JS-heavy sites or logins, use the
| Provider | Pros | Cons | API Key |
|----------|------|------|---------|
| **Brave** (default) | Fast, structured results, free tier | Traditional search results | `BRAVE_API_KEY` |
| **Perplexity** | AI-synthesized answers, citations, real-time | Requires OpenRouter credits | `OPENROUTER_API_KEY` or `PERPLEXITY_API_KEY` |
| **Perplexity** | AI-synthesized answers, citations, real-time | Requires Perplexity or OpenRouter access | `OPENROUTER_API_KEY` or `PERPLEXITY_API_KEY` |
See [Brave Search setup](/brave-search) and [Perplexity Sonar](/perplexity) for provider-specific details.
@@ -110,7 +110,7 @@ crypto/prepaid).
perplexity: {
// API key (optional if OPENROUTER_API_KEY or PERPLEXITY_API_KEY is set)
apiKey: "sk-or-v1-...",
// Base URL (defaults to OpenRouter)
// Base URL (key-aware default if omitted)
baseUrl: "https://openrouter.ai/api/v1",
// Model (defaults to perplexity/sonar-pro)
model: "perplexity/sonar-pro"
@@ -124,8 +124,11 @@ crypto/prepaid).
**Environment alternative:** set `OPENROUTER_API_KEY` or `PERPLEXITY_API_KEY` in the Gateway
environment. For a daemon install, put it in `~/.clawdbot/.env`.
If `PERPLEXITY_API_KEY` is used from the environment and no base URL is set,
Clawdbot defaults to the direct Perplexity endpoint (`https://api.perplexity.ai`).
If no base URL is set, Clawdbot chooses a default based on the API key source:
- `PERPLEXITY_API_KEY` or `pplx-...` → `https://api.perplexity.ai`
- `OPENROUTER_API_KEY` or `sk-or-...` → `https://openrouter.ai/api/v1`
- Unknown key formats → OpenRouter (safe fallback)
### Available Perplexity models
+2
View File
@@ -155,6 +155,7 @@
"@mariozechner/pi-coding-agent": "^0.46.0",
"@mariozechner/pi-tui": "^0.46.0",
"@mozilla/readability": "^0.6.0",
"@napi-rs/canvas": "^0.1.88",
"@sinclair/typebox": "0.34.47",
"@slack/bolt": "^4.6.0",
"@slack/web-api": "^7.13.0",
@@ -181,6 +182,7 @@
"long": "5.3.2",
"markdown-it": "^14.1.0",
"osc-progress": "^0.2.0",
"pdfjs-dist": "^5.4.530",
"playwright-core": "1.57.0",
"proper-lockfile": "^4.1.2",
"qrcode-terminal": "^0.12.0",
+131
View File
@@ -49,6 +49,9 @@ importers:
'@mozilla/readability':
specifier: ^0.6.0
version: 0.6.0
'@napi-rs/canvas':
specifier: ^0.1.88
version: 0.1.88
'@sinclair/typebox':
specifier: 0.34.47
version: 0.34.47
@@ -127,6 +130,9 @@ importers:
osc-progress:
specifier: ^0.2.0
version: 0.2.0
pdfjs-dist:
specifier: ^5.4.530
version: 5.4.530
playwright-core:
specifier: 1.57.0
version: 1.57.0
@@ -1205,6 +1211,76 @@ packages:
resolution: {integrity: sha512-juG5VWh4qAivzTAeMzvY9xs9HY5rAcr2E4I7tiSSCokRFi7XIZCAu92ZkSTsIj1OPceCifL3cpfteP3pDT9/QQ==}
engines: {node: '>=14.0.0'}
'@napi-rs/canvas-android-arm64@0.1.88':
resolution: {integrity: sha512-KEaClPnZuVxJ8smUWjV1wWFkByBO/D+vy4lN+Dm5DFH514oqwukxKGeck9xcKJhaWJGjfruGmYGiwRe//+/zQQ==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [android]
'@napi-rs/canvas-darwin-arm64@0.1.88':
resolution: {integrity: sha512-Xgywz0dDxOKSgx3eZnK85WgGMmGrQEW7ZLA/E7raZdlEE+xXCozobgqz2ZvYigpB6DJFYkqnwHjqCOTSDGlFdg==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [darwin]
'@napi-rs/canvas-darwin-x64@0.1.88':
resolution: {integrity: sha512-Yz4wSCIQOUgNucgk+8NFtQxQxZV5NO8VKRl9ePKE6XoNyNVC8JDqtvhh3b3TPqKK8W5p2EQpAr1rjjm0mfBxdg==}
engines: {node: '>= 10'}
cpu: [x64]
os: [darwin]
'@napi-rs/canvas-linux-arm-gnueabihf@0.1.88':
resolution: {integrity: sha512-9gQM2SlTo76hYhxHi2XxWTAqpTOb+JtxMPEIr+H5nAhHhyEtNmTSDRtz93SP7mGd2G3Ojf2oF5tP9OdgtgXyKg==}
engines: {node: '>= 10'}
cpu: [arm]
os: [linux]
'@napi-rs/canvas-linux-arm64-gnu@0.1.88':
resolution: {integrity: sha512-7qgaOBMXuVRk9Fzztzr3BchQKXDxGbY+nwsovD3I/Sx81e+sX0ReEDYHTItNb0Je4NHbAl7D0MKyd4SvUc04sg==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
'@napi-rs/canvas-linux-arm64-musl@0.1.88':
resolution: {integrity: sha512-kYyNrUsHLkoGHBc77u4Unh067GrfiCUMbGHC2+OTxbeWfZkPt2o32UOQkhnSswKd9Fko/wSqqGkY956bIUzruA==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
'@napi-rs/canvas-linux-riscv64-gnu@0.1.88':
resolution: {integrity: sha512-HVuH7QgzB0yavYdNZDRyAsn/ejoXB0hn8twwFnOqUbCCdkV+REna7RXjSR7+PdfW0qMQ2YYWsLvVBT5iL/mGpw==}
engines: {node: '>= 10'}
cpu: [riscv64]
os: [linux]
'@napi-rs/canvas-linux-x64-gnu@0.1.88':
resolution: {integrity: sha512-hvcvKIcPEQrvvJtJnwD35B3qk6umFJ8dFIr8bSymfrSMem0EQsfn1ztys8ETIFndTwdNWJKWluvxztA41ivsEw==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
'@napi-rs/canvas-linux-x64-musl@0.1.88':
resolution: {integrity: sha512-eSMpGYY2xnZSQ6UxYJ6plDboxq4KeJ4zT5HaVkUnbObNN6DlbJe0Mclh3wifAmquXfrlgTZt6zhHsUgz++AK6g==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
'@napi-rs/canvas-win32-arm64-msvc@0.1.88':
resolution: {integrity: sha512-qcIFfEgHrchyYqRrxsCeTQgpJZ/GqHiqPcU/Fvw/ARVlQeDX1VyFH+X+0gCR2tca6UJrq96vnW+5o7buCq+erA==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [win32]
'@napi-rs/canvas-win32-x64-msvc@0.1.88':
resolution: {integrity: sha512-ROVqbfS4QyZxYkqmaIBBpbz/BQvAR+05FXM5PAtTYVc0uyY8Y4BHJSMdGAaMf6TdIVRsQsiq+FG/dH9XhvWCFQ==}
engines: {node: '>= 10'}
cpu: [x64]
os: [win32]
'@napi-rs/canvas@0.1.88':
resolution: {integrity: sha512-/p08f93LEbsL5mDZFQ3DBxcPv/I4QG9EDYRRq1WNlCOXVfAHBTHMSVMwxlqG/AtnSfUr9+vgfN7MKiyDo0+Weg==}
engines: {node: '>= 10'}
'@napi-rs/wasm-runtime@1.1.1':
resolution: {integrity: sha512-p64ah1M1ld8xjWv3qbvFwHiFVWrq1yFvV4f7w+mzaqiR4IlSgkqhcRdHwsGgomwzBH51sRY4NEowLxnaBjcW/A==}
@@ -3756,6 +3832,10 @@ packages:
pathe@2.0.3:
resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==}
pdfjs-dist@5.4.530:
resolution: {integrity: sha512-r1hWsSIGGmyYUAHR26zSXkxYWLXLMd6AwqcaFYG9YUZ0GBf5GvcjJSeo512tabM4GYFhxhl5pMCmPr7Q72Rq2Q==}
engines: {node: '>=20.16.0 || >=22.3.0'}
picocolors@1.1.1:
resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
@@ -5671,6 +5751,53 @@ snapshots:
'@mozilla/readability@0.6.0': {}
'@napi-rs/canvas-android-arm64@0.1.88':
optional: true
'@napi-rs/canvas-darwin-arm64@0.1.88':
optional: true
'@napi-rs/canvas-darwin-x64@0.1.88':
optional: true
'@napi-rs/canvas-linux-arm-gnueabihf@0.1.88':
optional: true
'@napi-rs/canvas-linux-arm64-gnu@0.1.88':
optional: true
'@napi-rs/canvas-linux-arm64-musl@0.1.88':
optional: true
'@napi-rs/canvas-linux-riscv64-gnu@0.1.88':
optional: true
'@napi-rs/canvas-linux-x64-gnu@0.1.88':
optional: true
'@napi-rs/canvas-linux-x64-musl@0.1.88':
optional: true
'@napi-rs/canvas-win32-arm64-msvc@0.1.88':
optional: true
'@napi-rs/canvas-win32-x64-msvc@0.1.88':
optional: true
'@napi-rs/canvas@0.1.88':
optionalDependencies:
'@napi-rs/canvas-android-arm64': 0.1.88
'@napi-rs/canvas-darwin-arm64': 0.1.88
'@napi-rs/canvas-darwin-x64': 0.1.88
'@napi-rs/canvas-linux-arm-gnueabihf': 0.1.88
'@napi-rs/canvas-linux-arm64-gnu': 0.1.88
'@napi-rs/canvas-linux-arm64-musl': 0.1.88
'@napi-rs/canvas-linux-riscv64-gnu': 0.1.88
'@napi-rs/canvas-linux-x64-gnu': 0.1.88
'@napi-rs/canvas-linux-x64-musl': 0.1.88
'@napi-rs/canvas-win32-arm64-msvc': 0.1.88
'@napi-rs/canvas-win32-x64-msvc': 0.1.88
'@napi-rs/wasm-runtime@1.1.1':
dependencies:
'@emnapi/core': 1.8.1
@@ -8462,6 +8589,10 @@ snapshots:
pathe@2.0.3: {}
pdfjs-dist@5.4.530:
optionalDependencies:
'@napi-rs/canvas': 0.1.88
picocolors@1.1.1: {}
picomatch@2.3.1: {}
@@ -18,7 +18,7 @@ import {
ensureAuthProfileStore,
saveAuthProfileStore,
} from "./auth-profiles.js";
import { getApiKeyForModel } from "./model-auth.js";
import { getApiKeyForModel, requireApiKey } from "./model-auth.js";
import { normalizeProviderId, parseModelRef } from "./model-selection.js";
import { ensureClawdbotModelsJson } from "./models-config.js";
@@ -178,7 +178,8 @@ describeLive("live anthropic setup-token", () => {
profileId: tokenSource.profileId,
agentDir: tokenSource.agentDir,
});
const tokenError = validateAnthropicSetupToken(apiKeyInfo.apiKey);
const apiKey = requireApiKey(apiKeyInfo, model.provider);
const tokenError = validateAnthropicSetupToken(apiKey);
if (tokenError) {
throw new Error(`Resolved profile is not a setup-token: ${tokenError}`);
}
@@ -195,7 +196,7 @@ describeLive("live anthropic setup-token", () => {
],
},
{
apiKey: apiKeyInfo.apiKey,
apiKey,
maxTokens: 64,
temperature: 0,
},
+2 -1
View File
@@ -1,3 +1,4 @@
import { formatCliCommand } from "../../cli/command-format.js";
import type { ClawdbotConfig } from "../../config/config.js";
import { normalizeProviderId } from "../model-selection.js";
import { listProfilesForProvider } from "./profiles.js";
@@ -37,6 +38,6 @@ export function formatAuthDoctorHint(params: {
}`,
`- auth store oauth profiles: ${storeOauthProfiles || "(none)"}`,
`- suggested profile: ${suggested}`,
'Fix: run "clawdbot doctor --yes"',
`Fix: run "${formatCliCommand("clawdbot doctor --yes")}"`,
].join("\n");
}
+1
View File
@@ -45,6 +45,7 @@ export async function runCliAgent(params: {
timeoutMs: number;
runId: string;
extraSystemPrompt?: string;
streamParams?: import("../commands/agent/types.js").AgentStreamParams;
ownerNumbers?: string[];
cliSessionId?: string;
images?: ImageContent[];
+148 -55
View File
@@ -281,17 +281,19 @@ describe("getApiKeyForModel", () => {
}
});
it("accepts AWS profile auth for amazon-bedrock", async () => {
const previousProfile = process.env.AWS_PROFILE;
const previousAccess = process.env.AWS_ACCESS_KEY_ID;
const previousSecret = process.env.AWS_SECRET_ACCESS_KEY;
const previousBearer = process.env.AWS_BEARER_TOKEN_BEDROCK;
it("prefers Bedrock bearer token over access keys and profile", async () => {
const previous = {
bearer: process.env.AWS_BEARER_TOKEN_BEDROCK,
access: process.env.AWS_ACCESS_KEY_ID,
secret: process.env.AWS_SECRET_ACCESS_KEY,
profile: process.env.AWS_PROFILE,
};
try {
process.env.AWS_PROFILE = "bedrock-test";
delete process.env.AWS_ACCESS_KEY_ID;
delete process.env.AWS_SECRET_ACCESS_KEY;
delete process.env.AWS_BEARER_TOKEN_BEDROCK;
process.env.AWS_BEARER_TOKEN_BEDROCK = "bedrock-token";
process.env.AWS_ACCESS_KEY_ID = "access-key";
process.env.AWS_SECRET_ACCESS_KEY = "secret-key";
process.env.AWS_PROFILE = "profile";
vi.resetModules();
const { resolveApiKeyForProvider } = await import("./model-auth.js");
@@ -299,44 +301,60 @@ describe("getApiKeyForModel", () => {
const resolved = await resolveApiKeyForProvider({
provider: "amazon-bedrock",
store: { version: 1, profiles: {} },
cfg: {
models: {
providers: {
"amazon-bedrock": {
baseUrl: "https://bedrock-runtime.us-east-1.amazonaws.com",
api: "bedrock-converse-stream",
auth: "aws-sdk",
models: [],
},
},
},
} as never,
});
expect(resolved.apiKey).toBe("<authenticated>");
expect(resolved.source).toContain("AWS_PROFILE");
expect(resolved.mode).toBe("aws-sdk");
expect(resolved.apiKey).toBeUndefined();
expect(resolved.source).toContain("AWS_BEARER_TOKEN_BEDROCK");
} finally {
if (previousProfile === undefined) {
delete process.env.AWS_PROFILE;
} else {
process.env.AWS_PROFILE = previousProfile;
}
if (previousAccess === undefined) {
delete process.env.AWS_ACCESS_KEY_ID;
} else {
process.env.AWS_ACCESS_KEY_ID = previousAccess;
}
if (previousSecret === undefined) {
delete process.env.AWS_SECRET_ACCESS_KEY;
} else {
process.env.AWS_SECRET_ACCESS_KEY = previousSecret;
}
if (previousBearer === undefined) {
if (previous.bearer === undefined) {
delete process.env.AWS_BEARER_TOKEN_BEDROCK;
} else {
process.env.AWS_BEARER_TOKEN_BEDROCK = previousBearer;
process.env.AWS_BEARER_TOKEN_BEDROCK = previous.bearer;
}
if (previous.access === undefined) {
delete process.env.AWS_ACCESS_KEY_ID;
} else {
process.env.AWS_ACCESS_KEY_ID = previous.access;
}
if (previous.secret === undefined) {
delete process.env.AWS_SECRET_ACCESS_KEY;
} else {
process.env.AWS_SECRET_ACCESS_KEY = previous.secret;
}
if (previous.profile === undefined) {
delete process.env.AWS_PROFILE;
} else {
process.env.AWS_PROFILE = previous.profile;
}
}
});
it("allows amazon-bedrock without an API key", async () => {
const previousProfile = process.env.AWS_PROFILE;
const previousAccess = process.env.AWS_ACCESS_KEY_ID;
const previousSecret = process.env.AWS_SECRET_ACCESS_KEY;
const previousBearer = process.env.AWS_BEARER_TOKEN_BEDROCK;
it("prefers Bedrock access keys over profile", async () => {
const previous = {
bearer: process.env.AWS_BEARER_TOKEN_BEDROCK,
access: process.env.AWS_ACCESS_KEY_ID,
secret: process.env.AWS_SECRET_ACCESS_KEY,
profile: process.env.AWS_PROFILE,
};
try {
delete process.env.AWS_PROFILE;
delete process.env.AWS_ACCESS_KEY_ID;
delete process.env.AWS_SECRET_ACCESS_KEY;
delete process.env.AWS_BEARER_TOKEN_BEDROCK;
process.env.AWS_ACCESS_KEY_ID = "access-key";
process.env.AWS_SECRET_ACCESS_KEY = "secret-key";
process.env.AWS_PROFILE = "profile";
vi.resetModules();
const { resolveApiKeyForProvider } = await import("./model-auth.js");
@@ -344,29 +362,104 @@ describe("getApiKeyForModel", () => {
const resolved = await resolveApiKeyForProvider({
provider: "amazon-bedrock",
store: { version: 1, profiles: {} },
cfg: {
models: {
providers: {
"amazon-bedrock": {
baseUrl: "https://bedrock-runtime.us-east-1.amazonaws.com",
api: "bedrock-converse-stream",
auth: "aws-sdk",
models: [],
},
},
},
} as never,
});
expect(resolved.apiKey).toBe("<authenticated>");
expect(resolved.source).toBe("aws-sdk");
expect(resolved.mode).toBe("aws-sdk");
expect(resolved.apiKey).toBeUndefined();
expect(resolved.source).toContain("AWS_ACCESS_KEY_ID");
} finally {
if (previousProfile === undefined) {
delete process.env.AWS_PROFILE;
} else {
process.env.AWS_PROFILE = previousProfile;
}
if (previousAccess === undefined) {
delete process.env.AWS_ACCESS_KEY_ID;
} else {
process.env.AWS_ACCESS_KEY_ID = previousAccess;
}
if (previousSecret === undefined) {
delete process.env.AWS_SECRET_ACCESS_KEY;
} else {
process.env.AWS_SECRET_ACCESS_KEY = previousSecret;
}
if (previousBearer === undefined) {
if (previous.bearer === undefined) {
delete process.env.AWS_BEARER_TOKEN_BEDROCK;
} else {
process.env.AWS_BEARER_TOKEN_BEDROCK = previousBearer;
process.env.AWS_BEARER_TOKEN_BEDROCK = previous.bearer;
}
if (previous.access === undefined) {
delete process.env.AWS_ACCESS_KEY_ID;
} else {
process.env.AWS_ACCESS_KEY_ID = previous.access;
}
if (previous.secret === undefined) {
delete process.env.AWS_SECRET_ACCESS_KEY;
} else {
process.env.AWS_SECRET_ACCESS_KEY = previous.secret;
}
if (previous.profile === undefined) {
delete process.env.AWS_PROFILE;
} else {
process.env.AWS_PROFILE = previous.profile;
}
}
});
it("uses Bedrock profile when access keys are missing", async () => {
const previous = {
bearer: process.env.AWS_BEARER_TOKEN_BEDROCK,
access: process.env.AWS_ACCESS_KEY_ID,
secret: process.env.AWS_SECRET_ACCESS_KEY,
profile: process.env.AWS_PROFILE,
};
try {
delete process.env.AWS_BEARER_TOKEN_BEDROCK;
delete process.env.AWS_ACCESS_KEY_ID;
delete process.env.AWS_SECRET_ACCESS_KEY;
process.env.AWS_PROFILE = "profile";
vi.resetModules();
const { resolveApiKeyForProvider } = await import("./model-auth.js");
const resolved = await resolveApiKeyForProvider({
provider: "amazon-bedrock",
store: { version: 1, profiles: {} },
cfg: {
models: {
providers: {
"amazon-bedrock": {
baseUrl: "https://bedrock-runtime.us-east-1.amazonaws.com",
api: "bedrock-converse-stream",
auth: "aws-sdk",
models: [],
},
},
},
} as never,
});
expect(resolved.mode).toBe("aws-sdk");
expect(resolved.apiKey).toBeUndefined();
expect(resolved.source).toContain("AWS_PROFILE");
} finally {
if (previous.bearer === undefined) {
delete process.env.AWS_BEARER_TOKEN_BEDROCK;
} else {
process.env.AWS_BEARER_TOKEN_BEDROCK = previous.bearer;
}
if (previous.access === undefined) {
delete process.env.AWS_ACCESS_KEY_ID;
} else {
process.env.AWS_ACCESS_KEY_ID = previous.access;
}
if (previous.secret === undefined) {
delete process.env.AWS_SECRET_ACCESS_KEY;
} else {
process.env.AWS_SECRET_ACCESS_KEY = previous.secret;
}
if (previous.profile === undefined) {
delete process.env.AWS_PROFILE;
} else {
process.env.AWS_PROFILE = previous.profile;
}
}
});
+140 -33
View File
@@ -2,8 +2,9 @@ import path from "node:path";
import { type Api, getEnvApiKey, type Model } from "@mariozechner/pi-ai";
import type { ClawdbotConfig } from "../config/config.js";
import type { ModelProviderConfig } from "../config/types.js";
import type { ModelProviderAuthMode, ModelProviderConfig } from "../config/types.js";
import { getShellEnvAppliedKeys } from "../infra/shell-env.js";
import { formatCliCommand } from "../cli/command-format.js";
import {
type AuthProfileStore,
ensureAuthProfileStore,
@@ -16,16 +17,115 @@ import { normalizeProviderId } from "./model-selection.js";
export { ensureAuthProfileStore, resolveAuthProfileOrder } from "./auth-profiles.js";
const AWS_BEARER_ENV = "AWS_BEARER_TOKEN_BEDROCK";
const AWS_ACCESS_KEY_ENV = "AWS_ACCESS_KEY_ID";
const AWS_SECRET_KEY_ENV = "AWS_SECRET_ACCESS_KEY";
const AWS_PROFILE_ENV = "AWS_PROFILE";
function resolveProviderConfig(
cfg: ClawdbotConfig | undefined,
provider: string,
): ModelProviderConfig | undefined {
const providers = cfg?.models?.providers ?? {};
const direct = providers[provider] as ModelProviderConfig | undefined;
if (direct) return direct;
const normalized = normalizeProviderId(provider);
if (normalized === provider) {
const matched = Object.entries(providers).find(
([key]) => normalizeProviderId(key) === normalized,
);
return matched?.[1] as ModelProviderConfig | undefined;
}
return (
(providers[normalized] as ModelProviderConfig | undefined) ??
(Object.entries(providers).find(
([key]) => normalizeProviderId(key) === normalized,
)?.[1] as ModelProviderConfig | undefined)
);
}
export function getCustomProviderApiKey(
cfg: ClawdbotConfig | undefined,
provider: string,
): string | undefined {
const providers = cfg?.models?.providers ?? {};
const entry = providers[provider] as ModelProviderConfig | undefined;
const entry = resolveProviderConfig(cfg, provider);
const key = entry?.apiKey?.trim();
return key || undefined;
}
function resolveProviderAuthOverride(
cfg: ClawdbotConfig | undefined,
provider: string,
): ModelProviderAuthMode | undefined {
const entry = resolveProviderConfig(cfg, provider);
const auth = entry?.auth;
if (auth === "api-key" || auth === "aws-sdk" || auth === "oauth" || auth === "token") {
return auth;
}
return undefined;
}
function resolveEnvSourceLabel(params: {
applied: Set<string>;
envVars: string[];
label: string;
}): string {
const shellApplied = params.envVars.some((envVar) => params.applied.has(envVar));
const prefix = shellApplied ? "shell env: " : "env: ";
return `${prefix}${params.label}`;
}
export function resolveAwsSdkEnvVarName(): string | undefined {
if (process.env[AWS_BEARER_ENV]?.trim()) return AWS_BEARER_ENV;
if (process.env[AWS_ACCESS_KEY_ENV]?.trim() && process.env[AWS_SECRET_KEY_ENV]?.trim()) {
return AWS_ACCESS_KEY_ENV;
}
if (process.env[AWS_PROFILE_ENV]?.trim()) return AWS_PROFILE_ENV;
return undefined;
}
function resolveAwsSdkAuthInfo(): { mode: "aws-sdk"; source: string } {
const applied = new Set(getShellEnvAppliedKeys());
if (process.env[AWS_BEARER_ENV]?.trim()) {
return {
mode: "aws-sdk",
source: resolveEnvSourceLabel({
applied,
envVars: [AWS_BEARER_ENV],
label: AWS_BEARER_ENV,
}),
};
}
if (process.env[AWS_ACCESS_KEY_ENV]?.trim() && process.env[AWS_SECRET_KEY_ENV]?.trim()) {
return {
mode: "aws-sdk",
source: resolveEnvSourceLabel({
applied,
envVars: [AWS_ACCESS_KEY_ENV, AWS_SECRET_KEY_ENV],
label: `${AWS_ACCESS_KEY_ENV} + ${AWS_SECRET_KEY_ENV}`,
}),
};
}
if (process.env[AWS_PROFILE_ENV]?.trim()) {
return {
mode: "aws-sdk",
source: resolveEnvSourceLabel({
applied,
envVars: [AWS_PROFILE_ENV],
label: AWS_PROFILE_ENV,
}),
};
}
return { mode: "aws-sdk", source: "aws-sdk default chain" };
}
export type ResolvedProviderAuth = {
apiKey?: string;
profileId?: string;
source: string;
mode: "api-key" | "oauth" | "token" | "aws-sdk";
};
export async function resolveApiKeyForProvider(params: {
provider: string;
cfg?: ClawdbotConfig;
@@ -33,10 +133,9 @@ export async function resolveApiKeyForProvider(params: {
preferredProfile?: string;
store?: AuthProfileStore;
agentDir?: string;
}): Promise<{ apiKey: string; profileId?: string; source: string }> {
}): Promise<ResolvedProviderAuth> {
const { provider, cfg, profileId, preferredProfile } = params;
const store = params.store ?? ensureAuthProfileStore(params.agentDir);
const normalized = normalizeProviderId(provider);
if (profileId) {
const resolved = await resolveApiKeyForProfile({
@@ -48,13 +147,20 @@ export async function resolveApiKeyForProvider(params: {
if (!resolved) {
throw new Error(`No credentials found for profile "${profileId}".`);
}
const mode = store.profiles[profileId]?.type;
return {
apiKey: resolved.apiKey,
profileId,
source: `profile:${profileId}`,
mode: mode === "oauth" ? "oauth" : mode === "token" ? "token" : "api-key",
};
}
const authOverride = resolveProviderAuthOverride(cfg, provider);
if (authOverride === "aws-sdk") {
return resolveAwsSdkAuthInfo();
}
const order = resolveAuthProfileOrder({
cfg,
store,
@@ -70,10 +176,12 @@ export async function resolveApiKeyForProvider(params: {
agentDir: params.agentDir,
});
if (resolved) {
const mode = store.profiles[candidate]?.type;
return {
apiKey: resolved.apiKey,
profileId: candidate,
source: `profile:${candidate}`,
mode: mode === "oauth" ? "oauth" : mode === "token" ? "token" : "api-key",
};
}
} catch {}
@@ -81,16 +189,21 @@ export async function resolveApiKeyForProvider(params: {
const envResolved = resolveEnvApiKey(provider);
if (envResolved) {
return { apiKey: envResolved.apiKey, source: envResolved.source };
return {
apiKey: envResolved.apiKey,
source: envResolved.source,
mode: envResolved.source.includes("OAUTH_TOKEN") ? "oauth" : "api-key",
};
}
const customKey = getCustomProviderApiKey(cfg, provider);
if (customKey) {
return { apiKey: customKey, source: "models.json" };
return { apiKey: customKey, source: "models.json", mode: "api-key" };
}
if (normalized === "amazon-bedrock") {
return { apiKey: "<authenticated>", source: "aws-sdk" };
const normalized = normalizeProviderId(provider);
if (authOverride === undefined && normalized === "amazon-bedrock") {
return resolveAwsSdkAuthInfo();
}
if (provider === "openai") {
@@ -108,13 +221,13 @@ export async function resolveApiKeyForProvider(params: {
[
`No API key found for provider "${provider}".`,
`Auth store: ${authStorePath} (agentDir: ${resolvedAgentDir}).`,
"Configure auth for this agent (clawdbot agents add <id>) or copy auth-profiles.json from the main agentDir.",
`Configure auth for this agent (${formatCliCommand("clawdbot agents add <id>")}) or copy auth-profiles.json from the main agentDir.`,
].join(" "),
);
}
export type EnvApiKeyResult = { apiKey: string; source: string };
export type ModelAuthMode = "api-key" | "oauth" | "token" | "mixed" | "unknown";
export type ModelAuthMode = "api-key" | "oauth" | "token" | "mixed" | "aws-sdk" | "unknown";
export function resolveEnvApiKey(provider: string): EnvApiKeyResult | null {
const normalized = normalizeProviderId(provider);
@@ -125,12 +238,6 @@ export function resolveEnvApiKey(provider: string): EnvApiKeyResult | null {
const source = applied.has(envVar) ? `shell env: ${envVar}` : `env: ${envVar}`;
return { apiKey: value, source };
};
const pickPresent = (envVar: string): EnvApiKeyResult | null => {
const value = process.env[envVar]?.trim();
if (!value) return null;
const source = applied.has(envVar) ? `shell env: ${envVar}` : `env: ${envVar}`;
return { apiKey: "<authenticated>", source };
};
if (normalized === "github-copilot") {
return pick("COPILOT_GITHUB_TOKEN") ?? pick("GH_TOKEN") ?? pick("GITHUB_TOKEN");
@@ -154,21 +261,6 @@ export function resolveEnvApiKey(provider: string): EnvApiKeyResult | null {
return { apiKey: envKey, source: "gcloud adc" };
}
if (normalized === "amazon-bedrock") {
return (
pickPresent("AWS_BEARER_TOKEN_BEDROCK") ??
pickPresent("AWS_PROFILE") ??
(process.env.AWS_ACCESS_KEY_ID?.trim() && process.env.AWS_SECRET_ACCESS_KEY?.trim()
? {
apiKey: "<authenticated>",
source: applied.has("AWS_ACCESS_KEY_ID")
? "shell env: AWS_ACCESS_KEY_ID"
: "env: AWS_ACCESS_KEY_ID",
}
: null)
);
}
if (normalized === "opencode") {
return pick("OPENCODE_API_KEY") ?? pick("OPENCODE_ZEN_API_KEY");
}
@@ -206,6 +298,9 @@ export function resolveModelAuthMode(
const resolved = provider?.trim();
if (!resolved) return undefined;
const authOverride = resolveProviderAuthOverride(cfg, resolved);
if (authOverride === "aws-sdk") return "aws-sdk";
const authStore = store ?? ensureAuthProfileStore();
const profiles = listProfilesForProvider(authStore, resolved);
if (profiles.length > 0) {
@@ -223,6 +318,10 @@ export function resolveModelAuthMode(
if (modes.has("api_key")) return "api-key";
}
if (authOverride === undefined && normalizeProviderId(resolved) === "amazon-bedrock") {
return "aws-sdk";
}
const envKey = resolveEnvApiKey(resolved);
if (envKey?.apiKey) {
return envKey.source.includes("OAUTH_TOKEN") ? "oauth" : "api-key";
@@ -240,7 +339,7 @@ export async function getApiKeyForModel(params: {
preferredProfile?: string;
store?: AuthProfileStore;
agentDir?: string;
}): Promise<{ apiKey: string; profileId?: string; source: string }> {
}): Promise<ResolvedProviderAuth> {
return resolveApiKeyForProvider({
provider: params.model.provider,
cfg: params.cfg,
@@ -250,3 +349,11 @@ export async function getApiKeyForModel(params: {
agentDir: params.agentDir,
});
}
export function requireApiKey(auth: ResolvedProviderAuth, provider: string): string {
const key = auth.apiKey?.trim();
if (key) return key;
throw new Error(
`No API key resolved for provider "${provider}" (auth mode: ${auth.mode}).`,
);
}
+20 -11
View File
@@ -4,7 +4,7 @@ import {
resolveCopilotApiToken,
} from "../providers/github-copilot-token.js";
import { ensureAuthProfileStore, listProfilesForProvider } from "./auth-profiles.js";
import { resolveEnvApiKey } from "./model-auth.js";
import { resolveAwsSdkEnvVarName, resolveEnvApiKey } from "./model-auth.js";
import {
buildSyntheticModelDefinition,
SYNTHETIC_BASE_URL,
@@ -74,6 +74,10 @@ function resolveEnvApiKeyVarName(provider: string): string | undefined {
return match ? match[1] : undefined;
}
function resolveAwsSdkApiKeyVarName(): string {
return resolveAwsSdkEnvVarName() ?? "AWS_PROFILE";
}
function resolveApiKeyFromProfiles(params: {
provider: string;
store: ReturnType<typeof ensureAuthProfileStore>;
@@ -138,18 +142,23 @@ export function normalizeProviders(params: {
const hasModels =
Array.isArray(normalizedProvider.models) && normalizedProvider.models.length > 0;
if (hasModels && !normalizedProvider.apiKey?.trim()) {
const fromEnv = resolveEnvApiKeyVarName(normalizedKey);
const fromProfiles = resolveApiKeyFromProfiles({
provider: normalizedKey,
store: authStore,
});
const apiKey =
fromEnv ??
fromProfiles ??
(normalizedKey === "amazon-bedrock" ? "AWS_PROFILE" : undefined);
if (apiKey?.trim()) {
const authMode =
normalizedProvider.auth ?? (normalizedKey === "amazon-bedrock" ? "aws-sdk" : undefined);
if (authMode === "aws-sdk") {
const apiKey = resolveAwsSdkApiKeyVarName();
mutated = true;
normalizedProvider = { ...normalizedProvider, apiKey };
} else {
const fromEnv = resolveEnvApiKeyVarName(normalizedKey);
const fromProfiles = resolveApiKeyFromProfiles({
provider: normalizedKey,
store: authStore,
});
const apiKey = fromEnv ?? fromProfiles;
if (apiKey?.trim()) {
mutated = true;
normalizedProvider = { ...normalizedProvider, apiKey };
}
}
}
+2 -2
View File
@@ -11,7 +11,7 @@ import {
isAnthropicRateLimitError,
} from "./live-auth-keys.js";
import { isModernModelRef } from "./live-model-filter.js";
import { getApiKeyForModel } from "./model-auth.js";
import { getApiKeyForModel, requireApiKey } from "./model-auth.js";
import { ensureClawdbotModelsJson } from "./models-config.js";
import { isRateLimitErrorMessage } from "./pi-embedded-helpers/errors.js";
@@ -226,7 +226,7 @@ describeLive("live models (profile keys)", () => {
const apiKey =
model.provider === "anthropic" && anthropicKeys.length > 0
? anthropicKeys[attempt]
: apiKeyInfo.apiKey;
: requireApiKey(apiKeyInfo, model.provider);
try {
// Special regression: OpenAI requires replayed `reasoning` items for tool-only turns.
if (
Loaded 100 of 249 files, more files were not shown because too many files have changed in this diff. Show more