Merge pull request #1229 from RyanLisse/main
feat(gateway): add OpenResponses /v1/responses endpoint
This commit is contained in:
commit
11b9b6dba5
BIN
.agent/.DS_Store
vendored
Normal file
BIN
.agent/.DS_Store
vendored
Normal file
Binary file not shown.
366
.agent/workflows/update_clawdbot.md
Normal file
366
.agent/workflows/update_clawdbot.md
Normal 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."
|
||||||
|
```
|
||||||
3
.gitmodules
vendored
Normal file
3
.gitmodules
vendored
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
[submodule "Peekaboo"]
|
||||||
|
path = Peekaboo
|
||||||
|
url = https://github.com/steipete/Peekaboo.git
|
||||||
1
.serena/.gitignore
vendored
Normal file
1
.serena/.gitignore
vendored
Normal file
@ -0,0 +1 @@
|
|||||||
|
/cache
|
||||||
BIN
.serena/cache/typescript/document_symbols.pkl
vendored
Normal file
BIN
.serena/cache/typescript/document_symbols.pkl
vendored
Normal file
Binary file not shown.
BIN
.serena/cache/typescript/raw_document_symbols.pkl
vendored
Normal file
BIN
.serena/cache/typescript/raw_document_symbols.pkl
vendored
Normal file
Binary file not shown.
87
.serena/project.yml
Normal file
87
.serena/project.yml
Normal 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: []
|
||||||
@ -12,6 +12,7 @@ Docs: https://docs.clawd.bot
|
|||||||
### Changes
|
### Changes
|
||||||
- Android: remove legacy bridge transport code now that nodes use the gateway protocol.
|
- 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.
|
- 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.
|
- Docs: surface Amazon Bedrock in provider lists and clarify Bedrock auth env vars. (#1289) — thanks @steipete.
|
||||||
|
|
||||||
### Fixes
|
### Fixes
|
||||||
@ -35,6 +36,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.
|
- **BREAKING:** Reject invalid/unknown config entries and refuse to start the gateway for safety; run `clawdbot doctor --fix` to repair.
|
||||||
|
|
||||||
### Changes
|
### 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.
|
- Usage: add `/usage cost` summaries and macOS menu cost submenu with daily charting.
|
||||||
- Agents: clarify node_modules read-only guidance in agent instructions.
|
- Agents: clarify node_modules read-only guidance in agent instructions.
|
||||||
- TUI: add syntax highlighting for code blocks. (#1200) — thanks @vignesh07.
|
- TUI: add syntax highlighting for code blocks. (#1200) — thanks @vignesh07.
|
||||||
|
|||||||
1
Peekaboo
Submodule
1
Peekaboo
Submodule
@ -0,0 +1 @@
|
|||||||
|
Subproject commit 5c195f5e46ebfcc953af74fdd05fbc962d05a50c
|
||||||
@ -160,14 +160,14 @@ actor CameraController {
|
|||||||
defer { session.stopRunning() }
|
defer { session.stopRunning() }
|
||||||
await Self.warmUpCaptureSession()
|
await Self.warmUpCaptureSession()
|
||||||
|
|
||||||
let movURL = FileManager.default.temporaryDirectory
|
let movURL = FileManager().temporaryDirectory
|
||||||
.appendingPathComponent("clawdbot-camera-\(UUID().uuidString).mov")
|
.appendingPathComponent("clawdbot-camera-\(UUID().uuidString).mov")
|
||||||
let mp4URL = FileManager.default.temporaryDirectory
|
let mp4URL = FileManager().temporaryDirectory
|
||||||
.appendingPathComponent("clawdbot-camera-\(UUID().uuidString).mp4")
|
.appendingPathComponent("clawdbot-camera-\(UUID().uuidString).mp4")
|
||||||
|
|
||||||
defer {
|
defer {
|
||||||
try? FileManager.default.removeItem(at: movURL)
|
try? FileManager().removeItem(at: movURL)
|
||||||
try? FileManager.default.removeItem(at: mp4URL)
|
try? FileManager().removeItem(at: mp4URL)
|
||||||
}
|
}
|
||||||
|
|
||||||
var delegate: MovieFileDelegate?
|
var delegate: MovieFileDelegate?
|
||||||
|
|||||||
@ -837,7 +837,7 @@ final class NodeAppModel {
|
|||||||
fps: params.fps,
|
fps: params.fps,
|
||||||
includeAudio: params.includeAudio,
|
includeAudio: params.includeAudio,
|
||||||
outPath: nil)
|
outPath: nil)
|
||||||
defer { try? FileManager.default.removeItem(atPath: path) }
|
defer { try? FileManager().removeItem(atPath: path) }
|
||||||
let data = try Data(contentsOf: URL(fileURLWithPath: path))
|
let data = try Data(contentsOf: URL(fileURLWithPath: path))
|
||||||
struct Payload: Codable {
|
struct Payload: Codable {
|
||||||
var format: String
|
var format: String
|
||||||
|
|||||||
@ -91,7 +91,7 @@ final class ScreenRecordService: @unchecked Sendable {
|
|||||||
let includeAudio = includeAudio ?? true
|
let includeAudio = includeAudio ?? true
|
||||||
|
|
||||||
let outURL = self.makeOutputURL(outPath: outPath)
|
let outURL = self.makeOutputURL(outPath: outPath)
|
||||||
try? FileManager.default.removeItem(at: outURL)
|
try? FileManager().removeItem(at: outURL)
|
||||||
|
|
||||||
return RecordConfig(
|
return RecordConfig(
|
||||||
durationMs: durationMs,
|
durationMs: durationMs,
|
||||||
@ -104,7 +104,7 @@ final class ScreenRecordService: @unchecked Sendable {
|
|||||||
if let outPath, !outPath.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
if let outPath, !outPath.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||||||
return URL(fileURLWithPath: outPath)
|
return URL(fileURLWithPath: outPath)
|
||||||
}
|
}
|
||||||
return FileManager.default.temporaryDirectory
|
return FileManager().temporaryDirectory
|
||||||
.appendingPathComponent("clawdbot-screen-record-\(UUID().uuidString).mp4")
|
.appendingPathComponent("clawdbot-screen-record-\(UUID().uuidString).mp4")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -23,7 +23,7 @@ enum AgentWorkspace {
|
|||||||
}
|
}
|
||||||
|
|
||||||
static func displayPath(for url: URL) -> String {
|
static func displayPath(for url: URL) -> String {
|
||||||
let home = FileManager.default.homeDirectoryForCurrentUser.path
|
let home = FileManager().homeDirectoryForCurrentUser.path
|
||||||
let path = url.path
|
let path = url.path
|
||||||
if path == home { return "~" }
|
if path == home { return "~" }
|
||||||
if path.hasPrefix(home + "/") {
|
if path.hasPrefix(home + "/") {
|
||||||
@ -44,12 +44,12 @@ enum AgentWorkspace {
|
|||||||
}
|
}
|
||||||
|
|
||||||
static func workspaceEntries(workspaceURL: URL) throws -> [String] {
|
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) }
|
return contents.filter { !self.ignoredEntries.contains($0) }
|
||||||
}
|
}
|
||||||
|
|
||||||
static func isWorkspaceEmpty(workspaceURL: URL) -> Bool {
|
static func isWorkspaceEmpty(workspaceURL: URL) -> Bool {
|
||||||
let fm = FileManager.default
|
let fm = FileManager()
|
||||||
var isDir: ObjCBool = false
|
var isDir: ObjCBool = false
|
||||||
if !fm.fileExists(atPath: workspaceURL.path, isDirectory: &isDir) {
|
if !fm.fileExists(atPath: workspaceURL.path, isDirectory: &isDir) {
|
||||||
return true
|
return true
|
||||||
@ -66,7 +66,7 @@ enum AgentWorkspace {
|
|||||||
}
|
}
|
||||||
|
|
||||||
static func bootstrapSafety(for workspaceURL: URL) -> BootstrapSafety {
|
static func bootstrapSafety(for workspaceURL: URL) -> BootstrapSafety {
|
||||||
let fm = FileManager.default
|
let fm = FileManager()
|
||||||
var isDir: ObjCBool = false
|
var isDir: ObjCBool = false
|
||||||
if !fm.fileExists(atPath: workspaceURL.path, isDirectory: &isDir) {
|
if !fm.fileExists(atPath: workspaceURL.path, isDirectory: &isDir) {
|
||||||
return .safe
|
return .safe
|
||||||
@ -90,29 +90,29 @@ enum AgentWorkspace {
|
|||||||
|
|
||||||
static func bootstrap(workspaceURL: URL) throws -> URL {
|
static func bootstrap(workspaceURL: URL) throws -> URL {
|
||||||
let shouldSeedBootstrap = self.isWorkspaceEmpty(workspaceURL: workspaceURL)
|
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)
|
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)
|
try self.defaultTemplate().write(to: agentsURL, atomically: true, encoding: .utf8)
|
||||||
self.logger.info("Created AGENTS.md at \(agentsURL.path, privacy: .public)")
|
self.logger.info("Created AGENTS.md at \(agentsURL.path, privacy: .public)")
|
||||||
}
|
}
|
||||||
let soulURL = workspaceURL.appendingPathComponent(self.soulFilename)
|
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)
|
try self.defaultSoulTemplate().write(to: soulURL, atomically: true, encoding: .utf8)
|
||||||
self.logger.info("Created SOUL.md at \(soulURL.path, privacy: .public)")
|
self.logger.info("Created SOUL.md at \(soulURL.path, privacy: .public)")
|
||||||
}
|
}
|
||||||
let identityURL = workspaceURL.appendingPathComponent(self.identityFilename)
|
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)
|
try self.defaultIdentityTemplate().write(to: identityURL, atomically: true, encoding: .utf8)
|
||||||
self.logger.info("Created IDENTITY.md at \(identityURL.path, privacy: .public)")
|
self.logger.info("Created IDENTITY.md at \(identityURL.path, privacy: .public)")
|
||||||
}
|
}
|
||||||
let userURL = workspaceURL.appendingPathComponent(self.userFilename)
|
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)
|
try self.defaultUserTemplate().write(to: userURL, atomically: true, encoding: .utf8)
|
||||||
self.logger.info("Created USER.md at \(userURL.path, privacy: .public)")
|
self.logger.info("Created USER.md at \(userURL.path, privacy: .public)")
|
||||||
}
|
}
|
||||||
let bootstrapURL = workspaceURL.appendingPathComponent(self.bootstrapFilename)
|
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)
|
try self.defaultBootstrapTemplate().write(to: bootstrapURL, atomically: true, encoding: .utf8)
|
||||||
self.logger.info("Created BOOTSTRAP.md at \(bootstrapURL.path, privacy: .public)")
|
self.logger.info("Created BOOTSTRAP.md at \(bootstrapURL.path, privacy: .public)")
|
||||||
}
|
}
|
||||||
@ -120,7 +120,7 @@ enum AgentWorkspace {
|
|||||||
}
|
}
|
||||||
|
|
||||||
static func needsBootstrap(workspaceURL: URL) -> Bool {
|
static func needsBootstrap(workspaceURL: URL) -> Bool {
|
||||||
let fm = FileManager.default
|
let fm = FileManager()
|
||||||
var isDir: ObjCBool = false
|
var isDir: ObjCBool = false
|
||||||
if !fm.fileExists(atPath: workspaceURL.path, isDirectory: &isDir) {
|
if !fm.fileExists(atPath: workspaceURL.path, isDirectory: &isDir) {
|
||||||
return true
|
return true
|
||||||
@ -305,7 +305,7 @@ enum AgentWorkspace {
|
|||||||
if let dev = self.devTemplateURL(named: named) {
|
if let dev = self.devTemplateURL(named: named) {
|
||||||
urls.append(dev)
|
urls.append(dev)
|
||||||
}
|
}
|
||||||
let cwd = URL(fileURLWithPath: FileManager.default.currentDirectoryPath)
|
let cwd = URL(fileURLWithPath: FileManager().currentDirectoryPath)
|
||||||
urls.append(cwd.appendingPathComponent("docs")
|
urls.append(cwd.appendingPathComponent("docs")
|
||||||
.appendingPathComponent(self.templateDirname)
|
.appendingPathComponent(self.templateDirname)
|
||||||
.appendingPathComponent(named))
|
.appendingPathComponent(named))
|
||||||
|
|||||||
@ -45,7 +45,7 @@ struct AnthropicAuthControls: View {
|
|||||||
NSWorkspace.shared.activateFileViewerSelecting([ClawdbotOAuthStore.oauthURL()])
|
NSWorkspace.shared.activateFileViewerSelecting([ClawdbotOAuthStore.oauthURL()])
|
||||||
}
|
}
|
||||||
.buttonStyle(.bordered)
|
.buttonStyle(.bordered)
|
||||||
.disabled(!FileManager.default.fileExists(atPath: ClawdbotOAuthStore.oauthURL().path))
|
.disabled(!FileManager().fileExists(atPath: ClawdbotOAuthStore.oauthURL().path))
|
||||||
|
|
||||||
Button("Refresh") {
|
Button("Refresh") {
|
||||||
self.refresh()
|
self.refresh()
|
||||||
|
|||||||
@ -234,7 +234,7 @@ enum ClawdbotOAuthStore {
|
|||||||
return URL(fileURLWithPath: expanded, isDirectory: true)
|
return URL(fileURLWithPath: expanded, isDirectory: true)
|
||||||
}
|
}
|
||||||
|
|
||||||
return FileManager.default.homeDirectoryForCurrentUser
|
return FileManager().homeDirectoryForCurrentUser
|
||||||
.appendingPathComponent(".clawdbot", isDirectory: true)
|
.appendingPathComponent(".clawdbot", isDirectory: true)
|
||||||
.appendingPathComponent("credentials", isDirectory: true)
|
.appendingPathComponent("credentials", isDirectory: true)
|
||||||
}
|
}
|
||||||
@ -253,7 +253,7 @@ enum ClawdbotOAuthStore {
|
|||||||
urls.append(URL(fileURLWithPath: expanded, isDirectory: true).appendingPathComponent(self.oauthFilename))
|
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(".pi/agent/\(self.oauthFilename)"))
|
||||||
urls.append(home.appendingPathComponent(".claude/\(self.oauthFilename)"))
|
urls.append(home.appendingPathComponent(".claude/\(self.oauthFilename)"))
|
||||||
urls.append(home.appendingPathComponent(".config/claude/\(self.oauthFilename)"))
|
urls.append(home.appendingPathComponent(".config/claude/\(self.oauthFilename)"))
|
||||||
@ -270,10 +270,10 @@ enum ClawdbotOAuthStore {
|
|||||||
|
|
||||||
static func importLegacyAnthropicOAuthIfNeeded() -> URL? {
|
static func importLegacyAnthropicOAuthIfNeeded() -> URL? {
|
||||||
let dest = self.oauthURL()
|
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() {
|
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 self.anthropicOAuthStatus(at: url).isConnected else { continue }
|
||||||
guard let storage = self.loadStorage(at: url) else { continue }
|
guard let storage = self.loadStorage(at: url) else { continue }
|
||||||
do {
|
do {
|
||||||
@ -296,7 +296,7 @@ enum ClawdbotOAuthStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
static func anthropicOAuthStatus(at url: URL) -> AnthropicOAuthStatus {
|
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 data = try? Data(contentsOf: url) else { return .unreadableFile }
|
||||||
guard let json = try? JSONSerialization.jsonObject(with: data, options: []) else { return .invalidJSON }
|
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 {
|
private static func saveStorage(_ storage: [String: Any]) throws {
|
||||||
let dir = self.oauthDir()
|
let dir = self.oauthDir()
|
||||||
try FileManager.default.createDirectory(
|
try FileManager().createDirectory(
|
||||||
at: dir,
|
at: dir,
|
||||||
withIntermediateDirectories: true,
|
withIntermediateDirectories: true,
|
||||||
attributes: [.posixPermissions: 0o700])
|
attributes: [.posixPermissions: 0o700])
|
||||||
@ -370,7 +370,7 @@ enum ClawdbotOAuthStore {
|
|||||||
withJSONObject: storage,
|
withJSONObject: storage,
|
||||||
options: [.prettyPrinted, .sortedKeys])
|
options: [.prettyPrinted, .sortedKeys])
|
||||||
try data.write(to: url, options: [.atomic])
|
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 {
|
private static func installPrefix() -> String {
|
||||||
FileManager.default.homeDirectoryForCurrentUser
|
FileManager().homeDirectoryForCurrentUser
|
||||||
.appendingPathComponent(".clawdbot")
|
.appendingPathComponent(".clawdbot")
|
||||||
.path
|
.path
|
||||||
}
|
}
|
||||||
|
|||||||
@ -167,20 +167,20 @@ actor CameraCaptureService {
|
|||||||
defer { session.stopRunning() }
|
defer { session.stopRunning() }
|
||||||
await Self.warmUpCaptureSession()
|
await Self.warmUpCaptureSession()
|
||||||
|
|
||||||
let tmpMovURL = FileManager.default.temporaryDirectory
|
let tmpMovURL = FileManager().temporaryDirectory
|
||||||
.appendingPathComponent("clawdbot-camera-\(UUID().uuidString).mov")
|
.appendingPathComponent("clawdbot-camera-\(UUID().uuidString).mov")
|
||||||
defer { try? FileManager.default.removeItem(at: tmpMovURL) }
|
defer { try? FileManager().removeItem(at: tmpMovURL) }
|
||||||
|
|
||||||
let outputURL: URL = {
|
let outputURL: URL = {
|
||||||
if let outPath, !outPath.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
if let outPath, !outPath.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||||||
return URL(fileURLWithPath: outPath)
|
return URL(fileURLWithPath: outPath)
|
||||||
}
|
}
|
||||||
return FileManager.default.temporaryDirectory
|
return FileManager().temporaryDirectory
|
||||||
.appendingPathComponent("clawdbot-camera-\(UUID().uuidString).mp4")
|
.appendingPathComponent("clawdbot-camera-\(UUID().uuidString).mp4")
|
||||||
}()
|
}()
|
||||||
|
|
||||||
// Ensure we don't fail exporting due to an existing file.
|
// 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
|
let logger = self.logger
|
||||||
var delegate: MovieFileDelegate?
|
var delegate: MovieFileDelegate?
|
||||||
|
|||||||
@ -25,7 +25,7 @@ final class CanvasManager {
|
|||||||
var defaultAnchorProvider: (() -> NSRect?)?
|
var defaultAnchorProvider: (() -> NSRect?)?
|
||||||
|
|
||||||
private nonisolated static let canvasRoot: URL = {
|
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)
|
return base.appendingPathComponent("Clawdbot/canvas", isDirectory: true)
|
||||||
}()
|
}()
|
||||||
|
|
||||||
@ -83,7 +83,7 @@ final class CanvasManager {
|
|||||||
self.panelSessionKey = nil
|
self.panelSessionKey = nil
|
||||||
|
|
||||||
Self.logger.debug("showDetailed ensure canvas root dir")
|
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")
|
Self.logger.debug("showDetailed init CanvasWindowController")
|
||||||
let controller = try CanvasWindowController(
|
let controller = try CanvasWindowController(
|
||||||
sessionKey: session,
|
sessionKey: session,
|
||||||
@ -258,7 +258,7 @@ final class CanvasManager {
|
|||||||
// (Avoid treating Canvas routes like "/" as filesystem paths.)
|
// (Avoid treating Canvas routes like "/" as filesystem paths.)
|
||||||
if trimmed.hasPrefix("/") {
|
if trimmed.hasPrefix("/") {
|
||||||
var isDir: ObjCBool = false
|
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)
|
return URL(fileURLWithPath: trimmed)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -293,7 +293,7 @@ final class CanvasManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private static func localStatus(sessionDir: URL, target: String) -> CanvasShowStatus {
|
private static func localStatus(sessionDir: URL, target: String) -> CanvasShowStatus {
|
||||||
let fm = FileManager.default
|
let fm = FileManager()
|
||||||
let trimmed = target.trimmingCharacters(in: .whitespacesAndNewlines)
|
let trimmed = target.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
let withoutQuery = trimmed.split(separator: "?", maxSplits: 1, omittingEmptySubsequences: false).first
|
let withoutQuery = trimmed.split(separator: "?", maxSplits: 1, omittingEmptySubsequences: false).first
|
||||||
.map(String.init) ?? trimmed
|
.map(String.init) ?? trimmed
|
||||||
@ -331,7 +331,7 @@ final class CanvasManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private static func indexExists(in dir: URL) -> Bool {
|
private static func indexExists(in dir: URL) -> Bool {
|
||||||
let fm = FileManager.default
|
let fm = FileManager()
|
||||||
let a = dir.appendingPathComponent("index.html", isDirectory: false)
|
let a = dir.appendingPathComponent("index.html", isDirectory: false)
|
||||||
if fm.fileExists(atPath: a.path) { return true }
|
if fm.fileExists(atPath: a.path) { return true }
|
||||||
let b = dir.appendingPathComponent("index.htm", isDirectory: false)
|
let b = dir.appendingPathComponent("index.htm", isDirectory: false)
|
||||||
|
|||||||
@ -69,8 +69,8 @@ final class CanvasSchemeHandler: NSObject, WKURLSchemeHandler {
|
|||||||
if path.isEmpty {
|
if path.isEmpty {
|
||||||
let indexA = sessionRoot.appendingPathComponent("index.html", isDirectory: false)
|
let indexA = sessionRoot.appendingPathComponent("index.html", isDirectory: false)
|
||||||
let indexB = sessionRoot.appendingPathComponent("index.htm", isDirectory: false)
|
let indexB = sessionRoot.appendingPathComponent("index.htm", isDirectory: false)
|
||||||
if !FileManager.default.fileExists(atPath: indexA.path),
|
if !FileManager().fileExists(atPath: indexA.path),
|
||||||
!FileManager.default.fileExists(atPath: indexB.path)
|
!FileManager().fileExists(atPath: indexB.path)
|
||||||
{
|
{
|
||||||
return self.scaffoldPage(sessionRoot: sessionRoot)
|
return self.scaffoldPage(sessionRoot: sessionRoot)
|
||||||
}
|
}
|
||||||
@ -106,7 +106,7 @@ final class CanvasSchemeHandler: NSObject, WKURLSchemeHandler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func resolveFileURL(sessionRoot: URL, requestPath: String) -> URL? {
|
private func resolveFileURL(sessionRoot: URL, requestPath: String) -> URL? {
|
||||||
let fm = FileManager.default
|
let fm = FileManager()
|
||||||
var candidate = sessionRoot.appendingPathComponent(requestPath, isDirectory: false)
|
var candidate = sessionRoot.appendingPathComponent(requestPath, isDirectory: false)
|
||||||
|
|
||||||
var isDir: ObjCBool = false
|
var isDir: ObjCBool = false
|
||||||
@ -137,7 +137,7 @@ final class CanvasSchemeHandler: NSObject, WKURLSchemeHandler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func resolveIndex(in dir: URL) -> URL? {
|
private func resolveIndex(in dir: URL) -> URL? {
|
||||||
let fm = FileManager.default
|
let fm = FileManager()
|
||||||
let a = dir.appendingPathComponent("index.html", isDirectory: false)
|
let a = dir.appendingPathComponent("index.html", isDirectory: false)
|
||||||
if fm.fileExists(atPath: a.path) { return a }
|
if fm.fileExists(atPath: a.path) { return a }
|
||||||
let b = dir.appendingPathComponent("index.htm", isDirectory: false)
|
let b = dir.appendingPathComponent("index.htm", isDirectory: false)
|
||||||
|
|||||||
@ -32,7 +32,7 @@ final class CanvasWindowController: NSWindowController, WKNavigationDelegate, NS
|
|||||||
let safeSessionKey = CanvasWindowController.sanitizeSessionKey(sessionKey)
|
let safeSessionKey = CanvasWindowController.sanitizeSessionKey(sessionKey)
|
||||||
canvasWindowLogger.debug("CanvasWindowController init sanitized session=\(safeSessionKey, privacy: .public)")
|
canvasWindowLogger.debug("CanvasWindowController init sanitized session=\(safeSessionKey, privacy: .public)")
|
||||||
self.sessionDir = root.appendingPathComponent(safeSessionKey, isDirectory: true)
|
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")
|
canvasWindowLogger.debug("CanvasWindowController init session dir ready")
|
||||||
|
|
||||||
self.schemeHandler = CanvasSchemeHandler(root: root)
|
self.schemeHandler = CanvasSchemeHandler(root: root)
|
||||||
@ -143,8 +143,8 @@ final class CanvasWindowController: NSWindowController, WKNavigationDelegate, NS
|
|||||||
if path == "/" || path.isEmpty {
|
if path == "/" || path.isEmpty {
|
||||||
let indexA = sessionDir.appendingPathComponent("index.html", isDirectory: false)
|
let indexA = sessionDir.appendingPathComponent("index.html", isDirectory: false)
|
||||||
let indexB = sessionDir.appendingPathComponent("index.htm", isDirectory: false)
|
let indexB = sessionDir.appendingPathComponent("index.htm", isDirectory: false)
|
||||||
if !FileManager.default.fileExists(atPath: indexA.path),
|
if !FileManager().fileExists(atPath: indexA.path),
|
||||||
!FileManager.default.fileExists(atPath: indexB.path)
|
!FileManager().fileExists(atPath: indexB.path)
|
||||||
{
|
{
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@ -233,7 +233,7 @@ final class CanvasWindowController: NSWindowController, WKNavigationDelegate, NS
|
|||||||
// (Avoid treating Canvas routes like "/" as filesystem paths.)
|
// (Avoid treating Canvas routes like "/" as filesystem paths.)
|
||||||
if trimmed.hasPrefix("/") {
|
if trimmed.hasPrefix("/") {
|
||||||
var isDir: ObjCBool = false
|
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)
|
let url = URL(fileURLWithPath: trimmed)
|
||||||
canvasWindowLogger.debug("canvas load file \(url.absoluteString, privacy: .public)")
|
canvasWindowLogger.debug("canvas load file \(url.absoluteString, privacy: .public)")
|
||||||
self.loadFile(url)
|
self.loadFile(url)
|
||||||
|
|||||||
@ -18,7 +18,7 @@ enum ClawdbotConfigFile {
|
|||||||
|
|
||||||
static func loadDict() -> [String: Any] {
|
static func loadDict() -> [String: Any] {
|
||||||
let url = self.url()
|
let url = self.url()
|
||||||
guard FileManager.default.fileExists(atPath: url.path) else { return [:] }
|
guard FileManager().fileExists(atPath: url.path) else { return [:] }
|
||||||
do {
|
do {
|
||||||
let data = try Data(contentsOf: url)
|
let data = try Data(contentsOf: url)
|
||||||
guard let root = self.parseConfigData(data) else {
|
guard let root = self.parseConfigData(data) else {
|
||||||
@ -38,7 +38,7 @@ enum ClawdbotConfigFile {
|
|||||||
do {
|
do {
|
||||||
let data = try JSONSerialization.data(withJSONObject: dict, options: [.prettyPrinted, .sortedKeys])
|
let data = try JSONSerialization.data(withJSONObject: dict, options: [.prettyPrinted, .sortedKeys])
|
||||||
let url = self.url()
|
let url = self.url()
|
||||||
try FileManager.default.createDirectory(
|
try FileManager().createDirectory(
|
||||||
at: url.deletingLastPathComponent(),
|
at: url.deletingLastPathComponent(),
|
||||||
withIntermediateDirectories: true)
|
withIntermediateDirectories: true)
|
||||||
try data.write(to: url, options: [.atomic])
|
try data.write(to: url, options: [.atomic])
|
||||||
|
|||||||
@ -21,7 +21,7 @@ enum ClawdbotPaths {
|
|||||||
if let override = ClawdbotEnv.path(self.stateDirEnv) {
|
if let override = ClawdbotEnv.path(self.stateDirEnv) {
|
||||||
return URL(fileURLWithPath: override, isDirectory: true)
|
return URL(fileURLWithPath: override, isDirectory: true)
|
||||||
}
|
}
|
||||||
return FileManager.default.homeDirectoryForCurrentUser
|
return FileManager().homeDirectoryForCurrentUser
|
||||||
.appendingPathComponent(".clawdbot", isDirectory: true)
|
.appendingPathComponent(".clawdbot", isDirectory: true)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -6,9 +6,9 @@ enum CommandResolver {
|
|||||||
|
|
||||||
static func gatewayEntrypoint(in root: URL) -> String? {
|
static func gatewayEntrypoint(in root: URL) -> String? {
|
||||||
let distEntry = root.appendingPathComponent("dist/index.js").path
|
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
|
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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -47,16 +47,16 @@ enum CommandResolver {
|
|||||||
static func projectRoot() -> URL {
|
static func projectRoot() -> URL {
|
||||||
if let stored = UserDefaults.standard.string(forKey: self.projectRootDefaultsKey),
|
if let stored = UserDefaults.standard.string(forKey: self.projectRootDefaultsKey),
|
||||||
let url = self.expandPath(stored),
|
let url = self.expandPath(stored),
|
||||||
FileManager.default.fileExists(atPath: url.path)
|
FileManager().fileExists(atPath: url.path)
|
||||||
{
|
{
|
||||||
return url
|
return url
|
||||||
}
|
}
|
||||||
let fallback = FileManager.default.homeDirectoryForCurrentUser
|
let fallback = FileManager().homeDirectoryForCurrentUser
|
||||||
.appendingPathComponent("Projects/clawdbot")
|
.appendingPathComponent("Projects/clawdbot")
|
||||||
if FileManager.default.fileExists(atPath: fallback.path) {
|
if FileManager().fileExists(atPath: fallback.path) {
|
||||||
return fallback
|
return fallback
|
||||||
}
|
}
|
||||||
return FileManager.default.homeDirectoryForCurrentUser
|
return FileManager().homeDirectoryForCurrentUser
|
||||||
}
|
}
|
||||||
|
|
||||||
static func setProjectRoot(_ path: String) {
|
static func setProjectRoot(_ path: String) {
|
||||||
@ -70,7 +70,7 @@ enum CommandResolver {
|
|||||||
static func preferredPaths() -> [String] {
|
static func preferredPaths() -> [String] {
|
||||||
let current = ProcessInfo.processInfo.environment["PATH"]?
|
let current = ProcessInfo.processInfo.environment["PATH"]?
|
||||||
.split(separator: ":").map(String.init) ?? []
|
.split(separator: ":").map(String.init) ?? []
|
||||||
let home = FileManager.default.homeDirectoryForCurrentUser
|
let home = FileManager().homeDirectoryForCurrentUser
|
||||||
let projectRoot = self.projectRoot()
|
let projectRoot = self.projectRoot()
|
||||||
return self.preferredPaths(home: home, current: current, projectRoot: projectRoot)
|
return self.preferredPaths(home: home, current: current, projectRoot: projectRoot)
|
||||||
}
|
}
|
||||||
@ -99,10 +99,10 @@ enum CommandResolver {
|
|||||||
let bin = base.appendingPathComponent("bin")
|
let bin = base.appendingPathComponent("bin")
|
||||||
let nodeBin = base.appendingPathComponent("tools/node/bin")
|
let nodeBin = base.appendingPathComponent("tools/node/bin")
|
||||||
var paths: [String] = []
|
var paths: [String] = []
|
||||||
if FileManager.default.fileExists(atPath: bin.path) {
|
if FileManager().fileExists(atPath: bin.path) {
|
||||||
paths.append(bin.path)
|
paths.append(bin.path)
|
||||||
}
|
}
|
||||||
if FileManager.default.fileExists(atPath: nodeBin.path) {
|
if FileManager().fileExists(atPath: nodeBin.path) {
|
||||||
paths.append(nodeBin.path)
|
paths.append(nodeBin.path)
|
||||||
}
|
}
|
||||||
return paths
|
return paths
|
||||||
@ -113,13 +113,13 @@ enum CommandResolver {
|
|||||||
|
|
||||||
// Volta
|
// Volta
|
||||||
let volta = home.appendingPathComponent(".volta/bin")
|
let volta = home.appendingPathComponent(".volta/bin")
|
||||||
if FileManager.default.fileExists(atPath: volta.path) {
|
if FileManager().fileExists(atPath: volta.path) {
|
||||||
bins.append(volta.path)
|
bins.append(volta.path)
|
||||||
}
|
}
|
||||||
|
|
||||||
// asdf
|
// asdf
|
||||||
let asdf = home.appendingPathComponent(".asdf/shims")
|
let asdf = home.appendingPathComponent(".asdf/shims")
|
||||||
if FileManager.default.fileExists(atPath: asdf.path) {
|
if FileManager().fileExists(atPath: asdf.path) {
|
||||||
bins.append(asdf.path)
|
bins.append(asdf.path)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -137,10 +137,10 @@ enum CommandResolver {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private static func versionedNodeBinPaths(base: URL, suffix: String) -> [String] {
|
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]
|
let entries: [String]
|
||||||
do {
|
do {
|
||||||
entries = try FileManager.default.contentsOfDirectory(atPath: base.path)
|
entries = try FileManager().contentsOfDirectory(atPath: base.path)
|
||||||
} catch {
|
} catch {
|
||||||
return []
|
return []
|
||||||
}
|
}
|
||||||
@ -167,7 +167,7 @@ enum CommandResolver {
|
|||||||
for entry in sorted {
|
for entry in sorted {
|
||||||
let binDir = base.appendingPathComponent(entry).appendingPathComponent(suffix)
|
let binDir = base.appendingPathComponent(entry).appendingPathComponent(suffix)
|
||||||
let node = binDir.appendingPathComponent("node")
|
let node = binDir.appendingPathComponent("node")
|
||||||
if FileManager.default.isExecutableFile(atPath: node.path) {
|
if FileManager().isExecutableFile(atPath: node.path) {
|
||||||
paths.append(binDir.path)
|
paths.append(binDir.path)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -177,7 +177,7 @@ enum CommandResolver {
|
|||||||
static func findExecutable(named name: String, searchPaths: [String]? = nil) -> String? {
|
static func findExecutable(named name: String, searchPaths: [String]? = nil) -> String? {
|
||||||
for dir in searchPaths ?? self.preferredPaths() {
|
for dir in searchPaths ?? self.preferredPaths() {
|
||||||
let candidate = (dir as NSString).appendingPathComponent(name)
|
let candidate = (dir as NSString).appendingPathComponent(name)
|
||||||
if FileManager.default.isExecutableFile(atPath: candidate) {
|
if FileManager().isExecutableFile(atPath: candidate) {
|
||||||
return candidate
|
return candidate
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -191,12 +191,12 @@ enum CommandResolver {
|
|||||||
static func projectClawdbotExecutable(projectRoot: URL? = nil) -> String? {
|
static func projectClawdbotExecutable(projectRoot: URL? = nil) -> String? {
|
||||||
let root = projectRoot ?? self.projectRoot()
|
let root = projectRoot ?? self.projectRoot()
|
||||||
let candidate = root.appendingPathComponent("node_modules/.bin").appendingPathComponent(self.helperName).path
|
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? {
|
static func nodeCliPath() -> String? {
|
||||||
let candidate = self.projectRoot().appendingPathComponent("bin/clawdbot.js").path
|
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 {
|
static func hasAnyClawdbotInvoker(searchPaths: [String]? = nil) -> Bool {
|
||||||
@ -459,7 +459,7 @@ enum CommandResolver {
|
|||||||
private static func expandPath(_ path: String) -> URL? {
|
private static func expandPath(_ path: String) -> URL? {
|
||||||
var expanded = path
|
var expanded = path
|
||||||
if expanded.hasPrefix("~") {
|
if expanded.hasPrefix("~") {
|
||||||
let home = FileManager.default.homeDirectoryForCurrentUser.path
|
let home = FileManager().homeDirectoryForCurrentUser.path
|
||||||
expanded.replaceSubrange(expanded.startIndex...expanded.startIndex, with: home)
|
expanded.replaceSubrange(expanded.startIndex...expanded.startIndex, with: home)
|
||||||
}
|
}
|
||||||
return URL(fileURLWithPath: expanded)
|
return URL(fileURLWithPath: expanded)
|
||||||
|
|||||||
@ -26,7 +26,7 @@ enum DebugActions {
|
|||||||
static func openLog() {
|
static func openLog() {
|
||||||
let path = self.pinoLogPath()
|
let path = self.pinoLogPath()
|
||||||
let url = URL(fileURLWithPath: path)
|
let url = URL(fileURLWithPath: path)
|
||||||
guard FileManager.default.fileExists(atPath: path) else {
|
guard FileManager().fileExists(atPath: path) else {
|
||||||
let alert = NSAlert()
|
let alert = NSAlert()
|
||||||
alert.messageText = "Log file not found"
|
alert.messageText = "Log file not found"
|
||||||
alert.informativeText = path
|
alert.informativeText = path
|
||||||
@ -38,7 +38,7 @@ enum DebugActions {
|
|||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
static func openConfigFolder() {
|
static func openConfigFolder() {
|
||||||
let url = FileManager.default
|
let url = FileManager()
|
||||||
.homeDirectoryForCurrentUser
|
.homeDirectoryForCurrentUser
|
||||||
.appendingPathComponent(".clawdbot", isDirectory: true)
|
.appendingPathComponent(".clawdbot", isDirectory: true)
|
||||||
NSWorkspace.shared.activateFileViewerSelecting([url])
|
NSWorkspace.shared.activateFileViewerSelecting([url])
|
||||||
@ -55,7 +55,7 @@ enum DebugActions {
|
|||||||
}
|
}
|
||||||
let path = self.resolveSessionStorePath()
|
let path = self.resolveSessionStorePath()
|
||||||
let url = URL(fileURLWithPath: path)
|
let url = URL(fileURLWithPath: path)
|
||||||
if FileManager.default.fileExists(atPath: path) {
|
if FileManager().fileExists(atPath: path) {
|
||||||
NSWorkspace.shared.activateFileViewerSelecting([url])
|
NSWorkspace.shared.activateFileViewerSelecting([url])
|
||||||
} else {
|
} else {
|
||||||
NSWorkspace.shared.open(url.deletingLastPathComponent())
|
NSWorkspace.shared.open(url.deletingLastPathComponent())
|
||||||
@ -195,7 +195,7 @@ enum DebugActions {
|
|||||||
@MainActor
|
@MainActor
|
||||||
private static func resolveSessionStorePath() -> String {
|
private static func resolveSessionStorePath() -> String {
|
||||||
let defaultPath = SessionLoader.defaultStorePath
|
let defaultPath = SessionLoader.defaultStorePath
|
||||||
let configURL = FileManager.default.homeDirectoryForCurrentUser
|
let configURL = FileManager().homeDirectoryForCurrentUser
|
||||||
.appendingPathComponent(".clawdbot/clawdbot.json")
|
.appendingPathComponent(".clawdbot/clawdbot.json")
|
||||||
guard
|
guard
|
||||||
let data = try? Data(contentsOf: configURL),
|
let data = try? Data(contentsOf: configURL),
|
||||||
|
|||||||
@ -354,7 +354,7 @@ struct DebugSettings: View {
|
|||||||
Button("Save") { self.saveRelayRoot() }
|
Button("Save") { self.saveRelayRoot() }
|
||||||
.buttonStyle(.borderedProminent)
|
.buttonStyle(.borderedProminent)
|
||||||
Button("Reset") {
|
Button("Reset") {
|
||||||
let def = FileManager.default.homeDirectoryForCurrentUser
|
let def = FileManager().homeDirectoryForCurrentUser
|
||||||
.appendingPathComponent("Projects/clawdbot").path
|
.appendingPathComponent("Projects/clawdbot").path
|
||||||
self.gatewayRootInput = def
|
self.gatewayRootInput = def
|
||||||
self.saveRelayRoot()
|
self.saveRelayRoot()
|
||||||
@ -743,7 +743,7 @@ struct DebugSettings: View {
|
|||||||
|
|
||||||
do {
|
do {
|
||||||
let data = try JSONSerialization.data(withJSONObject: root, options: [.prettyPrinted, .sortedKeys])
|
let data = try JSONSerialization.data(withJSONObject: root, options: [.prettyPrinted, .sortedKeys])
|
||||||
try FileManager.default.createDirectory(
|
try FileManager().createDirectory(
|
||||||
at: url.deletingLastPathComponent(),
|
at: url.deletingLastPathComponent(),
|
||||||
withIntermediateDirectories: true)
|
withIntermediateDirectories: true)
|
||||||
try data.write(to: url, options: [.atomic])
|
try data.write(to: url, options: [.atomic])
|
||||||
@ -776,7 +776,7 @@ struct DebugSettings: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func configURL() -> URL {
|
private func configURL() -> URL {
|
||||||
FileManager.default.homeDirectoryForCurrentUser
|
FileManager().homeDirectoryForCurrentUser
|
||||||
.appendingPathComponent(".clawdbot")
|
.appendingPathComponent(".clawdbot")
|
||||||
.appendingPathComponent("clawdbot.json")
|
.appendingPathComponent("clawdbot.json")
|
||||||
}
|
}
|
||||||
|
|||||||
@ -20,8 +20,8 @@ actor DiagnosticsFileLog {
|
|||||||
}
|
}
|
||||||
|
|
||||||
nonisolated static func logDirectoryURL() -> URL {
|
nonisolated static func logDirectoryURL() -> URL {
|
||||||
let library = FileManager.default.urls(for: .libraryDirectory, in: .userDomainMask).first
|
let library = FileManager().urls(for: .libraryDirectory, in: .userDomainMask).first
|
||||||
?? FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent("Library", isDirectory: true)
|
?? FileManager().homeDirectoryForCurrentUser.appendingPathComponent("Library", isDirectory: true)
|
||||||
return library
|
return library
|
||||||
.appendingPathComponent("Logs", isDirectory: true)
|
.appendingPathComponent("Logs", isDirectory: true)
|
||||||
.appendingPathComponent("Clawdbot", isDirectory: true)
|
.appendingPathComponent("Clawdbot", isDirectory: true)
|
||||||
@ -43,7 +43,7 @@ actor DiagnosticsFileLog {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func clear() throws {
|
func clear() throws {
|
||||||
let fm = FileManager.default
|
let fm = FileManager()
|
||||||
let base = Self.logFileURL()
|
let base = Self.logFileURL()
|
||||||
if fm.fileExists(atPath: base.path) {
|
if fm.fileExists(atPath: base.path) {
|
||||||
try fm.removeItem(at: base)
|
try fm.removeItem(at: base)
|
||||||
@ -67,7 +67,7 @@ actor DiagnosticsFileLog {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func ensureDirectory() throws {
|
private func ensureDirectory() throws {
|
||||||
try FileManager.default.createDirectory(
|
try FileManager().createDirectory(
|
||||||
at: Self.logDirectoryURL(),
|
at: Self.logDirectoryURL(),
|
||||||
withIntermediateDirectories: true)
|
withIntermediateDirectories: true)
|
||||||
}
|
}
|
||||||
@ -79,7 +79,7 @@ actor DiagnosticsFileLog {
|
|||||||
line.append(data)
|
line.append(data)
|
||||||
line.append(0x0A) // newline
|
line.append(0x0A) // newline
|
||||||
|
|
||||||
let fm = FileManager.default
|
let fm = FileManager()
|
||||||
if !fm.fileExists(atPath: url.path) {
|
if !fm.fileExists(atPath: url.path) {
|
||||||
fm.createFile(atPath: url.path, contents: nil)
|
fm.createFile(atPath: url.path, contents: nil)
|
||||||
}
|
}
|
||||||
@ -92,13 +92,13 @@ actor DiagnosticsFileLog {
|
|||||||
|
|
||||||
private func rotateIfNeeded() throws {
|
private func rotateIfNeeded() throws {
|
||||||
let url = Self.logFileURL()
|
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
|
let size = attrs[.size] as? NSNumber
|
||||||
else { return }
|
else { return }
|
||||||
|
|
||||||
if size.int64Value < self.maxBytes { return }
|
if size.int64Value < self.maxBytes { return }
|
||||||
|
|
||||||
let fm = FileManager.default
|
let fm = FileManager()
|
||||||
|
|
||||||
let oldest = self.rotatedURL(index: self.maxBackups)
|
let oldest = self.rotatedURL(index: self.maxBackups)
|
||||||
if fm.fileExists(atPath: oldest.path) {
|
if fm.fileExists(atPath: oldest.path) {
|
||||||
|
|||||||
@ -176,7 +176,7 @@ enum ExecApprovalsStore {
|
|||||||
|
|
||||||
static func readSnapshot() -> ExecApprovalsSnapshot {
|
static func readSnapshot() -> ExecApprovalsSnapshot {
|
||||||
let url = self.fileURL()
|
let url = self.fileURL()
|
||||||
guard FileManager.default.fileExists(atPath: url.path) else {
|
guard FileManager().fileExists(atPath: url.path) else {
|
||||||
return ExecApprovalsSnapshot(
|
return ExecApprovalsSnapshot(
|
||||||
path: url.path,
|
path: url.path,
|
||||||
exists: false,
|
exists: false,
|
||||||
@ -216,7 +216,7 @@ enum ExecApprovalsStore {
|
|||||||
|
|
||||||
static func loadFile() -> ExecApprovalsFile {
|
static func loadFile() -> ExecApprovalsFile {
|
||||||
let url = self.fileURL()
|
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: [:])
|
return ExecApprovalsFile(version: 1, socket: nil, defaults: nil, agents: [:])
|
||||||
}
|
}
|
||||||
do {
|
do {
|
||||||
@ -238,11 +238,11 @@ enum ExecApprovalsStore {
|
|||||||
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
|
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
|
||||||
let data = try encoder.encode(file)
|
let data = try encoder.encode(file)
|
||||||
let url = self.fileURL()
|
let url = self.fileURL()
|
||||||
try FileManager.default.createDirectory(
|
try FileManager().createDirectory(
|
||||||
at: url.deletingLastPathComponent(),
|
at: url.deletingLastPathComponent(),
|
||||||
withIntermediateDirectories: true)
|
withIntermediateDirectories: true)
|
||||||
try data.write(to: url, options: [.atomic])
|
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 {
|
} catch {
|
||||||
self.logger.error("exec approvals save failed: \(error.localizedDescription, privacy: .public)")
|
self.logger.error("exec approvals save failed: \(error.localizedDescription, privacy: .public)")
|
||||||
}
|
}
|
||||||
@ -442,11 +442,11 @@ enum ExecApprovalsStore {
|
|||||||
private static func expandPath(_ raw: String) -> String {
|
private static func expandPath(_ raw: String) -> String {
|
||||||
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
if trimmed == "~" {
|
if trimmed == "~" {
|
||||||
return FileManager.default.homeDirectoryForCurrentUser.path
|
return FileManager().homeDirectoryForCurrentUser.path
|
||||||
}
|
}
|
||||||
if trimmed.hasPrefix("~/") {
|
if trimmed.hasPrefix("~/") {
|
||||||
let suffix = trimmed.dropFirst(2)
|
let suffix = trimmed.dropFirst(2)
|
||||||
return FileManager.default.homeDirectoryForCurrentUser
|
return FileManager().homeDirectoryForCurrentUser
|
||||||
.appendingPathComponent(String(suffix)).path
|
.appendingPathComponent(String(suffix)).path
|
||||||
}
|
}
|
||||||
return trimmed
|
return trimmed
|
||||||
@ -497,7 +497,7 @@ struct ExecCommandResolution: Sendable {
|
|||||||
return expanded
|
return expanded
|
||||||
}
|
}
|
||||||
let base = cwd?.trimmingCharacters(in: .whitespacesAndNewlines)
|
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
|
return URL(fileURLWithPath: root).appendingPathComponent(expanded).path
|
||||||
}
|
}
|
||||||
let searchPaths = self.searchPaths(from: env)
|
let searchPaths = self.searchPaths(from: env)
|
||||||
|
|||||||
@ -155,7 +155,8 @@ actor GatewayEndpointStore {
|
|||||||
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
if !trimmed.isEmpty {
|
if !trimmed.isEmpty {
|
||||||
if let configToken = self.resolveConfigToken(isRemote: isRemote, root: root),
|
if let configToken = self.resolveConfigToken(isRemote: isRemote, root: root),
|
||||||
!configToken.isEmpty
|
!configToken.isEmpty,
|
||||||
|
configToken != trimmed
|
||||||
{
|
{
|
||||||
self.warnEnvOverrideOnce(
|
self.warnEnvOverrideOnce(
|
||||||
kind: .token,
|
kind: .token,
|
||||||
@ -164,32 +165,19 @@ actor GatewayEndpointStore {
|
|||||||
}
|
}
|
||||||
return trimmed
|
return trimmed
|
||||||
}
|
}
|
||||||
if isRemote {
|
|
||||||
if let gateway = root["gateway"] as? [String: Any],
|
if let configToken = self.resolveConfigToken(isRemote: isRemote, root: root),
|
||||||
let remote = gateway["remote"] as? [String: Any],
|
!configToken.isEmpty
|
||||||
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
|
|
||||||
{
|
{
|
||||||
let value = token.trimmingCharacters(in: .whitespacesAndNewlines)
|
return configToken
|
||||||
if !value.isEmpty {
|
|
||||||
return value
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if let token = launchdSnapshot?.token?.trimmingCharacters(in: .whitespacesAndNewlines),
|
if let token = launchdSnapshot?.token?.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||||
!token.isEmpty
|
!token.isEmpty
|
||||||
{
|
{
|
||||||
return token
|
return token
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -5,7 +5,7 @@ enum GatewayLaunchAgentManager {
|
|||||||
private static let disableLaunchAgentMarker = ".clawdbot/disable-launchagent"
|
private static let disableLaunchAgentMarker = ".clawdbot/disable-launchagent"
|
||||||
|
|
||||||
private static var plistURL: URL {
|
private static var plistURL: URL {
|
||||||
FileManager.default.homeDirectoryForCurrentUser
|
FileManager().homeDirectoryForCurrentUser
|
||||||
.appendingPathComponent("Library/LaunchAgents/\(gatewayLaunchdLabel).plist")
|
.appendingPathComponent("Library/LaunchAgents/\(gatewayLaunchdLabel).plist")
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -67,9 +67,9 @@ enum GatewayLaunchAgentManager {
|
|||||||
|
|
||||||
extension GatewayLaunchAgentManager {
|
extension GatewayLaunchAgentManager {
|
||||||
private static func isLaunchAgentWriteDisabled() -> Bool {
|
private static func isLaunchAgentWriteDisabled() -> Bool {
|
||||||
let marker = FileManager.default.homeDirectoryForCurrentUser
|
let marker = FileManager().homeDirectoryForCurrentUser
|
||||||
.appendingPathComponent(self.disableLaunchAgentMarker)
|
.appendingPathComponent(self.disableLaunchAgentMarker)
|
||||||
return FileManager.default.fileExists(atPath: marker.path)
|
return FileManager().fileExists(atPath: marker.path)
|
||||||
}
|
}
|
||||||
|
|
||||||
private static func readDaemonLoaded() async -> Bool? {
|
private static func readDaemonLoaded() async -> Bool? {
|
||||||
|
|||||||
@ -365,7 +365,7 @@ final class GatewayProcessManager {
|
|||||||
|
|
||||||
func clearLog() {
|
func clearLog() {
|
||||||
self.log = ""
|
self.log = ""
|
||||||
try? FileManager.default.removeItem(atPath: GatewayLaunchAgentManager.launchdGatewayLogPath())
|
try? FileManager().removeItem(atPath: GatewayLaunchAgentManager.launchdGatewayLogPath())
|
||||||
self.logger.debug("gateway log cleared")
|
self.logger.debug("gateway log cleared")
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -378,7 +378,7 @@ final class GatewayProcessManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private nonisolated static func readGatewayLog(path: String, limit: Int) -> String {
|
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 "" }
|
guard let data = try? Data(contentsOf: URL(fileURLWithPath: path)) else { return "" }
|
||||||
let text = String(data: data, encoding: .utf8) ?? ""
|
let text = String(data: data, encoding: .utf8) ?? ""
|
||||||
if text.count <= limit { return text }
|
if text.count <= limit { return text }
|
||||||
|
|||||||
@ -3,17 +3,17 @@ import Foundation
|
|||||||
enum LaunchAgentManager {
|
enum LaunchAgentManager {
|
||||||
private static let legacyLaunchdLabel = "com.steipete.clawdbot"
|
private static let legacyLaunchdLabel = "com.steipete.clawdbot"
|
||||||
private static var plistURL: URL {
|
private static var plistURL: URL {
|
||||||
FileManager.default.homeDirectoryForCurrentUser
|
FileManager().homeDirectoryForCurrentUser
|
||||||
.appendingPathComponent("Library/LaunchAgents/com.clawdbot.mac.plist")
|
.appendingPathComponent("Library/LaunchAgents/com.clawdbot.mac.plist")
|
||||||
}
|
}
|
||||||
|
|
||||||
private static var legacyPlistURL: URL {
|
private static var legacyPlistURL: URL {
|
||||||
FileManager.default.homeDirectoryForCurrentUser
|
FileManager().homeDirectoryForCurrentUser
|
||||||
.appendingPathComponent("Library/LaunchAgents/\(legacyLaunchdLabel).plist")
|
.appendingPathComponent("Library/LaunchAgents/\(legacyLaunchdLabel).plist")
|
||||||
}
|
}
|
||||||
|
|
||||||
static func status() async -> Bool {
|
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)"])
|
let result = await self.runLaunchctl(["print", "gui/\(getuid())/\(launchdLabel)"])
|
||||||
return result == 0
|
return result == 0
|
||||||
}
|
}
|
||||||
@ -21,7 +21,7 @@ enum LaunchAgentManager {
|
|||||||
static func set(enabled: Bool, bundlePath: String) async {
|
static func set(enabled: Bool, bundlePath: String) async {
|
||||||
if enabled {
|
if enabled {
|
||||||
_ = await self.runLaunchctl(["bootout", "gui/\(getuid())/\(self.legacyLaunchdLabel)"])
|
_ = 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)
|
self.writePlist(bundlePath: bundlePath)
|
||||||
_ = await self.runLaunchctl(["bootout", "gui/\(getuid())/\(launchdLabel)"])
|
_ = await self.runLaunchctl(["bootout", "gui/\(getuid())/\(launchdLabel)"])
|
||||||
_ = await self.runLaunchctl(["bootstrap", "gui/\(getuid())", self.plistURL.path])
|
_ = await self.runLaunchctl(["bootstrap", "gui/\(getuid())", self.plistURL.path])
|
||||||
@ -29,7 +29,7 @@ enum LaunchAgentManager {
|
|||||||
} else {
|
} else {
|
||||||
// Disable autostart going forward but leave the current app running.
|
// 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).
|
// 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>
|
<string>\(bundlePath)/Contents/MacOS/Clawdbot</string>
|
||||||
</array>
|
</array>
|
||||||
<key>WorkingDirectory</key>
|
<key>WorkingDirectory</key>
|
||||||
<string>\(FileManager.default.homeDirectoryForCurrentUser.path)</string>
|
<string>\(FileManager().homeDirectoryForCurrentUser.path)</string>
|
||||||
<key>RunAtLoad</key>
|
<key>RunAtLoad</key>
|
||||||
<true/>
|
<true/>
|
||||||
<key>KeepAlive</key>
|
<key>KeepAlive</key>
|
||||||
|
|||||||
@ -18,7 +18,7 @@ enum LogLocator {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private static func ensureLogDirExists() {
|
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 {
|
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.
|
/// Returns the newest log file under /tmp/clawdbot/ (rolling or stdout), or nil if none exist.
|
||||||
static func bestLogFile() -> URL? {
|
static func bestLogFile() -> URL? {
|
||||||
self.ensureLogDirExists()
|
self.ensureLogDirExists()
|
||||||
let fm = FileManager.default
|
let fm = FileManager()
|
||||||
let files = (try? fm.contentsOfDirectory(
|
let files = (try? fm.contentsOfDirectory(
|
||||||
at: self.logDir,
|
at: self.logDir,
|
||||||
includingPropertiesForKeys: [.contentModificationDateKey],
|
includingPropertiesForKeys: [.contentModificationDateKey],
|
||||||
|
|||||||
@ -2,7 +2,7 @@ import Foundation
|
|||||||
import JavaScriptCore
|
import JavaScriptCore
|
||||||
|
|
||||||
enum ModelCatalogLoader {
|
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
|
.appendingPathComponent("Projects/pi-mono/packages/ai/src/models.generated.ts").path
|
||||||
private static let logger = Logger(subsystem: "com.clawdbot", category: "models")
|
private static let logger = Logger(subsystem: "com.clawdbot", category: "models")
|
||||||
|
|
||||||
|
|||||||
@ -133,7 +133,7 @@ actor MacNodeRuntime {
|
|||||||
|
|
||||||
let sessionKey = self.mainSessionKey
|
let sessionKey = self.mainSessionKey
|
||||||
let path = try await CanvasManager.shared.snapshot(sessionKey: sessionKey, outPath: nil)
|
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))
|
let data = try Data(contentsOf: URL(fileURLWithPath: path))
|
||||||
guard let image = NSImage(data: data) else {
|
guard let image = NSImage(data: data) else {
|
||||||
return Self.errorResponse(req, code: .unavailable, message: "canvas snapshot decode failed")
|
return Self.errorResponse(req, code: .unavailable, message: "canvas snapshot decode failed")
|
||||||
@ -206,7 +206,7 @@ actor MacNodeRuntime {
|
|||||||
includeAudio: params.includeAudio ?? true,
|
includeAudio: params.includeAudio ?? true,
|
||||||
deviceId: params.deviceId,
|
deviceId: params.deviceId,
|
||||||
outPath: nil)
|
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))
|
let data = try Data(contentsOf: URL(fileURLWithPath: res.path))
|
||||||
struct ClipPayload: Encodable {
|
struct ClipPayload: Encodable {
|
||||||
var format: String
|
var format: String
|
||||||
@ -312,7 +312,7 @@ actor MacNodeRuntime {
|
|||||||
fps: params.fps,
|
fps: params.fps,
|
||||||
includeAudio: params.includeAudio,
|
includeAudio: params.includeAudio,
|
||||||
outPath: nil)
|
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))
|
let data = try Data(contentsOf: URL(fileURLWithPath: res.path))
|
||||||
struct ScreenPayload: Encodable {
|
struct ScreenPayload: Encodable {
|
||||||
var format: String
|
var format: String
|
||||||
|
|||||||
@ -24,7 +24,7 @@ actor PortGuardian {
|
|||||||
private var records: [Record] = []
|
private var records: [Record] = []
|
||||||
private let logger = Logger(subsystem: "com.clawdbot", category: "portguard")
|
private let logger = Logger(subsystem: "com.clawdbot", category: "portguard")
|
||||||
private nonisolated static let appSupportDir: URL = {
|
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)
|
return base.appendingPathComponent("Clawdbot", isDirectory: true)
|
||||||
}()
|
}()
|
||||||
|
|
||||||
@ -71,7 +71,7 @@ actor PortGuardian {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func record(port: Int, pid: Int32, command: String, mode: AppState.ConnectionMode) async {
|
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.removeAll { $0.pid == pid }
|
||||||
self.records.append(
|
self.records.append(
|
||||||
Record(
|
Record(
|
||||||
|
|||||||
@ -111,7 +111,7 @@ enum RuntimeLocator {
|
|||||||
// MARK: - Internals
|
// MARK: - Internals
|
||||||
|
|
||||||
private static func findExecutable(named name: String, searchPaths: [String]) -> String? {
|
private static func findExecutable(named name: String, searchPaths: [String]) -> String? {
|
||||||
let fm = FileManager.default
|
let fm = FileManager()
|
||||||
for dir in searchPaths {
|
for dir in searchPaths {
|
||||||
let candidate = (dir as NSString).appendingPathComponent(name)
|
let candidate = (dir as NSString).appendingPathComponent(name)
|
||||||
if fm.isExecutableFile(atPath: candidate) {
|
if fm.isExecutableFile(atPath: candidate) {
|
||||||
|
|||||||
@ -42,10 +42,10 @@ final class ScreenRecordService {
|
|||||||
if let outPath, !outPath.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
if let outPath, !outPath.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||||||
return URL(fileURLWithPath: outPath)
|
return URL(fileURLWithPath: outPath)
|
||||||
}
|
}
|
||||||
return FileManager.default.temporaryDirectory
|
return FileManager().temporaryDirectory
|
||||||
.appendingPathComponent("clawdbot-screen-record-\(UUID().uuidString).mp4")
|
.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 content = try await SCShareableContent.current
|
||||||
let displays = content.displays.sorted { $0.displayID < $1.displayID }
|
let displays = content.displays.sorted { $0.displayID < $1.displayID }
|
||||||
|
|||||||
@ -66,12 +66,12 @@ enum SessionActions {
|
|||||||
let dir = URL(fileURLWithPath: storePath).deletingLastPathComponent()
|
let dir = URL(fileURLWithPath: storePath).deletingLastPathComponent()
|
||||||
urls.append(dir.appendingPathComponent("\(sessionId).jsonl"))
|
urls.append(dir.appendingPathComponent("\(sessionId).jsonl"))
|
||||||
}
|
}
|
||||||
let home = FileManager.default.homeDirectoryForCurrentUser
|
let home = FileManager().homeDirectoryForCurrentUser
|
||||||
urls.append(home.appendingPathComponent(".clawdbot/sessions/\(sessionId).jsonl"))
|
urls.append(home.appendingPathComponent(".clawdbot/sessions/\(sessionId).jsonl"))
|
||||||
return urls
|
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 {
|
guard let url = existing else {
|
||||||
let alert = NSAlert()
|
let alert = NSAlert()
|
||||||
alert.messageText = "Session log not found"
|
alert.messageText = "Session log not found"
|
||||||
|
|||||||
@ -246,7 +246,7 @@ enum SessionLoader {
|
|||||||
static let fallbackContextTokens = 200_000
|
static let fallbackContextTokens = 200_000
|
||||||
|
|
||||||
static let defaultStorePath = standardize(
|
static let defaultStorePath = standardize(
|
||||||
FileManager.default.homeDirectoryForCurrentUser
|
FileManager().homeDirectoryForCurrentUser
|
||||||
.appendingPathComponent(".clawdbot/sessions/sessions.json").path)
|
.appendingPathComponent(".clawdbot/sessions/sessions.json").path)
|
||||||
|
|
||||||
static func loadSnapshot(
|
static func loadSnapshot(
|
||||||
|
|||||||
@ -44,7 +44,7 @@ enum SoundEffectCatalog {
|
|||||||
]
|
]
|
||||||
|
|
||||||
private static let searchRoots: [URL] = [
|
private static let searchRoots: [URL] = [
|
||||||
FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent("Library/Sounds"),
|
FileManager().homeDirectoryForCurrentUser.appendingPathComponent("Library/Sounds"),
|
||||||
URL(fileURLWithPath: "/Library/Sounds"),
|
URL(fileURLWithPath: "/Library/Sounds"),
|
||||||
URL(fileURLWithPath: "/System/Applications/Mail.app/Contents/Resources"), // Mail “swoosh”
|
URL(fileURLWithPath: "/System/Applications/Mail.app/Contents/Resources"), // Mail “swoosh”
|
||||||
URL(fileURLWithPath: "/System/Library/Sounds"),
|
URL(fileURLWithPath: "/System/Library/Sounds"),
|
||||||
@ -53,7 +53,7 @@ enum SoundEffectCatalog {
|
|||||||
private static let discoveredSoundMap: [String: URL] = {
|
private static let discoveredSoundMap: [String: URL] = {
|
||||||
var map: [String: URL] = [:]
|
var map: [String: URL] = [:]
|
||||||
for root in Self.searchRoots {
|
for root in Self.searchRoots {
|
||||||
guard let contents = try? FileManager.default.contentsOfDirectory(
|
guard let contents = try? FileManager().contentsOfDirectory(
|
||||||
at: root,
|
at: root,
|
||||||
includingPropertiesForKeys: nil,
|
includingPropertiesForKeys: nil,
|
||||||
options: [.skipsHiddenFiles])
|
options: [.skipsHiddenFiles])
|
||||||
|
|||||||
@ -53,7 +53,7 @@ final class TailscaleService {
|
|||||||
#endif
|
#endif
|
||||||
|
|
||||||
func checkAppInstallation() -> Bool {
|
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)")
|
self.logger.info("Tailscale app installed: \(installed)")
|
||||||
return installed
|
return installed
|
||||||
}
|
}
|
||||||
|
|||||||
@ -37,7 +37,7 @@ final class VoicePushToTalkHotkey: @unchecked Sendable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func startMonitoring() {
|
private func startMonitoring() {
|
||||||
assert(Thread.isMainThread)
|
// assert(Thread.isMainThread) - Removed for Swift 6
|
||||||
guard self.globalMonitor == nil, self.localMonitor == nil else { return }
|
guard self.globalMonitor == nil, self.localMonitor == nil else { return }
|
||||||
// Listen-only global monitor; we rely on Input Monitoring permission to receive events.
|
// Listen-only global monitor; we rely on Input Monitoring permission to receive events.
|
||||||
self.globalMonitor = NSEvent.addGlobalMonitorForEvents(matching: .flagsChanged) { [weak self] event in
|
self.globalMonitor = NSEvent.addGlobalMonitorForEvents(matching: .flagsChanged) { [weak self] event in
|
||||||
@ -55,7 +55,7 @@ final class VoicePushToTalkHotkey: @unchecked Sendable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func stopMonitoring() {
|
private func stopMonitoring() {
|
||||||
assert(Thread.isMainThread)
|
// assert(Thread.isMainThread) - Removed for Swift 6
|
||||||
if let globalMonitor {
|
if let globalMonitor {
|
||||||
NSEvent.removeMonitor(globalMonitor)
|
NSEvent.removeMonitor(globalMonitor)
|
||||||
self.globalMonitor = nil
|
self.globalMonitor = nil
|
||||||
@ -75,15 +75,11 @@ final class VoicePushToTalkHotkey: @unchecked Sendable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func withMainThread(_ block: @escaping @Sendable () -> Void) {
|
private func withMainThread(_ block: @escaping @Sendable () -> Void) {
|
||||||
if Thread.isMainThread {
|
DispatchQueue.main.async(execute: block)
|
||||||
block()
|
|
||||||
} else {
|
|
||||||
DispatchQueue.main.async(execute: block)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func updateModifierState(keyCode: UInt16, modifierFlags: NSEvent.ModifierFlags) {
|
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.
|
// Right Option (keyCode 61) acts as a hold-to-talk modifier.
|
||||||
if keyCode == 61 {
|
if keyCode == 61 {
|
||||||
self.optionDown = modifierFlags.contains(.option)
|
self.optionDown = modifierFlags.contains(.option)
|
||||||
|
|||||||
@ -409,7 +409,7 @@ extension Request: Codable {
|
|||||||
|
|
||||||
// Shared transport settings
|
// Shared transport settings
|
||||||
public let controlSocketPath =
|
public let controlSocketPath =
|
||||||
FileManager.default
|
FileManager()
|
||||||
.homeDirectoryForCurrentUser
|
.homeDirectoryForCurrentUser
|
||||||
.appendingPathComponent("Library/Application Support/clawdbot/control.sock")
|
.appendingPathComponent("Library/Application Support/clawdbot/control.sock")
|
||||||
.path
|
.path
|
||||||
|
|||||||
@ -187,7 +187,7 @@ private func resolvedPassword(opts: WizardCliOptions, config: GatewayConfig) ->
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func loadGatewayConfig() -> GatewayConfig {
|
private func loadGatewayConfig() -> GatewayConfig {
|
||||||
let url = FileManager.default.homeDirectoryForCurrentUser
|
let url = FileManager().homeDirectoryForCurrentUser
|
||||||
.appendingPathComponent(".clawdbot")
|
.appendingPathComponent(".clawdbot")
|
||||||
.appendingPathComponent("clawdbot.json")
|
.appendingPathComponent("clawdbot.json")
|
||||||
guard let data = try? Data(contentsOf: url) else { return GatewayConfig() }
|
guard let data = try? Data(contentsOf: url) else { return GatewayConfig() }
|
||||||
|
|||||||
@ -6,7 +6,7 @@ import Testing
|
|||||||
struct AgentWorkspaceTests {
|
struct AgentWorkspaceTests {
|
||||||
@Test
|
@Test
|
||||||
func displayPathUsesTildeForHome() {
|
func displayPathUsesTildeForHome() {
|
||||||
let home = FileManager.default.homeDirectoryForCurrentUser
|
let home = FileManager().homeDirectoryForCurrentUser
|
||||||
#expect(AgentWorkspace.displayPath(for: home) == "~")
|
#expect(AgentWorkspace.displayPath(for: home) == "~")
|
||||||
|
|
||||||
let inside = home.appendingPathComponent("Projects", isDirectory: true)
|
let inside = home.appendingPathComponent("Projects", isDirectory: true)
|
||||||
@ -28,12 +28,12 @@ struct AgentWorkspaceTests {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
func bootstrapCreatesAgentsFileWhenMissing() throws {
|
func bootstrapCreatesAgentsFileWhenMissing() throws {
|
||||||
let tmp = FileManager.default.temporaryDirectory
|
let tmp = FileManager().temporaryDirectory
|
||||||
.appendingPathComponent("clawdbot-ws-\(UUID().uuidString)", isDirectory: true)
|
.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)
|
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)
|
let contents = try String(contentsOf: agentsURL, encoding: .utf8)
|
||||||
#expect(contents.contains("# AGENTS.md"))
|
#expect(contents.contains("# AGENTS.md"))
|
||||||
@ -41,9 +41,9 @@ struct AgentWorkspaceTests {
|
|||||||
let identityURL = tmp.appendingPathComponent(AgentWorkspace.identityFilename)
|
let identityURL = tmp.appendingPathComponent(AgentWorkspace.identityFilename)
|
||||||
let userURL = tmp.appendingPathComponent(AgentWorkspace.userFilename)
|
let userURL = tmp.appendingPathComponent(AgentWorkspace.userFilename)
|
||||||
let bootstrapURL = tmp.appendingPathComponent(AgentWorkspace.bootstrapFilename)
|
let bootstrapURL = tmp.appendingPathComponent(AgentWorkspace.bootstrapFilename)
|
||||||
#expect(FileManager.default.fileExists(atPath: identityURL.path))
|
#expect(FileManager().fileExists(atPath: identityURL.path))
|
||||||
#expect(FileManager.default.fileExists(atPath: userURL.path))
|
#expect(FileManager().fileExists(atPath: userURL.path))
|
||||||
#expect(FileManager.default.fileExists(atPath: bootstrapURL.path))
|
#expect(FileManager().fileExists(atPath: bootstrapURL.path))
|
||||||
|
|
||||||
let second = try AgentWorkspace.bootstrap(workspaceURL: tmp)
|
let second = try AgentWorkspace.bootstrap(workspaceURL: tmp)
|
||||||
#expect(second == agentsURL)
|
#expect(second == agentsURL)
|
||||||
@ -51,10 +51,10 @@ struct AgentWorkspaceTests {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
func bootstrapSafetyRejectsNonEmptyFolderWithoutAgents() throws {
|
func bootstrapSafetyRejectsNonEmptyFolderWithoutAgents() throws {
|
||||||
let tmp = FileManager.default.temporaryDirectory
|
let tmp = FileManager().temporaryDirectory
|
||||||
.appendingPathComponent("clawdbot-ws-\(UUID().uuidString)", isDirectory: true)
|
.appendingPathComponent("clawdbot-ws-\(UUID().uuidString)", isDirectory: true)
|
||||||
defer { try? FileManager.default.removeItem(at: tmp) }
|
defer { try? FileManager().removeItem(at: tmp) }
|
||||||
try FileManager.default.createDirectory(at: tmp, withIntermediateDirectories: true)
|
try FileManager().createDirectory(at: tmp, withIntermediateDirectories: true)
|
||||||
let marker = tmp.appendingPathComponent("notes.txt")
|
let marker = tmp.appendingPathComponent("notes.txt")
|
||||||
try "hello".write(to: marker, atomically: true, encoding: .utf8)
|
try "hello".write(to: marker, atomically: true, encoding: .utf8)
|
||||||
|
|
||||||
@ -69,10 +69,10 @@ struct AgentWorkspaceTests {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
func bootstrapSafetyAllowsExistingAgentsFile() throws {
|
func bootstrapSafetyAllowsExistingAgentsFile() throws {
|
||||||
let tmp = FileManager.default.temporaryDirectory
|
let tmp = FileManager().temporaryDirectory
|
||||||
.appendingPathComponent("clawdbot-ws-\(UUID().uuidString)", isDirectory: true)
|
.appendingPathComponent("clawdbot-ws-\(UUID().uuidString)", isDirectory: true)
|
||||||
defer { try? FileManager.default.removeItem(at: tmp) }
|
defer { try? FileManager().removeItem(at: tmp) }
|
||||||
try FileManager.default.createDirectory(at: tmp, withIntermediateDirectories: true)
|
try FileManager().createDirectory(at: tmp, withIntermediateDirectories: true)
|
||||||
let agents = tmp.appendingPathComponent(AgentWorkspace.agentsFilename)
|
let agents = tmp.appendingPathComponent(AgentWorkspace.agentsFilename)
|
||||||
try "# AGENTS.md".write(to: agents, atomically: true, encoding: .utf8)
|
try "# AGENTS.md".write(to: agents, atomically: true, encoding: .utf8)
|
||||||
|
|
||||||
@ -87,25 +87,25 @@ struct AgentWorkspaceTests {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
func bootstrapSkipsBootstrapFileWhenWorkspaceHasContent() throws {
|
func bootstrapSkipsBootstrapFileWhenWorkspaceHasContent() throws {
|
||||||
let tmp = FileManager.default.temporaryDirectory
|
let tmp = FileManager().temporaryDirectory
|
||||||
.appendingPathComponent("clawdbot-ws-\(UUID().uuidString)", isDirectory: true)
|
.appendingPathComponent("clawdbot-ws-\(UUID().uuidString)", isDirectory: true)
|
||||||
defer { try? FileManager.default.removeItem(at: tmp) }
|
defer { try? FileManager().removeItem(at: tmp) }
|
||||||
try FileManager.default.createDirectory(at: tmp, withIntermediateDirectories: true)
|
try FileManager().createDirectory(at: tmp, withIntermediateDirectories: true)
|
||||||
let marker = tmp.appendingPathComponent("notes.txt")
|
let marker = tmp.appendingPathComponent("notes.txt")
|
||||||
try "hello".write(to: marker, atomically: true, encoding: .utf8)
|
try "hello".write(to: marker, atomically: true, encoding: .utf8)
|
||||||
|
|
||||||
_ = try AgentWorkspace.bootstrap(workspaceURL: tmp)
|
_ = try AgentWorkspace.bootstrap(workspaceURL: tmp)
|
||||||
|
|
||||||
let bootstrapURL = tmp.appendingPathComponent(AgentWorkspace.bootstrapFilename)
|
let bootstrapURL = tmp.appendingPathComponent(AgentWorkspace.bootstrapFilename)
|
||||||
#expect(!FileManager.default.fileExists(atPath: bootstrapURL.path))
|
#expect(!FileManager().fileExists(atPath: bootstrapURL.path))
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
func needsBootstrapFalseWhenIdentityAlreadySet() throws {
|
func needsBootstrapFalseWhenIdentityAlreadySet() throws {
|
||||||
let tmp = FileManager.default.temporaryDirectory
|
let tmp = FileManager().temporaryDirectory
|
||||||
.appendingPathComponent("clawdbot-ws-\(UUID().uuidString)", isDirectory: true)
|
.appendingPathComponent("clawdbot-ws-\(UUID().uuidString)", isDirectory: true)
|
||||||
defer { try? FileManager.default.removeItem(at: tmp) }
|
defer { try? FileManager().removeItem(at: tmp) }
|
||||||
try FileManager.default.createDirectory(at: tmp, withIntermediateDirectories: true)
|
try FileManager().createDirectory(at: tmp, withIntermediateDirectories: true)
|
||||||
let identityURL = tmp.appendingPathComponent(AgentWorkspace.identityFilename)
|
let identityURL = tmp.appendingPathComponent(AgentWorkspace.identityFilename)
|
||||||
try """
|
try """
|
||||||
# IDENTITY.md - Agent Identity
|
# IDENTITY.md - Agent Identity
|
||||||
|
|||||||
@ -6,9 +6,9 @@ import Testing
|
|||||||
struct AnthropicAuthResolverTests {
|
struct AnthropicAuthResolverTests {
|
||||||
@Test
|
@Test
|
||||||
func prefersOAuthFileOverEnv() throws {
|
func prefersOAuthFileOverEnv() throws {
|
||||||
let dir = FileManager.default.temporaryDirectory
|
let dir = FileManager().temporaryDirectory
|
||||||
.appendingPathComponent("clawdbot-oauth-\(UUID().uuidString)", isDirectory: true)
|
.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 oauthFile = dir.appendingPathComponent("oauth.json")
|
||||||
let payload = [
|
let payload = [
|
||||||
"anthropic": [
|
"anthropic": [
|
||||||
|
|||||||
@ -6,7 +6,7 @@ import Testing
|
|||||||
@MainActor
|
@MainActor
|
||||||
struct CLIInstallerTests {
|
struct CLIInstallerTests {
|
||||||
@Test func installedLocationFindsExecutable() throws {
|
@Test func installedLocationFindsExecutable() throws {
|
||||||
let fm = FileManager.default
|
let fm = FileManager()
|
||||||
let root = fm.temporaryDirectory.appendingPathComponent(
|
let root = fm.temporaryDirectory.appendingPathComponent(
|
||||||
"clawdbot-cli-installer-\(UUID().uuidString)")
|
"clawdbot-cli-installer-\(UUID().uuidString)")
|
||||||
defer { try? fm.removeItem(at: root) }
|
defer { try? fm.removeItem(at: root) }
|
||||||
|
|||||||
@ -7,13 +7,13 @@ import Testing
|
|||||||
private func makeTempDir() throws -> URL {
|
private func makeTempDir() throws -> URL {
|
||||||
let base = URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true)
|
let base = URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true)
|
||||||
let dir = base.appendingPathComponent("clawdbot-canvaswatch-\(UUID().uuidString)", 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
|
return dir
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test func detectsInPlaceFileWrites() async throws {
|
@Test func detectsInPlaceFileWrites() async throws {
|
||||||
let dir = try self.makeTempDir()
|
let dir = try self.makeTempDir()
|
||||||
defer { try? FileManager.default.removeItem(at: dir) }
|
defer { try? FileManager().removeItem(at: dir) }
|
||||||
|
|
||||||
let file = dir.appendingPathComponent("index.html")
|
let file = dir.appendingPathComponent("index.html")
|
||||||
try "hello".write(to: file, atomically: false, encoding: .utf8)
|
try "hello".write(to: file, atomically: false, encoding: .utf8)
|
||||||
|
|||||||
@ -8,10 +8,10 @@ import Testing
|
|||||||
@MainActor
|
@MainActor
|
||||||
struct CanvasWindowSmokeTests {
|
struct CanvasWindowSmokeTests {
|
||||||
@Test func panelControllerShowsAndHides() async throws {
|
@Test func panelControllerShowsAndHides() async throws {
|
||||||
let root = FileManager.default.temporaryDirectory
|
let root = FileManager().temporaryDirectory
|
||||||
.appendingPathComponent("clawdbot-canvas-test-\(UUID().uuidString)")
|
.appendingPathComponent("clawdbot-canvas-test-\(UUID().uuidString)")
|
||||||
try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true)
|
try FileManager().createDirectory(at: root, withIntermediateDirectories: true)
|
||||||
defer { try? FileManager.default.removeItem(at: root) }
|
defer { try? FileManager().removeItem(at: root) }
|
||||||
|
|
||||||
let anchor = { NSRect(x: 200, y: 400, width: 40, height: 40) }
|
let anchor = { NSRect(x: 200, y: 400, width: 40, height: 40) }
|
||||||
let controller = try CanvasWindowController(
|
let controller = try CanvasWindowController(
|
||||||
@ -31,10 +31,10 @@ struct CanvasWindowSmokeTests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test func windowControllerShowsAndCloses() async throws {
|
@Test func windowControllerShowsAndCloses() async throws {
|
||||||
let root = FileManager.default.temporaryDirectory
|
let root = FileManager().temporaryDirectory
|
||||||
.appendingPathComponent("clawdbot-canvas-test-\(UUID().uuidString)")
|
.appendingPathComponent("clawdbot-canvas-test-\(UUID().uuidString)")
|
||||||
try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true)
|
try FileManager().createDirectory(at: root, withIntermediateDirectories: true)
|
||||||
defer { try? FileManager.default.removeItem(at: root) }
|
defer { try? FileManager().removeItem(at: root) }
|
||||||
|
|
||||||
let controller = try CanvasWindowController(
|
let controller = try CanvasWindowController(
|
||||||
sessionKey: "main",
|
sessionKey: "main",
|
||||||
|
|||||||
@ -6,7 +6,7 @@ import Testing
|
|||||||
struct ClawdbotConfigFileTests {
|
struct ClawdbotConfigFileTests {
|
||||||
@Test
|
@Test
|
||||||
func configPathRespectsEnvOverride() async {
|
func configPathRespectsEnvOverride() async {
|
||||||
let override = FileManager.default.temporaryDirectory
|
let override = FileManager().temporaryDirectory
|
||||||
.appendingPathComponent("clawdbot-config-\(UUID().uuidString)")
|
.appendingPathComponent("clawdbot-config-\(UUID().uuidString)")
|
||||||
.appendingPathComponent("clawdbot.json")
|
.appendingPathComponent("clawdbot.json")
|
||||||
.path
|
.path
|
||||||
@ -19,7 +19,7 @@ struct ClawdbotConfigFileTests {
|
|||||||
@MainActor
|
@MainActor
|
||||||
@Test
|
@Test
|
||||||
func remoteGatewayPortParsesAndMatchesHost() async {
|
func remoteGatewayPortParsesAndMatchesHost() async {
|
||||||
let override = FileManager.default.temporaryDirectory
|
let override = FileManager().temporaryDirectory
|
||||||
.appendingPathComponent("clawdbot-config-\(UUID().uuidString)")
|
.appendingPathComponent("clawdbot-config-\(UUID().uuidString)")
|
||||||
.appendingPathComponent("clawdbot.json")
|
.appendingPathComponent("clawdbot.json")
|
||||||
.path
|
.path
|
||||||
@ -42,7 +42,7 @@ struct ClawdbotConfigFileTests {
|
|||||||
@MainActor
|
@MainActor
|
||||||
@Test
|
@Test
|
||||||
func setRemoteGatewayUrlPreservesScheme() async {
|
func setRemoteGatewayUrlPreservesScheme() async {
|
||||||
let override = FileManager.default.temporaryDirectory
|
let override = FileManager().temporaryDirectory
|
||||||
.appendingPathComponent("clawdbot-config-\(UUID().uuidString)")
|
.appendingPathComponent("clawdbot-config-\(UUID().uuidString)")
|
||||||
.appendingPathComponent("clawdbot.json")
|
.appendingPathComponent("clawdbot.json")
|
||||||
.path
|
.path
|
||||||
@ -64,7 +64,7 @@ struct ClawdbotConfigFileTests {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
func stateDirOverrideSetsConfigPath() async {
|
func stateDirOverrideSetsConfigPath() async {
|
||||||
let dir = FileManager.default.temporaryDirectory
|
let dir = FileManager().temporaryDirectory
|
||||||
.appendingPathComponent("clawdbot-state-\(UUID().uuidString)", isDirectory: true)
|
.appendingPathComponent("clawdbot-state-\(UUID().uuidString)", isDirectory: true)
|
||||||
.path
|
.path
|
||||||
|
|
||||||
|
|||||||
@ -6,7 +6,7 @@ import Testing
|
|||||||
struct ClawdbotOAuthStoreTests {
|
struct ClawdbotOAuthStoreTests {
|
||||||
@Test
|
@Test
|
||||||
func returnsMissingWhenFileAbsent() {
|
func returnsMissingWhenFileAbsent() {
|
||||||
let url = FileManager.default.temporaryDirectory
|
let url = FileManager().temporaryDirectory
|
||||||
.appendingPathComponent("clawdbot-oauth-\(UUID().uuidString)")
|
.appendingPathComponent("clawdbot-oauth-\(UUID().uuidString)")
|
||||||
.appendingPathComponent("oauth.json")
|
.appendingPathComponent("oauth.json")
|
||||||
#expect(ClawdbotOAuthStore.anthropicOAuthStatus(at: url) == .missingFile)
|
#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)
|
.appendingPathComponent("clawdbot-oauth-\(UUID().uuidString)", isDirectory: true)
|
||||||
setenv(key, dir.path, 1)
|
setenv(key, dir.path, 1)
|
||||||
|
|
||||||
@ -85,9 +85,9 @@ struct ClawdbotOAuthStoreTests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func writeOAuthFile(_ json: [String: Any]) throws -> URL {
|
private func writeOAuthFile(_ json: [String: Any]) throws -> URL {
|
||||||
let dir = FileManager.default.temporaryDirectory
|
let dir = FileManager().temporaryDirectory
|
||||||
.appendingPathComponent("clawdbot-oauth-\(UUID().uuidString)", isDirectory: true)
|
.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 url = dir.appendingPathComponent("oauth.json")
|
||||||
let data = try JSONSerialization.data(withJSONObject: json, options: [.prettyPrinted, .sortedKeys])
|
let data = try JSONSerialization.data(withJSONObject: json, options: [.prettyPrinted, .sortedKeys])
|
||||||
|
|||||||
@ -12,16 +12,16 @@ import Testing
|
|||||||
private func makeTempDir() throws -> URL {
|
private func makeTempDir() throws -> URL {
|
||||||
let base = URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true)
|
let base = URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true)
|
||||||
let dir = base.appendingPathComponent(UUID().uuidString, 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
|
return dir
|
||||||
}
|
}
|
||||||
|
|
||||||
private func makeExec(at path: URL) throws {
|
private func makeExec(at path: URL) throws {
|
||||||
try FileManager.default.createDirectory(
|
try FileManager().createDirectory(
|
||||||
at: path.deletingLastPathComponent(),
|
at: path.deletingLastPathComponent(),
|
||||||
withIntermediateDirectories: true)
|
withIntermediateDirectories: true)
|
||||||
FileManager.default.createFile(atPath: path.path, contents: Data("echo ok\n".utf8))
|
FileManager().createFile(atPath: path.path, contents: Data("echo ok\n".utf8))
|
||||||
try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: path.path)
|
try FileManager().setAttributes([.posixPermissions: 0o755], ofItemAtPath: path.path)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test func prefersClawdbotBinary() async throws {
|
@Test func prefersClawdbotBinary() async throws {
|
||||||
@ -49,7 +49,7 @@ import Testing
|
|||||||
let scriptPath = tmp.appendingPathComponent("bin/clawdbot.js")
|
let scriptPath = tmp.appendingPathComponent("bin/clawdbot.js")
|
||||||
try self.makeExec(at: nodePath)
|
try self.makeExec(at: nodePath)
|
||||||
try "#!/bin/sh\necho v22.0.0\n".write(to: nodePath, atomically: true, encoding: .utf8)
|
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)
|
try self.makeExec(at: scriptPath)
|
||||||
|
|
||||||
let cmd = CommandResolver.clawdbotCommand(
|
let cmd = CommandResolver.clawdbotCommand(
|
||||||
|
|||||||
@ -32,7 +32,7 @@ import Testing
|
|||||||
}
|
}
|
||||||
|
|
||||||
private static func swiftFiles(under root: URL) throws -> [URL] {
|
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 {
|
guard let enumerator = fm.enumerator(at: root, includingPropertiesForKeys: [.isRegularFileKey]) else {
|
||||||
return []
|
return []
|
||||||
}
|
}
|
||||||
|
|||||||
@ -4,7 +4,7 @@ import Testing
|
|||||||
|
|
||||||
@Suite struct GatewayLaunchAgentManagerTests {
|
@Suite struct GatewayLaunchAgentManagerTests {
|
||||||
@Test func launchAgentPlistSnapshotParsesArgsAndEnv() throws {
|
@Test func launchAgentPlistSnapshotParsesArgsAndEnv() throws {
|
||||||
let url = FileManager.default.temporaryDirectory
|
let url = FileManager().temporaryDirectory
|
||||||
.appendingPathComponent("clawdbot-launchd-\(UUID().uuidString).plist")
|
.appendingPathComponent("clawdbot-launchd-\(UUID().uuidString).plist")
|
||||||
let plist: [String: Any] = [
|
let plist: [String: Any] = [
|
||||||
"ProgramArguments": ["clawdbot", "gateway-daemon", "--port", "18789", "--bind", "loopback"],
|
"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)
|
let data = try PropertyListSerialization.data(fromPropertyList: plist, format: .xml, options: 0)
|
||||||
try data.write(to: url, options: [.atomic])
|
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))
|
let snapshot = try #require(LaunchAgentPlist.snapshot(url: url))
|
||||||
#expect(snapshot.port == 18789)
|
#expect(snapshot.port == 18789)
|
||||||
@ -25,14 +25,14 @@ import Testing
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test func launchAgentPlistSnapshotAllowsMissingBind() throws {
|
@Test func launchAgentPlistSnapshotAllowsMissingBind() throws {
|
||||||
let url = FileManager.default.temporaryDirectory
|
let url = FileManager().temporaryDirectory
|
||||||
.appendingPathComponent("clawdbot-launchd-\(UUID().uuidString).plist")
|
.appendingPathComponent("clawdbot-launchd-\(UUID().uuidString).plist")
|
||||||
let plist: [String: Any] = [
|
let plist: [String: Any] = [
|
||||||
"ProgramArguments": ["clawdbot", "gateway-daemon", "--port", "18789"],
|
"ProgramArguments": ["clawdbot", "gateway-daemon", "--port", "18789"],
|
||||||
]
|
]
|
||||||
let data = try PropertyListSerialization.data(fromPropertyList: plist, format: .xml, options: 0)
|
let data = try PropertyListSerialization.data(fromPropertyList: plist, format: .xml, options: 0)
|
||||||
try data.write(to: url, options: [.atomic])
|
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))
|
let snapshot = try #require(LaunchAgentPlist.snapshot(url: url))
|
||||||
#expect(snapshot.port == 18789)
|
#expect(snapshot.port == 18789)
|
||||||
|
|||||||
@ -5,7 +5,7 @@ import Testing
|
|||||||
|
|
||||||
@Suite struct LogLocatorTests {
|
@Suite struct LogLocatorTests {
|
||||||
@Test func launchdGatewayLogPathEnsuresTmpDirExists() throws {
|
@Test func launchdGatewayLogPathEnsuresTmpDirExists() throws {
|
||||||
let fm = FileManager.default
|
let fm = FileManager()
|
||||||
let baseDir = URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true)
|
let baseDir = URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true)
|
||||||
let logDir = baseDir.appendingPathComponent("clawdbot-tests-\(UUID().uuidString)")
|
let logDir = baseDir.appendingPathComponent("clawdbot-tests-\(UUID().uuidString)")
|
||||||
|
|
||||||
|
|||||||
@ -77,9 +77,9 @@ struct LowCoverageHelperTests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test func pairedNodesStorePersists() async throws {
|
@Test func pairedNodesStorePersists() async throws {
|
||||||
let dir = FileManager.default.temporaryDirectory
|
let dir = FileManager().temporaryDirectory
|
||||||
.appendingPathComponent("paired-\(UUID().uuidString)", isDirectory: true)
|
.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 url = dir.appendingPathComponent("nodes.json")
|
||||||
let store = PairedNodesStore(fileURL: url)
|
let store = PairedNodesStore(fileURL: url)
|
||||||
await store.load()
|
await store.load()
|
||||||
@ -143,12 +143,12 @@ struct LowCoverageHelperTests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test @MainActor func canvasSchemeHandlerResolvesFilesAndErrors() throws {
|
@Test @MainActor func canvasSchemeHandlerResolvesFilesAndErrors() throws {
|
||||||
let root = FileManager.default.temporaryDirectory
|
let root = FileManager().temporaryDirectory
|
||||||
.appendingPathComponent("canvas-\(UUID().uuidString)", isDirectory: true)
|
.appendingPathComponent("canvas-\(UUID().uuidString)", isDirectory: true)
|
||||||
defer { try? FileManager.default.removeItem(at: root) }
|
defer { try? FileManager().removeItem(at: root) }
|
||||||
try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true)
|
try FileManager().createDirectory(at: root, withIntermediateDirectories: true)
|
||||||
let session = root.appendingPathComponent("main", isDirectory: 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")
|
let index = session.appendingPathComponent("index.html")
|
||||||
try "<h1>Hello</h1>".write(to: index, atomically: true, encoding: .utf8)
|
try "<h1>Hello</h1>".write(to: index, atomically: true, encoding: .utf8)
|
||||||
|
|||||||
@ -59,7 +59,7 @@ struct MacNodeRuntimeTests {
|
|||||||
includeAudio: Bool?,
|
includeAudio: Bool?,
|
||||||
outPath: String?) async throws -> (path: String, hasAudio: 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")
|
.appendingPathComponent("clawdbot-test-screen-record-\(UUID().uuidString).mp4")
|
||||||
try Data("ok".utf8).write(to: url)
|
try Data("ok".utf8).write(to: url)
|
||||||
return (path: url.path, hasAudio: false)
|
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")
|
.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)
|
try src.write(to: tmp, atomically: true, encoding: .utf8)
|
||||||
|
|
||||||
let choices = try await ModelCatalogLoader.load(from: tmp.path)
|
let choices = try await ModelCatalogLoader.load(from: tmp.path)
|
||||||
@ -42,9 +42,9 @@ struct ModelCatalogLoaderTests {
|
|||||||
@Test
|
@Test
|
||||||
func loadWithNoExportReturnsEmptyChoices() async throws {
|
func loadWithNoExportReturnsEmptyChoices() async throws {
|
||||||
let src = "const NOPE = 1;"
|
let src = "const NOPE = 1;"
|
||||||
let tmp = FileManager.default.temporaryDirectory
|
let tmp = FileManager().temporaryDirectory
|
||||||
.appendingPathComponent("models-\(UUID().uuidString).ts")
|
.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)
|
try src.write(to: tmp, atomically: true, encoding: .utf8)
|
||||||
|
|
||||||
let choices = try await ModelCatalogLoader.load(from: tmp.path)
|
let choices = try await ModelCatalogLoader.load(from: tmp.path)
|
||||||
|
|||||||
@ -6,16 +6,16 @@ import Testing
|
|||||||
private func makeTempDir() throws -> URL {
|
private func makeTempDir() throws -> URL {
|
||||||
let base = URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true)
|
let base = URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true)
|
||||||
let dir = base.appendingPathComponent(UUID().uuidString, 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
|
return dir
|
||||||
}
|
}
|
||||||
|
|
||||||
private func makeExec(at path: URL) throws {
|
private func makeExec(at path: URL) throws {
|
||||||
try FileManager.default.createDirectory(
|
try FileManager().createDirectory(
|
||||||
at: path.deletingLastPathComponent(),
|
at: path.deletingLastPathComponent(),
|
||||||
withIntermediateDirectories: true)
|
withIntermediateDirectories: true)
|
||||||
FileManager.default.createFile(atPath: path.path, contents: Data("echo ok\n".utf8))
|
FileManager().createFile(atPath: path.path, contents: Data("echo ok\n".utf8))
|
||||||
try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: path.path)
|
try FileManager().setAttributes([.posixPermissions: 0o755], ofItemAtPath: path.path)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test func fnmNodeBinsPreferNewestInstalledVersion() throws {
|
@Test func fnmNodeBinsPreferNewestInstalledVersion() throws {
|
||||||
@ -37,7 +37,7 @@ import Testing
|
|||||||
let home = try self.makeTempDir()
|
let home = try self.makeTempDir()
|
||||||
let missingNodeBin = home
|
let missingNodeBin = home
|
||||||
.appendingPathComponent(".local/share/fnm/node-versions/v99.0.0/installation/bin")
|
.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)
|
let bins = CommandResolver._testNodeManagerBinPaths(home: home)
|
||||||
#expect(!bins.contains(missingNodeBin.path))
|
#expect(!bins.contains(missingNodeBin.path))
|
||||||
|
|||||||
@ -6,10 +6,10 @@ import Testing
|
|||||||
private func makeTempExecutable(contents: String) throws -> URL {
|
private func makeTempExecutable(contents: String) throws -> URL {
|
||||||
let dir = URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true)
|
let dir = URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true)
|
||||||
.appendingPathComponent(UUID().uuidString, 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")
|
let path = dir.appendingPathComponent("node")
|
||||||
try contents.write(to: path, atomically: true, encoding: .utf8)
|
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
|
return path
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -109,7 +109,7 @@ enum TestIsolation {
|
|||||||
}
|
}
|
||||||
|
|
||||||
nonisolated static func tempConfigPath() -> String {
|
nonisolated static func tempConfigPath() -> String {
|
||||||
FileManager.default.temporaryDirectory
|
FileManager().temporaryDirectory
|
||||||
.appendingPathComponent("clawdbot-test-config-\(UUID().uuidString).json")
|
.appendingPathComponent("clawdbot-test-config-\(UUID().uuidString).json")
|
||||||
.path
|
.path
|
||||||
}
|
}
|
||||||
|
|||||||
@ -47,17 +47,17 @@ import Testing
|
|||||||
.appendingPathComponent(UUID().uuidString, isDirectory: true)
|
.appendingPathComponent(UUID().uuidString, isDirectory: true)
|
||||||
let dist = tmp.appendingPathComponent("dist/index.js")
|
let dist = tmp.appendingPathComponent("dist/index.js")
|
||||||
let bin = tmp.appendingPathComponent("bin/clawdbot.js")
|
let bin = tmp.appendingPathComponent("bin/clawdbot.js")
|
||||||
try FileManager.default.createDirectory(at: dist.deletingLastPathComponent(), withIntermediateDirectories: true)
|
try FileManager().createDirectory(at: dist.deletingLastPathComponent(), withIntermediateDirectories: true)
|
||||||
try FileManager.default.createDirectory(at: bin.deletingLastPathComponent(), withIntermediateDirectories: true)
|
try FileManager().createDirectory(at: bin.deletingLastPathComponent(), withIntermediateDirectories: true)
|
||||||
FileManager.default.createFile(atPath: dist.path, contents: Data())
|
FileManager().createFile(atPath: dist.path, contents: Data())
|
||||||
FileManager.default.createFile(atPath: bin.path, contents: Data())
|
FileManager().createFile(atPath: bin.path, contents: Data())
|
||||||
|
|
||||||
let entry = CommandResolver.gatewayEntrypoint(in: tmp)
|
let entry = CommandResolver.gatewayEntrypoint(in: tmp)
|
||||||
#expect(entry == dist.path)
|
#expect(entry == dist.path)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test func logLocatorPicksNewestLogFile() throws {
|
@Test func logLocatorPicksNewestLogFile() throws {
|
||||||
let fm = FileManager.default
|
let fm = FileManager()
|
||||||
let dir = URL(fileURLWithPath: "/tmp/clawdbot", isDirectory: true)
|
let dir = URL(fileURLWithPath: "/tmp/clawdbot", isDirectory: true)
|
||||||
try? fm.createDirectory(at: dir, withIntermediateDirectories: true)
|
try? fm.createDirectory(at: dir, withIntermediateDirectories: true)
|
||||||
|
|
||||||
|
|||||||
@ -2,7 +2,7 @@ import Foundation
|
|||||||
|
|
||||||
public enum ClawdbotNodeStorage {
|
public enum ClawdbotNodeStorage {
|
||||||
public static func appSupportDir() throws -> URL {
|
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 {
|
guard let base else {
|
||||||
throw NSError(domain: "ClawdbotNodeStorage", code: 1, userInfo: [
|
throw NSError(domain: "ClawdbotNodeStorage", code: 1, userInfo: [
|
||||||
NSLocalizedDescriptionKey: "Application Support directory unavailable",
|
NSLocalizedDescriptionKey: "Application Support directory unavailable",
|
||||||
@ -19,7 +19,7 @@ public enum ClawdbotNodeStorage {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public static func cachesDir() throws -> URL {
|
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 {
|
guard let base else {
|
||||||
throw NSError(domain: "ClawdbotNodeStorage", code: 2, userInfo: [
|
throw NSError(domain: "ClawdbotNodeStorage", code: 2, userInfo: [
|
||||||
NSLocalizedDescriptionKey: "Caches directory unavailable",
|
NSLocalizedDescriptionKey: "Caches directory unavailable",
|
||||||
|
|||||||
122
docs/experiments/plans/openresponses-gateway.md
Normal file
122
docs/experiments/plans/openresponses-gateway.md
Normal file
@ -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`.
|
||||||
@ -2669,6 +2669,7 @@ Notes:
|
|||||||
- `clawdbot gateway` refuses to start unless `gateway.mode` is set to `local` (or you pass the override flag).
|
- `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).
|
- `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`.
|
- 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`.
|
- 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`).
|
- 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).
|
- The onboarding wizard generates a gateway token by default (even on loopback).
|
||||||
|
|||||||
@ -29,6 +29,7 @@ pnpm gateway:watch
|
|||||||
- Binds WebSocket control plane to `127.0.0.1:<port>` (default 18789).
|
- 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.
|
- The same port also serves HTTP (control UI, hooks, A2UI). Single-port multiplex.
|
||||||
- OpenAI Chat Completions (HTTP): [`/v1/chat/completions`](/gateway/openai-http-api).
|
- 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`.
|
- 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.
|
- 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.
|
- Pass `--verbose` to mirror debug logging (handshakes, req/res, events) from the log file into stdio when troubleshooting.
|
||||||
|
|||||||
277
docs/gateway/openresponses-http-api.md
Normal file
277
docs/gateway/openresponses-http-api.md
Normal file
@ -0,0 +1,277 @@
|
|||||||
|
---
|
||||||
|
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.
|
||||||
|
|
||||||
|
## 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 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"
|
||||||
|
}'
|
||||||
|
```
|
||||||
@ -155,6 +155,7 @@
|
|||||||
"@mariozechner/pi-coding-agent": "^0.46.0",
|
"@mariozechner/pi-coding-agent": "^0.46.0",
|
||||||
"@mariozechner/pi-tui": "^0.46.0",
|
"@mariozechner/pi-tui": "^0.46.0",
|
||||||
"@mozilla/readability": "^0.6.0",
|
"@mozilla/readability": "^0.6.0",
|
||||||
|
"@napi-rs/canvas": "^0.1.88",
|
||||||
"@sinclair/typebox": "0.34.47",
|
"@sinclair/typebox": "0.34.47",
|
||||||
"@slack/bolt": "^4.6.0",
|
"@slack/bolt": "^4.6.0",
|
||||||
"@slack/web-api": "^7.13.0",
|
"@slack/web-api": "^7.13.0",
|
||||||
@ -181,6 +182,7 @@
|
|||||||
"long": "5.3.2",
|
"long": "5.3.2",
|
||||||
"markdown-it": "^14.1.0",
|
"markdown-it": "^14.1.0",
|
||||||
"osc-progress": "^0.2.0",
|
"osc-progress": "^0.2.0",
|
||||||
|
"pdfjs-dist": "^5.4.530",
|
||||||
"playwright-core": "1.57.0",
|
"playwright-core": "1.57.0",
|
||||||
"proper-lockfile": "^4.1.2",
|
"proper-lockfile": "^4.1.2",
|
||||||
"qrcode-terminal": "^0.12.0",
|
"qrcode-terminal": "^0.12.0",
|
||||||
|
|||||||
131
pnpm-lock.yaml
generated
131
pnpm-lock.yaml
generated
@ -49,6 +49,9 @@ importers:
|
|||||||
'@mozilla/readability':
|
'@mozilla/readability':
|
||||||
specifier: ^0.6.0
|
specifier: ^0.6.0
|
||||||
version: 0.6.0
|
version: 0.6.0
|
||||||
|
'@napi-rs/canvas':
|
||||||
|
specifier: ^0.1.88
|
||||||
|
version: 0.1.88
|
||||||
'@sinclair/typebox':
|
'@sinclair/typebox':
|
||||||
specifier: 0.34.47
|
specifier: 0.34.47
|
||||||
version: 0.34.47
|
version: 0.34.47
|
||||||
@ -127,6 +130,9 @@ importers:
|
|||||||
osc-progress:
|
osc-progress:
|
||||||
specifier: ^0.2.0
|
specifier: ^0.2.0
|
||||||
version: 0.2.0
|
version: 0.2.0
|
||||||
|
pdfjs-dist:
|
||||||
|
specifier: ^5.4.530
|
||||||
|
version: 5.4.530
|
||||||
playwright-core:
|
playwright-core:
|
||||||
specifier: 1.57.0
|
specifier: 1.57.0
|
||||||
version: 1.57.0
|
version: 1.57.0
|
||||||
@ -1205,6 +1211,76 @@ packages:
|
|||||||
resolution: {integrity: sha512-juG5VWh4qAivzTAeMzvY9xs9HY5rAcr2E4I7tiSSCokRFi7XIZCAu92ZkSTsIj1OPceCifL3cpfteP3pDT9/QQ==}
|
resolution: {integrity: sha512-juG5VWh4qAivzTAeMzvY9xs9HY5rAcr2E4I7tiSSCokRFi7XIZCAu92ZkSTsIj1OPceCifL3cpfteP3pDT9/QQ==}
|
||||||
engines: {node: '>=14.0.0'}
|
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':
|
'@napi-rs/wasm-runtime@1.1.1':
|
||||||
resolution: {integrity: sha512-p64ah1M1ld8xjWv3qbvFwHiFVWrq1yFvV4f7w+mzaqiR4IlSgkqhcRdHwsGgomwzBH51sRY4NEowLxnaBjcW/A==}
|
resolution: {integrity: sha512-p64ah1M1ld8xjWv3qbvFwHiFVWrq1yFvV4f7w+mzaqiR4IlSgkqhcRdHwsGgomwzBH51sRY4NEowLxnaBjcW/A==}
|
||||||
|
|
||||||
@ -3756,6 +3832,10 @@ packages:
|
|||||||
pathe@2.0.3:
|
pathe@2.0.3:
|
||||||
resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==}
|
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:
|
picocolors@1.1.1:
|
||||||
resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
|
resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
|
||||||
|
|
||||||
@ -5671,6 +5751,53 @@ snapshots:
|
|||||||
|
|
||||||
'@mozilla/readability@0.6.0': {}
|
'@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':
|
'@napi-rs/wasm-runtime@1.1.1':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@emnapi/core': 1.8.1
|
'@emnapi/core': 1.8.1
|
||||||
@ -8462,6 +8589,10 @@ snapshots:
|
|||||||
|
|
||||||
pathe@2.0.3: {}
|
pathe@2.0.3: {}
|
||||||
|
|
||||||
|
pdfjs-dist@5.4.530:
|
||||||
|
optionalDependencies:
|
||||||
|
'@napi-rs/canvas': 0.1.88
|
||||||
|
|
||||||
picocolors@1.1.1: {}
|
picocolors@1.1.1: {}
|
||||||
|
|
||||||
picomatch@2.3.1: {}
|
picomatch@2.3.1: {}
|
||||||
|
|||||||
@ -45,6 +45,7 @@ export async function runCliAgent(params: {
|
|||||||
timeoutMs: number;
|
timeoutMs: number;
|
||||||
runId: string;
|
runId: string;
|
||||||
extraSystemPrompt?: string;
|
extraSystemPrompt?: string;
|
||||||
|
streamParams?: import("../commands/agent/types.js").AgentStreamParams;
|
||||||
ownerNumbers?: string[];
|
ownerNumbers?: string[];
|
||||||
cliSessionId?: string;
|
cliSessionId?: string;
|
||||||
images?: ImageContent[];
|
images?: ImageContent[];
|
||||||
|
|||||||
@ -63,13 +63,21 @@ export function applyExtraParamsToAgent(
|
|||||||
cfg: ClawdbotConfig | undefined,
|
cfg: ClawdbotConfig | undefined,
|
||||||
provider: string,
|
provider: string,
|
||||||
modelId: string,
|
modelId: string,
|
||||||
|
extraParamsOverride?: Record<string, unknown>,
|
||||||
): void {
|
): void {
|
||||||
const extraParams = resolveExtraParams({
|
const extraParams = resolveExtraParams({
|
||||||
cfg,
|
cfg,
|
||||||
provider,
|
provider,
|
||||||
modelId,
|
modelId,
|
||||||
});
|
});
|
||||||
const wrappedStreamFn = createStreamFnWithExtraParams(agent.streamFn, extraParams);
|
const override =
|
||||||
|
extraParamsOverride && Object.keys(extraParamsOverride).length > 0
|
||||||
|
? Object.fromEntries(
|
||||||
|
Object.entries(extraParamsOverride).filter(([, value]) => value !== undefined),
|
||||||
|
)
|
||||||
|
: undefined;
|
||||||
|
const merged = Object.assign({}, extraParams, override);
|
||||||
|
const wrappedStreamFn = createStreamFnWithExtraParams(agent.streamFn, merged);
|
||||||
|
|
||||||
if (wrappedStreamFn) {
|
if (wrappedStreamFn) {
|
||||||
log.debug(`applying extraParams to agent streamFn for ${provider}/${modelId}`);
|
log.debug(`applying extraParams to agent streamFn for ${provider}/${modelId}`);
|
||||||
|
|||||||
@ -239,6 +239,7 @@ export async function runEmbeddedPiAgent(
|
|||||||
onToolResult: params.onToolResult,
|
onToolResult: params.onToolResult,
|
||||||
onAgentEvent: params.onAgentEvent,
|
onAgentEvent: params.onAgentEvent,
|
||||||
extraSystemPrompt: params.extraSystemPrompt,
|
extraSystemPrompt: params.extraSystemPrompt,
|
||||||
|
streamParams: params.streamParams,
|
||||||
ownerNumbers: params.ownerNumbers,
|
ownerNumbers: params.ownerNumbers,
|
||||||
enforceFinalTag: params.enforceFinalTag,
|
enforceFinalTag: params.enforceFinalTag,
|
||||||
});
|
});
|
||||||
@ -482,6 +483,17 @@ export async function runEmbeddedPiAgent(
|
|||||||
agentMeta,
|
agentMeta,
|
||||||
aborted,
|
aborted,
|
||||||
systemPromptReport: attempt.systemPromptReport,
|
systemPromptReport: attempt.systemPromptReport,
|
||||||
|
// Handle client tool calls (OpenResponses hosted tools)
|
||||||
|
stopReason: attempt.clientToolCall ? "tool_calls" : undefined,
|
||||||
|
pendingToolCalls: attempt.clientToolCall
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
id: `call_${Date.now()}`,
|
||||||
|
name: attempt.clientToolCall.name,
|
||||||
|
arguments: JSON.stringify(attempt.clientToolCall.params),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: undefined,
|
||||||
},
|
},
|
||||||
didSendViaMessagingTool: attempt.didSendViaMessagingTool,
|
didSendViaMessagingTool: attempt.didSendViaMessagingTool,
|
||||||
messagingToolSentTexts: attempt.messagingToolSentTexts,
|
messagingToolSentTexts: attempt.messagingToolSentTexts,
|
||||||
|
|||||||
@ -64,6 +64,7 @@ import { prewarmSessionFile, trackSessionManagerAccess } from "../session-manage
|
|||||||
import { prepareSessionManagerForRun } from "../session-manager-init.js";
|
import { prepareSessionManagerForRun } from "../session-manager-init.js";
|
||||||
import { buildEmbeddedSystemPrompt, createSystemPromptOverride } from "../system-prompt.js";
|
import { buildEmbeddedSystemPrompt, createSystemPromptOverride } from "../system-prompt.js";
|
||||||
import { splitSdkTools } from "../tool-split.js";
|
import { splitSdkTools } from "../tool-split.js";
|
||||||
|
import { toClientToolDefinitions } from "../../pi-tool-definition-adapter.js";
|
||||||
import { buildSystemPromptParams } from "../../system-prompt-params.js";
|
import { buildSystemPromptParams } from "../../system-prompt-params.js";
|
||||||
import { describeUnknownError, mapThinkingLevel } from "../utils.js";
|
import { describeUnknownError, mapThinkingLevel } from "../utils.js";
|
||||||
import { resolveSandboxRuntimeStatus } from "../../sandbox/runtime-status.js";
|
import { resolveSandboxRuntimeStatus } from "../../sandbox/runtime-status.js";
|
||||||
@ -314,6 +315,16 @@ export async function runEmbeddedAttempt(
|
|||||||
sandboxEnabled: !!sandbox?.enabled,
|
sandboxEnabled: !!sandbox?.enabled,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Add client tools (OpenResponses hosted tools) to customTools
|
||||||
|
let clientToolCallDetected: { name: string; params: Record<string, unknown> } | null = null;
|
||||||
|
const clientToolDefs = params.clientTools
|
||||||
|
? toClientToolDefinitions(params.clientTools, (toolName, toolParams) => {
|
||||||
|
clientToolCallDetected = { name: toolName, params: toolParams };
|
||||||
|
})
|
||||||
|
: [];
|
||||||
|
|
||||||
|
const allCustomTools = [...customTools, ...clientToolDefs];
|
||||||
|
|
||||||
({ session } = await createAgentSession({
|
({ session } = await createAgentSession({
|
||||||
cwd: resolvedWorkspace,
|
cwd: resolvedWorkspace,
|
||||||
agentDir,
|
agentDir,
|
||||||
@ -323,7 +334,7 @@ export async function runEmbeddedAttempt(
|
|||||||
thinkingLevel: mapThinkingLevel(params.thinkLevel),
|
thinkingLevel: mapThinkingLevel(params.thinkLevel),
|
||||||
systemPrompt,
|
systemPrompt,
|
||||||
tools: builtInTools,
|
tools: builtInTools,
|
||||||
customTools,
|
customTools: allCustomTools,
|
||||||
sessionManager,
|
sessionManager,
|
||||||
settingsManager,
|
settingsManager,
|
||||||
skills: [],
|
skills: [],
|
||||||
@ -338,7 +349,13 @@ export async function runEmbeddedAttempt(
|
|||||||
// Force a stable streamFn reference so vitest can reliably mock @mariozechner/pi-ai.
|
// Force a stable streamFn reference so vitest can reliably mock @mariozechner/pi-ai.
|
||||||
activeSession.agent.streamFn = streamSimple;
|
activeSession.agent.streamFn = streamSimple;
|
||||||
|
|
||||||
applyExtraParamsToAgent(activeSession.agent, params.config, params.provider, params.modelId);
|
applyExtraParamsToAgent(
|
||||||
|
activeSession.agent,
|
||||||
|
params.config,
|
||||||
|
params.provider,
|
||||||
|
params.modelId,
|
||||||
|
params.streamParams,
|
||||||
|
);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const prior = await sanitizeSessionHistory({
|
const prior = await sanitizeSessionHistory({
|
||||||
@ -681,6 +698,8 @@ export async function runEmbeddedAttempt(
|
|||||||
cloudCodeAssistFormatError: Boolean(
|
cloudCodeAssistFormatError: Boolean(
|
||||||
lastAssistant?.errorMessage && isCloudCodeAssistFormatError(lastAssistant.errorMessage),
|
lastAssistant?.errorMessage && isCloudCodeAssistFormatError(lastAssistant.errorMessage),
|
||||||
),
|
),
|
||||||
|
// Client tool call detected (OpenResponses hosted tools)
|
||||||
|
clientToolCall: clientToolCallDetected ?? undefined,
|
||||||
};
|
};
|
||||||
} finally {
|
} finally {
|
||||||
// Always tear down the session (and release the lock) before we leave this attempt.
|
// Always tear down the session (and release the lock) before we leave this attempt.
|
||||||
|
|||||||
@ -1,11 +1,22 @@
|
|||||||
import type { ImageContent } from "@mariozechner/pi-ai";
|
import type { ImageContent } from "@mariozechner/pi-ai";
|
||||||
import type { ReasoningLevel, ThinkLevel, VerboseLevel } from "../../../auto-reply/thinking.js";
|
import type { ReasoningLevel, ThinkLevel, VerboseLevel } from "../../../auto-reply/thinking.js";
|
||||||
import type { ClawdbotConfig } from "../../../config/config.js";
|
import type { ClawdbotConfig } from "../../../config/config.js";
|
||||||
|
import type { AgentStreamParams } from "../../../commands/agent/types.js";
|
||||||
import type { enqueueCommand } from "../../../process/command-queue.js";
|
import type { enqueueCommand } from "../../../process/command-queue.js";
|
||||||
import type { ExecElevatedDefaults, ExecToolDefaults } from "../../bash-tools.js";
|
import type { ExecElevatedDefaults, ExecToolDefaults } from "../../bash-tools.js";
|
||||||
import type { BlockReplyChunking, ToolResultFormat } from "../../pi-embedded-subscribe.js";
|
import type { BlockReplyChunking, ToolResultFormat } from "../../pi-embedded-subscribe.js";
|
||||||
import type { SkillSnapshot } from "../../skills.js";
|
import type { SkillSnapshot } from "../../skills.js";
|
||||||
|
|
||||||
|
// Simplified tool definition for client-provided tools (OpenResponses hosted tools)
|
||||||
|
export type ClientToolDefinition = {
|
||||||
|
type: "function";
|
||||||
|
function: {
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
parameters?: Record<string, unknown>;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
export type RunEmbeddedPiAgentParams = {
|
export type RunEmbeddedPiAgentParams = {
|
||||||
sessionId: string;
|
sessionId: string;
|
||||||
sessionKey?: string;
|
sessionKey?: string;
|
||||||
@ -27,6 +38,8 @@ export type RunEmbeddedPiAgentParams = {
|
|||||||
skillsSnapshot?: SkillSnapshot;
|
skillsSnapshot?: SkillSnapshot;
|
||||||
prompt: string;
|
prompt: string;
|
||||||
images?: ImageContent[];
|
images?: ImageContent[];
|
||||||
|
/** Optional client-provided tools (OpenResponses hosted tools). */
|
||||||
|
clientTools?: ClientToolDefinition[];
|
||||||
provider?: string;
|
provider?: string;
|
||||||
model?: string;
|
model?: string;
|
||||||
authProfileId?: string;
|
authProfileId?: string;
|
||||||
@ -58,6 +71,7 @@ export type RunEmbeddedPiAgentParams = {
|
|||||||
lane?: string;
|
lane?: string;
|
||||||
enqueue?: typeof enqueueCommand;
|
enqueue?: typeof enqueueCommand;
|
||||||
extraSystemPrompt?: string;
|
extraSystemPrompt?: string;
|
||||||
|
streamParams?: AgentStreamParams;
|
||||||
ownerNumbers?: string[];
|
ownerNumbers?: string[];
|
||||||
enforceFinalTag?: boolean;
|
enforceFinalTag?: boolean;
|
||||||
};
|
};
|
||||||
|
|||||||
@ -4,11 +4,13 @@ import type { discoverAuthStorage, discoverModels } from "@mariozechner/pi-codin
|
|||||||
|
|
||||||
import type { ReasoningLevel, ThinkLevel, VerboseLevel } from "../../../auto-reply/thinking.js";
|
import type { ReasoningLevel, ThinkLevel, VerboseLevel } from "../../../auto-reply/thinking.js";
|
||||||
import type { ClawdbotConfig } from "../../../config/config.js";
|
import type { ClawdbotConfig } from "../../../config/config.js";
|
||||||
|
import type { AgentStreamParams } from "../../../commands/agent/types.js";
|
||||||
import type { ExecElevatedDefaults, ExecToolDefaults } from "../../bash-tools.js";
|
import type { ExecElevatedDefaults, ExecToolDefaults } from "../../bash-tools.js";
|
||||||
import type { MessagingToolSend } from "../../pi-embedded-messaging.js";
|
import type { MessagingToolSend } from "../../pi-embedded-messaging.js";
|
||||||
import type { BlockReplyChunking, ToolResultFormat } from "../../pi-embedded-subscribe.js";
|
import type { BlockReplyChunking, ToolResultFormat } from "../../pi-embedded-subscribe.js";
|
||||||
import type { SkillSnapshot } from "../../skills.js";
|
import type { SkillSnapshot } from "../../skills.js";
|
||||||
import type { SessionSystemPromptReport } from "../../../config/sessions/types.js";
|
import type { SessionSystemPromptReport } from "../../../config/sessions/types.js";
|
||||||
|
import type { ClientToolDefinition } from "./params.js";
|
||||||
|
|
||||||
type AuthStorage = ReturnType<typeof discoverAuthStorage>;
|
type AuthStorage = ReturnType<typeof discoverAuthStorage>;
|
||||||
type ModelRegistry = ReturnType<typeof discoverModels>;
|
type ModelRegistry = ReturnType<typeof discoverModels>;
|
||||||
@ -30,6 +32,8 @@ export type EmbeddedRunAttemptParams = {
|
|||||||
skillsSnapshot?: SkillSnapshot;
|
skillsSnapshot?: SkillSnapshot;
|
||||||
prompt: string;
|
prompt: string;
|
||||||
images?: ImageContent[];
|
images?: ImageContent[];
|
||||||
|
/** Optional client-provided tools (OpenResponses hosted tools). */
|
||||||
|
clientTools?: ClientToolDefinition[];
|
||||||
provider: string;
|
provider: string;
|
||||||
modelId: string;
|
modelId: string;
|
||||||
model: Model<Api>;
|
model: Model<Api>;
|
||||||
@ -60,6 +64,7 @@ export type EmbeddedRunAttemptParams = {
|
|||||||
onToolResult?: (payload: { text?: string; mediaUrls?: string[] }) => void | Promise<void>;
|
onToolResult?: (payload: { text?: string; mediaUrls?: string[] }) => void | Promise<void>;
|
||||||
onAgentEvent?: (evt: { stream: string; data: Record<string, unknown> }) => void;
|
onAgentEvent?: (evt: { stream: string; data: Record<string, unknown> }) => void;
|
||||||
extraSystemPrompt?: string;
|
extraSystemPrompt?: string;
|
||||||
|
streamParams?: AgentStreamParams;
|
||||||
ownerNumbers?: string[];
|
ownerNumbers?: string[];
|
||||||
enforceFinalTag?: boolean;
|
enforceFinalTag?: boolean;
|
||||||
};
|
};
|
||||||
@ -79,4 +84,6 @@ export type EmbeddedRunAttemptResult = {
|
|||||||
messagingToolSentTexts: string[];
|
messagingToolSentTexts: string[];
|
||||||
messagingToolSentTargets: MessagingToolSend[];
|
messagingToolSentTargets: MessagingToolSend[];
|
||||||
cloudCodeAssistFormatError: boolean;
|
cloudCodeAssistFormatError: boolean;
|
||||||
|
/** Client tool call detected (OpenResponses hosted tools). */
|
||||||
|
clientToolCall?: { name: string; params: Record<string, unknown> };
|
||||||
};
|
};
|
||||||
|
|||||||
@ -23,6 +23,14 @@ export type EmbeddedPiRunMeta = {
|
|||||||
kind: "context_overflow" | "compaction_failure" | "role_ordering";
|
kind: "context_overflow" | "compaction_failure" | "role_ordering";
|
||||||
message: string;
|
message: string;
|
||||||
};
|
};
|
||||||
|
/** Stop reason for the agent run (e.g., "completed", "tool_calls"). */
|
||||||
|
stopReason?: string;
|
||||||
|
/** Pending tool calls when stopReason is "tool_calls". */
|
||||||
|
pendingToolCalls?: Array<{
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
arguments: string;
|
||||||
|
}>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type EmbeddedPiRunResult = {
|
export type EmbeddedPiRunResult = {
|
||||||
|
|||||||
@ -4,6 +4,7 @@ import type {
|
|||||||
AgentToolUpdateCallback,
|
AgentToolUpdateCallback,
|
||||||
} from "@mariozechner/pi-agent-core";
|
} from "@mariozechner/pi-agent-core";
|
||||||
import type { ToolDefinition } from "@mariozechner/pi-coding-agent";
|
import type { ToolDefinition } from "@mariozechner/pi-coding-agent";
|
||||||
|
import type { ClientToolDefinition } from "./pi-embedded-runner/run/params.js";
|
||||||
import { logDebug, logError } from "../logger.js";
|
import { logDebug, logError } from "../logger.js";
|
||||||
import { normalizeToolName } from "./tool-policy.js";
|
import { normalizeToolName } from "./tool-policy.js";
|
||||||
import { jsonResult } from "./tools/common.js";
|
import { jsonResult } from "./tools/common.js";
|
||||||
@ -65,3 +66,38 @@ export function toToolDefinitions(tools: AnyAgentTool[]): ToolDefinition[] {
|
|||||||
} satisfies ToolDefinition;
|
} satisfies ToolDefinition;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Convert client tools (OpenResponses hosted tools) to ToolDefinition format
|
||||||
|
// These tools are intercepted to return a "pending" result instead of executing
|
||||||
|
export function toClientToolDefinitions(
|
||||||
|
tools: ClientToolDefinition[],
|
||||||
|
onClientToolCall?: (toolName: string, params: Record<string, unknown>) => void,
|
||||||
|
): ToolDefinition[] {
|
||||||
|
return tools.map((tool) => {
|
||||||
|
const func = tool.function;
|
||||||
|
return {
|
||||||
|
name: func.name,
|
||||||
|
label: func.name,
|
||||||
|
description: func.description ?? "",
|
||||||
|
parameters: func.parameters as any,
|
||||||
|
execute: async (
|
||||||
|
toolCallId,
|
||||||
|
params,
|
||||||
|
_onUpdate: AgentToolUpdateCallback<unknown> | undefined,
|
||||||
|
_ctx,
|
||||||
|
_signal,
|
||||||
|
): Promise<AgentToolResult<unknown>> => {
|
||||||
|
// Notify handler that a client tool was called
|
||||||
|
if (onClientToolCall) {
|
||||||
|
onClientToolCall(func.name, params as Record<string, unknown>);
|
||||||
|
}
|
||||||
|
// Return a pending result - the client will execute this tool
|
||||||
|
return jsonResult({
|
||||||
|
status: "pending",
|
||||||
|
tool: func.name,
|
||||||
|
message: "Tool execution delegated to client",
|
||||||
|
});
|
||||||
|
},
|
||||||
|
} satisfies ToolDefinition;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@ -1 +1 @@
|
|||||||
c51e9080b032ccb0dd153452854f2ffdaca8e1db14d7b98ed56cca8f0f1a5257
|
2d9d05442e500250f72da79cc23fd2a688d8d44e8a2a0f40dc0401375f96ef8f
|
||||||
|
|||||||
@ -396,6 +396,7 @@ export async function agentCommand(
|
|||||||
extraSystemPrompt: opts.extraSystemPrompt,
|
extraSystemPrompt: opts.extraSystemPrompt,
|
||||||
cliSessionId,
|
cliSessionId,
|
||||||
images: opts.images,
|
images: opts.images,
|
||||||
|
streamParams: opts.streamParams,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
const authProfileId =
|
const authProfileId =
|
||||||
@ -415,6 +416,7 @@ export async function agentCommand(
|
|||||||
skillsSnapshot,
|
skillsSnapshot,
|
||||||
prompt: body,
|
prompt: body,
|
||||||
images: opts.images,
|
images: opts.images,
|
||||||
|
clientTools: opts.clientTools,
|
||||||
provider: providerOverride,
|
provider: providerOverride,
|
||||||
model: modelOverride,
|
model: modelOverride,
|
||||||
authProfileId,
|
authProfileId,
|
||||||
@ -428,6 +430,7 @@ export async function agentCommand(
|
|||||||
lane: opts.lane,
|
lane: opts.lane,
|
||||||
abortSignal: opts.abortSignal,
|
abortSignal: opts.abortSignal,
|
||||||
extraSystemPrompt: opts.extraSystemPrompt,
|
extraSystemPrompt: opts.extraSystemPrompt,
|
||||||
|
streamParams: opts.streamParams,
|
||||||
agentDir,
|
agentDir,
|
||||||
onAgentEvent: (evt) => {
|
onAgentEvent: (evt) => {
|
||||||
if (
|
if (
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
import type { ChannelOutboundTargetMode } from "../../channels/plugins/types.js";
|
import type { ChannelOutboundTargetMode } from "../../channels/plugins/types.js";
|
||||||
|
import type { ClientToolDefinition } from "../../agents/pi-embedded-runner/run/params.js";
|
||||||
|
|
||||||
/** Image content block for Claude API multimodal messages. */
|
/** Image content block for Claude API multimodal messages. */
|
||||||
export type ImageContent = {
|
export type ImageContent = {
|
||||||
@ -7,6 +8,12 @@ export type ImageContent = {
|
|||||||
mimeType: string;
|
mimeType: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type AgentStreamParams = {
|
||||||
|
/** Provider stream params override (best-effort). */
|
||||||
|
temperature?: number;
|
||||||
|
maxTokens?: number;
|
||||||
|
};
|
||||||
|
|
||||||
export type AgentRunContext = {
|
export type AgentRunContext = {
|
||||||
messageChannel?: string;
|
messageChannel?: string;
|
||||||
accountId?: string;
|
accountId?: string;
|
||||||
@ -20,6 +27,8 @@ export type AgentCommandOpts = {
|
|||||||
message: string;
|
message: string;
|
||||||
/** Optional image attachments for multimodal messages. */
|
/** Optional image attachments for multimodal messages. */
|
||||||
images?: ImageContent[];
|
images?: ImageContent[];
|
||||||
|
/** Optional client-provided tools (OpenResponses hosted tools). */
|
||||||
|
clientTools?: ClientToolDefinition[];
|
||||||
/** Agent id override (must exist in config). */
|
/** Agent id override (must exist in config). */
|
||||||
agentId?: string;
|
agentId?: string;
|
||||||
to?: string;
|
to?: string;
|
||||||
@ -50,4 +59,6 @@ export type AgentCommandOpts = {
|
|||||||
lane?: string;
|
lane?: string;
|
||||||
runId?: string;
|
runId?: string;
|
||||||
extraSystemPrompt?: string;
|
extraSystemPrompt?: string;
|
||||||
|
/** Per-call stream param overrides (best-effort). */
|
||||||
|
streamParams?: AgentStreamParams;
|
||||||
};
|
};
|
||||||
|
|||||||
@ -105,8 +105,65 @@ export type GatewayHttpChatCompletionsConfig = {
|
|||||||
enabled?: boolean;
|
enabled?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type GatewayHttpResponsesConfig = {
|
||||||
|
/**
|
||||||
|
* If false, the Gateway will not serve `POST /v1/responses` (OpenResponses API).
|
||||||
|
* Default: false when absent.
|
||||||
|
*/
|
||||||
|
enabled?: boolean;
|
||||||
|
/**
|
||||||
|
* Max request body size in bytes for `/v1/responses`.
|
||||||
|
* Default: 20MB.
|
||||||
|
*/
|
||||||
|
maxBodyBytes?: number;
|
||||||
|
/** File inputs (input_file). */
|
||||||
|
files?: GatewayHttpResponsesFilesConfig;
|
||||||
|
/** Image inputs (input_image). */
|
||||||
|
images?: GatewayHttpResponsesImagesConfig;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type GatewayHttpResponsesFilesConfig = {
|
||||||
|
/** Allow URL fetches for input_file. Default: true. */
|
||||||
|
allowUrl?: boolean;
|
||||||
|
/** Allowed MIME types (case-insensitive). */
|
||||||
|
allowedMimes?: string[];
|
||||||
|
/** Max bytes per file. Default: 5MB. */
|
||||||
|
maxBytes?: number;
|
||||||
|
/** Max decoded characters per file. Default: 200k. */
|
||||||
|
maxChars?: number;
|
||||||
|
/** Max redirects when fetching a URL. Default: 3. */
|
||||||
|
maxRedirects?: number;
|
||||||
|
/** Fetch timeout in ms. Default: 10s. */
|
||||||
|
timeoutMs?: number;
|
||||||
|
/** PDF handling (application/pdf). */
|
||||||
|
pdf?: GatewayHttpResponsesPdfConfig;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type GatewayHttpResponsesPdfConfig = {
|
||||||
|
/** Max pages to parse/render. Default: 4. */
|
||||||
|
maxPages?: number;
|
||||||
|
/** Max pixels per rendered page. Default: 4M. */
|
||||||
|
maxPixels?: number;
|
||||||
|
/** Minimum extracted text length to skip rasterization. Default: 200 chars. */
|
||||||
|
minTextChars?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type GatewayHttpResponsesImagesConfig = {
|
||||||
|
/** Allow URL fetches for input_image. Default: true. */
|
||||||
|
allowUrl?: boolean;
|
||||||
|
/** Allowed MIME types (case-insensitive). */
|
||||||
|
allowedMimes?: string[];
|
||||||
|
/** Max bytes per image. Default: 10MB. */
|
||||||
|
maxBytes?: number;
|
||||||
|
/** Max redirects when fetching a URL. Default: 3. */
|
||||||
|
maxRedirects?: number;
|
||||||
|
/** Fetch timeout in ms. Default: 10s. */
|
||||||
|
timeoutMs?: number;
|
||||||
|
};
|
||||||
|
|
||||||
export type GatewayHttpEndpointsConfig = {
|
export type GatewayHttpEndpointsConfig = {
|
||||||
chatCompletions?: GatewayHttpChatCompletionsConfig;
|
chatCompletions?: GatewayHttpChatCompletionsConfig;
|
||||||
|
responses?: GatewayHttpResponsesConfig;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type GatewayHttpConfig = {
|
export type GatewayHttpConfig = {
|
||||||
|
|||||||
@ -299,6 +299,42 @@ export const ClawdbotSchema = z
|
|||||||
})
|
})
|
||||||
.strict()
|
.strict()
|
||||||
.optional(),
|
.optional(),
|
||||||
|
responses: z
|
||||||
|
.object({
|
||||||
|
enabled: z.boolean().optional(),
|
||||||
|
maxBodyBytes: z.number().int().positive().optional(),
|
||||||
|
files: z
|
||||||
|
.object({
|
||||||
|
allowUrl: z.boolean().optional(),
|
||||||
|
allowedMimes: z.array(z.string()).optional(),
|
||||||
|
maxBytes: z.number().int().positive().optional(),
|
||||||
|
maxChars: z.number().int().positive().optional(),
|
||||||
|
maxRedirects: z.number().int().nonnegative().optional(),
|
||||||
|
timeoutMs: z.number().int().positive().optional(),
|
||||||
|
pdf: z
|
||||||
|
.object({
|
||||||
|
maxPages: z.number().int().positive().optional(),
|
||||||
|
maxPixels: z.number().int().positive().optional(),
|
||||||
|
minTextChars: z.number().int().nonnegative().optional(),
|
||||||
|
})
|
||||||
|
.strict()
|
||||||
|
.optional(),
|
||||||
|
})
|
||||||
|
.strict()
|
||||||
|
.optional(),
|
||||||
|
images: z
|
||||||
|
.object({
|
||||||
|
allowUrl: z.boolean().optional(),
|
||||||
|
allowedMimes: z.array(z.string()).optional(),
|
||||||
|
maxBytes: z.number().int().positive().optional(),
|
||||||
|
maxRedirects: z.number().int().nonnegative().optional(),
|
||||||
|
timeoutMs: z.number().int().positive().optional(),
|
||||||
|
})
|
||||||
|
.strict()
|
||||||
|
.optional(),
|
||||||
|
})
|
||||||
|
.strict()
|
||||||
|
.optional(),
|
||||||
})
|
})
|
||||||
.strict()
|
.strict()
|
||||||
.optional(),
|
.optional(),
|
||||||
|
|||||||
@ -90,7 +90,7 @@ export class GatewayClient {
|
|||||||
};
|
};
|
||||||
if (url.startsWith("wss://") && this.opts.tlsFingerprint) {
|
if (url.startsWith("wss://") && this.opts.tlsFingerprint) {
|
||||||
wsOptions.rejectUnauthorized = false;
|
wsOptions.rejectUnauthorized = false;
|
||||||
wsOptions.checkServerIdentity = (_host: string, cert: CertMeta) => {
|
wsOptions.checkServerIdentity = ((_host: string, cert: CertMeta) => {
|
||||||
const fingerprintValue =
|
const fingerprintValue =
|
||||||
typeof cert === "object" && cert && "fingerprint256" in cert
|
typeof cert === "object" && cert && "fingerprint256" in cert
|
||||||
? ((cert as { fingerprint256?: string }).fingerprint256 ?? "")
|
? ((cert as { fingerprint256?: string }).fingerprint256 ?? "")
|
||||||
@ -99,9 +99,17 @@ export class GatewayClient {
|
|||||||
typeof fingerprintValue === "string" ? fingerprintValue : "",
|
typeof fingerprintValue === "string" ? fingerprintValue : "",
|
||||||
);
|
);
|
||||||
const expected = normalizeFingerprint(this.opts.tlsFingerprint ?? "");
|
const expected = normalizeFingerprint(this.opts.tlsFingerprint ?? "");
|
||||||
if (!expected || !fingerprint) return false;
|
if (!expected) {
|
||||||
return fingerprint === expected;
|
return new Error("gateway tls fingerprint missing");
|
||||||
};
|
}
|
||||||
|
if (!fingerprint) {
|
||||||
|
return new Error("gateway tls fingerprint unavailable");
|
||||||
|
}
|
||||||
|
if (fingerprint !== expected) {
|
||||||
|
return new Error("gateway tls fingerprint mismatch");
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}) as any;
|
||||||
}
|
}
|
||||||
this.ws = new WebSocket(url, wsOptions);
|
this.ws = new WebSocket(url, wsOptions);
|
||||||
|
|
||||||
|
|||||||
64
src/gateway/http-utils.ts
Normal file
64
src/gateway/http-utils.ts
Normal file
@ -0,0 +1,64 @@
|
|||||||
|
import { randomUUID } from "node:crypto";
|
||||||
|
import type { IncomingMessage } from "node:http";
|
||||||
|
|
||||||
|
import { buildAgentMainSessionKey, normalizeAgentId } from "../routing/session-key.js";
|
||||||
|
|
||||||
|
export function getHeader(req: IncomingMessage, name: string): string | undefined {
|
||||||
|
const raw = req.headers[name.toLowerCase()];
|
||||||
|
if (typeof raw === "string") return raw;
|
||||||
|
if (Array.isArray(raw)) return raw[0];
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getBearerToken(req: IncomingMessage): string | undefined {
|
||||||
|
const raw = getHeader(req, "authorization")?.trim() ?? "";
|
||||||
|
if (!raw.toLowerCase().startsWith("bearer ")) return undefined;
|
||||||
|
const token = raw.slice(7).trim();
|
||||||
|
return token || undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveAgentIdFromHeader(req: IncomingMessage): string | undefined {
|
||||||
|
const raw =
|
||||||
|
getHeader(req, "x-clawdbot-agent-id")?.trim() ||
|
||||||
|
getHeader(req, "x-clawdbot-agent")?.trim() ||
|
||||||
|
"";
|
||||||
|
if (!raw) return undefined;
|
||||||
|
return normalizeAgentId(raw);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveAgentIdFromModel(model: string | undefined): string | undefined {
|
||||||
|
const raw = model?.trim();
|
||||||
|
if (!raw) return undefined;
|
||||||
|
|
||||||
|
const m =
|
||||||
|
raw.match(/^clawdbot[:/](?<agentId>[a-z0-9][a-z0-9_-]{0,63})$/i) ??
|
||||||
|
raw.match(/^agent:(?<agentId>[a-z0-9][a-z0-9_-]{0,63})$/i);
|
||||||
|
const agentId = m?.groups?.agentId;
|
||||||
|
if (!agentId) return undefined;
|
||||||
|
return normalizeAgentId(agentId);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveAgentIdForRequest(params: {
|
||||||
|
req: IncomingMessage;
|
||||||
|
model: string | undefined;
|
||||||
|
}): string {
|
||||||
|
const fromHeader = resolveAgentIdFromHeader(params.req);
|
||||||
|
if (fromHeader) return fromHeader;
|
||||||
|
|
||||||
|
const fromModel = resolveAgentIdFromModel(params.model);
|
||||||
|
return fromModel ?? "main";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveSessionKey(params: {
|
||||||
|
req: IncomingMessage;
|
||||||
|
agentId: string;
|
||||||
|
user?: string | undefined;
|
||||||
|
prefix: string;
|
||||||
|
}): string {
|
||||||
|
const explicit = getHeader(params.req, "x-clawdbot-session-key")?.trim();
|
||||||
|
if (explicit) return explicit;
|
||||||
|
|
||||||
|
const user = params.user?.trim();
|
||||||
|
const mainKey = user ? `${params.prefix}-user:${user}` : `${params.prefix}:${randomUUID()}`;
|
||||||
|
return buildAgentMainSessionKey({ agentId: params.agentId, mainKey });
|
||||||
|
}
|
||||||
354
src/gateway/open-responses.schema.ts
Normal file
354
src/gateway/open-responses.schema.ts
Normal file
@ -0,0 +1,354 @@
|
|||||||
|
/**
|
||||||
|
* OpenResponses API Zod Schemas
|
||||||
|
*
|
||||||
|
* Zod schemas for the OpenResponses `/v1/responses` endpoint.
|
||||||
|
* This module is isolated from gateway imports to enable future codegen and prevent drift.
|
||||||
|
*
|
||||||
|
* @see https://www.open-responses.com/
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Content Parts
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export const InputTextContentPartSchema = z
|
||||||
|
.object({
|
||||||
|
type: z.literal("input_text"),
|
||||||
|
text: z.string(),
|
||||||
|
})
|
||||||
|
.strict();
|
||||||
|
|
||||||
|
export const OutputTextContentPartSchema = z
|
||||||
|
.object({
|
||||||
|
type: z.literal("output_text"),
|
||||||
|
text: z.string(),
|
||||||
|
})
|
||||||
|
.strict();
|
||||||
|
|
||||||
|
// OpenResponses Image Content: Supports URL or base64 sources
|
||||||
|
export const InputImageSourceSchema = z.discriminatedUnion("type", [
|
||||||
|
z.object({
|
||||||
|
type: z.literal("url"),
|
||||||
|
url: z.string().url(),
|
||||||
|
}),
|
||||||
|
z.object({
|
||||||
|
type: z.literal("base64"),
|
||||||
|
media_type: z.enum(["image/jpeg", "image/png", "image/gif", "image/webp"]),
|
||||||
|
data: z.string().min(1), // base64-encoded
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
export const InputImageContentPartSchema = z
|
||||||
|
.object({
|
||||||
|
type: z.literal("input_image"),
|
||||||
|
source: InputImageSourceSchema,
|
||||||
|
})
|
||||||
|
.strict();
|
||||||
|
|
||||||
|
// OpenResponses File Content: Supports URL or base64 sources
|
||||||
|
export const InputFileSourceSchema = z.discriminatedUnion("type", [
|
||||||
|
z.object({
|
||||||
|
type: z.literal("url"),
|
||||||
|
url: z.string().url(),
|
||||||
|
}),
|
||||||
|
z.object({
|
||||||
|
type: z.literal("base64"),
|
||||||
|
media_type: z.string().min(1), // MIME type
|
||||||
|
data: z.string().min(1), // base64-encoded
|
||||||
|
filename: z.string().optional(),
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
export const InputFileContentPartSchema = z
|
||||||
|
.object({
|
||||||
|
type: z.literal("input_file"),
|
||||||
|
source: InputFileSourceSchema,
|
||||||
|
})
|
||||||
|
.strict();
|
||||||
|
|
||||||
|
export const ContentPartSchema = z.discriminatedUnion("type", [
|
||||||
|
InputTextContentPartSchema,
|
||||||
|
OutputTextContentPartSchema,
|
||||||
|
InputImageContentPartSchema,
|
||||||
|
InputFileContentPartSchema,
|
||||||
|
]);
|
||||||
|
|
||||||
|
export type ContentPart = z.infer<typeof ContentPartSchema>;
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Item Types (ItemParam)
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export const MessageItemRoleSchema = z.enum(["system", "developer", "user", "assistant"]);
|
||||||
|
|
||||||
|
export type MessageItemRole = z.infer<typeof MessageItemRoleSchema>;
|
||||||
|
|
||||||
|
export const MessageItemSchema = z
|
||||||
|
.object({
|
||||||
|
type: z.literal("message"),
|
||||||
|
role: MessageItemRoleSchema,
|
||||||
|
content: z.union([z.string(), z.array(ContentPartSchema)]),
|
||||||
|
})
|
||||||
|
.strict();
|
||||||
|
|
||||||
|
export const FunctionCallItemSchema = z
|
||||||
|
.object({
|
||||||
|
type: z.literal("function_call"),
|
||||||
|
id: z.string().optional(),
|
||||||
|
call_id: z.string().optional(),
|
||||||
|
name: z.string(),
|
||||||
|
arguments: z.string(),
|
||||||
|
})
|
||||||
|
.strict();
|
||||||
|
|
||||||
|
export const FunctionCallOutputItemSchema = z
|
||||||
|
.object({
|
||||||
|
type: z.literal("function_call_output"),
|
||||||
|
call_id: z.string(),
|
||||||
|
output: z.string(),
|
||||||
|
})
|
||||||
|
.strict();
|
||||||
|
|
||||||
|
export const ReasoningItemSchema = z
|
||||||
|
.object({
|
||||||
|
type: z.literal("reasoning"),
|
||||||
|
content: z.string().optional(),
|
||||||
|
encrypted_content: z.string().optional(),
|
||||||
|
summary: z.string().optional(),
|
||||||
|
})
|
||||||
|
.strict();
|
||||||
|
|
||||||
|
export const ItemReferenceItemSchema = z
|
||||||
|
.object({
|
||||||
|
type: z.literal("item_reference"),
|
||||||
|
id: z.string(),
|
||||||
|
})
|
||||||
|
.strict();
|
||||||
|
|
||||||
|
export const ItemParamSchema = z.discriminatedUnion("type", [
|
||||||
|
MessageItemSchema,
|
||||||
|
FunctionCallItemSchema,
|
||||||
|
FunctionCallOutputItemSchema,
|
||||||
|
ReasoningItemSchema,
|
||||||
|
ItemReferenceItemSchema,
|
||||||
|
]);
|
||||||
|
|
||||||
|
export type ItemParam = z.infer<typeof ItemParamSchema>;
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Tool Definitions
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export const FunctionToolDefinitionSchema = z
|
||||||
|
.object({
|
||||||
|
type: z.literal("function"),
|
||||||
|
function: z.object({
|
||||||
|
name: z.string().min(1, "Tool name cannot be empty"),
|
||||||
|
description: z.string().optional(),
|
||||||
|
parameters: z.record(z.string(), z.unknown()).optional(),
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
.strict();
|
||||||
|
|
||||||
|
// OpenResponses tool definitions match internal ToolDefinition structure
|
||||||
|
export const ToolDefinitionSchema = FunctionToolDefinitionSchema;
|
||||||
|
|
||||||
|
export type ToolDefinition = z.infer<typeof ToolDefinitionSchema>;
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Request Body
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export const ToolChoiceSchema = z.union([
|
||||||
|
z.literal("auto"),
|
||||||
|
z.literal("none"),
|
||||||
|
z.literal("required"),
|
||||||
|
z.object({
|
||||||
|
type: z.literal("function"),
|
||||||
|
function: z.object({ name: z.string() }),
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
export const CreateResponseBodySchema = z
|
||||||
|
.object({
|
||||||
|
model: z.string(),
|
||||||
|
input: z.union([z.string(), z.array(ItemParamSchema)]),
|
||||||
|
instructions: z.string().optional(),
|
||||||
|
tools: z.array(ToolDefinitionSchema).optional(),
|
||||||
|
tool_choice: ToolChoiceSchema.optional(),
|
||||||
|
stream: z.boolean().optional(),
|
||||||
|
max_output_tokens: z.number().int().positive().optional(),
|
||||||
|
max_tool_calls: z.number().int().positive().optional(),
|
||||||
|
user: z.string().optional(),
|
||||||
|
// Phase 1: ignore but accept these fields
|
||||||
|
temperature: z.number().optional(),
|
||||||
|
top_p: z.number().optional(),
|
||||||
|
metadata: z.record(z.string(), z.string()).optional(),
|
||||||
|
store: z.boolean().optional(),
|
||||||
|
previous_response_id: z.string().optional(),
|
||||||
|
reasoning: z
|
||||||
|
.object({
|
||||||
|
effort: z.enum(["low", "medium", "high"]).optional(),
|
||||||
|
summary: z.enum(["auto", "concise", "detailed"]).optional(),
|
||||||
|
})
|
||||||
|
.optional(),
|
||||||
|
truncation: z.enum(["auto", "disabled"]).optional(),
|
||||||
|
})
|
||||||
|
.strict();
|
||||||
|
|
||||||
|
export type CreateResponseBody = z.infer<typeof CreateResponseBodySchema>;
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Response Resource
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export const ResponseStatusSchema = z.enum([
|
||||||
|
"in_progress",
|
||||||
|
"completed",
|
||||||
|
"failed",
|
||||||
|
"cancelled",
|
||||||
|
"incomplete",
|
||||||
|
]);
|
||||||
|
|
||||||
|
export type ResponseStatus = z.infer<typeof ResponseStatusSchema>;
|
||||||
|
|
||||||
|
export const OutputItemSchema = z.discriminatedUnion("type", [
|
||||||
|
z
|
||||||
|
.object({
|
||||||
|
type: z.literal("message"),
|
||||||
|
id: z.string(),
|
||||||
|
role: z.literal("assistant"),
|
||||||
|
content: z.array(OutputTextContentPartSchema),
|
||||||
|
status: z.enum(["in_progress", "completed"]).optional(),
|
||||||
|
})
|
||||||
|
.strict(),
|
||||||
|
z
|
||||||
|
.object({
|
||||||
|
type: z.literal("function_call"),
|
||||||
|
id: z.string(),
|
||||||
|
call_id: z.string(),
|
||||||
|
name: z.string(),
|
||||||
|
arguments: z.string(),
|
||||||
|
status: z.enum(["in_progress", "completed"]).optional(),
|
||||||
|
})
|
||||||
|
.strict(),
|
||||||
|
z
|
||||||
|
.object({
|
||||||
|
type: z.literal("reasoning"),
|
||||||
|
id: z.string(),
|
||||||
|
content: z.string().optional(),
|
||||||
|
summary: z.string().optional(),
|
||||||
|
})
|
||||||
|
.strict(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
export type OutputItem = z.infer<typeof OutputItemSchema>;
|
||||||
|
|
||||||
|
export const UsageSchema = z.object({
|
||||||
|
input_tokens: z.number().int().nonnegative(),
|
||||||
|
output_tokens: z.number().int().nonnegative(),
|
||||||
|
total_tokens: z.number().int().nonnegative(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type Usage = z.infer<typeof UsageSchema>;
|
||||||
|
|
||||||
|
export const ResponseResourceSchema = z.object({
|
||||||
|
id: z.string(),
|
||||||
|
object: z.literal("response"),
|
||||||
|
created_at: z.number().int(),
|
||||||
|
status: ResponseStatusSchema,
|
||||||
|
model: z.string(),
|
||||||
|
output: z.array(OutputItemSchema),
|
||||||
|
usage: UsageSchema,
|
||||||
|
// Optional fields for future phases
|
||||||
|
error: z
|
||||||
|
.object({
|
||||||
|
code: z.string(),
|
||||||
|
message: z.string(),
|
||||||
|
})
|
||||||
|
.optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type ResponseResource = z.infer<typeof ResponseResourceSchema>;
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Streaming Event Types
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export const ResponseCreatedEventSchema = z.object({
|
||||||
|
type: z.literal("response.created"),
|
||||||
|
response: ResponseResourceSchema,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const ResponseInProgressEventSchema = z.object({
|
||||||
|
type: z.literal("response.in_progress"),
|
||||||
|
response: ResponseResourceSchema,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const ResponseCompletedEventSchema = z.object({
|
||||||
|
type: z.literal("response.completed"),
|
||||||
|
response: ResponseResourceSchema,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const ResponseFailedEventSchema = z.object({
|
||||||
|
type: z.literal("response.failed"),
|
||||||
|
response: ResponseResourceSchema,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const OutputItemAddedEventSchema = z.object({
|
||||||
|
type: z.literal("response.output_item.added"),
|
||||||
|
output_index: z.number().int().nonnegative(),
|
||||||
|
item: OutputItemSchema,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const OutputItemDoneEventSchema = z.object({
|
||||||
|
type: z.literal("response.output_item.done"),
|
||||||
|
output_index: z.number().int().nonnegative(),
|
||||||
|
item: OutputItemSchema,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const ContentPartAddedEventSchema = z.object({
|
||||||
|
type: z.literal("response.content_part.added"),
|
||||||
|
item_id: z.string(),
|
||||||
|
output_index: z.number().int().nonnegative(),
|
||||||
|
content_index: z.number().int().nonnegative(),
|
||||||
|
part: OutputTextContentPartSchema,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const ContentPartDoneEventSchema = z.object({
|
||||||
|
type: z.literal("response.content_part.done"),
|
||||||
|
item_id: z.string(),
|
||||||
|
output_index: z.number().int().nonnegative(),
|
||||||
|
content_index: z.number().int().nonnegative(),
|
||||||
|
part: OutputTextContentPartSchema,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const OutputTextDeltaEventSchema = z.object({
|
||||||
|
type: z.literal("response.output_text.delta"),
|
||||||
|
item_id: z.string(),
|
||||||
|
output_index: z.number().int().nonnegative(),
|
||||||
|
content_index: z.number().int().nonnegative(),
|
||||||
|
delta: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const OutputTextDoneEventSchema = z.object({
|
||||||
|
type: z.literal("response.output_text.done"),
|
||||||
|
item_id: z.string(),
|
||||||
|
output_index: z.number().int().nonnegative(),
|
||||||
|
content_index: z.number().int().nonnegative(),
|
||||||
|
text: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type StreamingEvent =
|
||||||
|
| z.infer<typeof ResponseCreatedEventSchema>
|
||||||
|
| z.infer<typeof ResponseInProgressEventSchema>
|
||||||
|
| z.infer<typeof ResponseCompletedEventSchema>
|
||||||
|
| z.infer<typeof ResponseFailedEventSchema>
|
||||||
|
| z.infer<typeof OutputItemAddedEventSchema>
|
||||||
|
| z.infer<typeof OutputItemDoneEventSchema>
|
||||||
|
| z.infer<typeof ContentPartAddedEventSchema>
|
||||||
|
| z.infer<typeof ContentPartDoneEventSchema>
|
||||||
|
| z.infer<typeof OutputTextDeltaEventSchema>
|
||||||
|
| z.infer<typeof OutputTextDoneEventSchema>;
|
||||||
@ -5,9 +5,9 @@ import { buildHistoryContextFromEntries, type HistoryEntry } from "../auto-reply
|
|||||||
import { createDefaultDeps } from "../cli/deps.js";
|
import { createDefaultDeps } from "../cli/deps.js";
|
||||||
import { agentCommand } from "../commands/agent.js";
|
import { agentCommand } from "../commands/agent.js";
|
||||||
import { emitAgentEvent, onAgentEvent } from "../infra/agent-events.js";
|
import { emitAgentEvent, onAgentEvent } from "../infra/agent-events.js";
|
||||||
import { buildAgentMainSessionKey, normalizeAgentId } from "../routing/session-key.js";
|
|
||||||
import { defaultRuntime } from "../runtime.js";
|
import { defaultRuntime } from "../runtime.js";
|
||||||
import { authorizeGatewayConnect, type ResolvedGatewayAuth } from "./auth.js";
|
import { authorizeGatewayConnect, type ResolvedGatewayAuth } from "./auth.js";
|
||||||
|
import { getBearerToken, resolveAgentIdForRequest, resolveSessionKey } from "./http-utils.js";
|
||||||
import { readJsonBody } from "./hooks.js";
|
import { readJsonBody } from "./hooks.js";
|
||||||
|
|
||||||
type OpenAiHttpOptions = {
|
type OpenAiHttpOptions = {
|
||||||
@ -34,20 +34,6 @@ function sendJson(res: ServerResponse, status: number, body: unknown) {
|
|||||||
res.end(JSON.stringify(body));
|
res.end(JSON.stringify(body));
|
||||||
}
|
}
|
||||||
|
|
||||||
function getHeader(req: IncomingMessage, name: string): string | undefined {
|
|
||||||
const raw = req.headers[name.toLowerCase()];
|
|
||||||
if (typeof raw === "string") return raw;
|
|
||||||
if (Array.isArray(raw)) return raw[0];
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getBearerToken(req: IncomingMessage): string | undefined {
|
|
||||||
const raw = getHeader(req, "authorization")?.trim() ?? "";
|
|
||||||
if (!raw.toLowerCase().startsWith("bearer ")) return undefined;
|
|
||||||
const token = raw.slice(7).trim();
|
|
||||||
return token || undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
function writeSse(res: ServerResponse, data: unknown) {
|
function writeSse(res: ServerResponse, data: unknown) {
|
||||||
res.write(`data: ${JSON.stringify(data)}\n\n`);
|
res.write(`data: ${JSON.stringify(data)}\n\n`);
|
||||||
}
|
}
|
||||||
@ -154,50 +140,12 @@ function buildAgentPrompt(messagesUnknown: unknown): {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveAgentIdFromHeader(req: IncomingMessage): string | undefined {
|
function resolveOpenAiSessionKey(params: {
|
||||||
const raw =
|
|
||||||
getHeader(req, "x-clawdbot-agent-id")?.trim() ||
|
|
||||||
getHeader(req, "x-clawdbot-agent")?.trim() ||
|
|
||||||
"";
|
|
||||||
if (!raw) return undefined;
|
|
||||||
return normalizeAgentId(raw);
|
|
||||||
}
|
|
||||||
|
|
||||||
function resolveAgentIdFromModel(model: string | undefined): string | undefined {
|
|
||||||
const raw = model?.trim();
|
|
||||||
if (!raw) return undefined;
|
|
||||||
|
|
||||||
const m =
|
|
||||||
raw.match(/^clawdbot[:/](?<agentId>[a-z0-9][a-z0-9_-]{0,63})$/i) ??
|
|
||||||
raw.match(/^agent:(?<agentId>[a-z0-9][a-z0-9_-]{0,63})$/i);
|
|
||||||
const agentId = m?.groups?.agentId;
|
|
||||||
if (!agentId) return undefined;
|
|
||||||
return normalizeAgentId(agentId);
|
|
||||||
}
|
|
||||||
|
|
||||||
function resolveAgentIdForRequest(params: {
|
|
||||||
req: IncomingMessage;
|
|
||||||
model: string | undefined;
|
|
||||||
}): string {
|
|
||||||
const fromHeader = resolveAgentIdFromHeader(params.req);
|
|
||||||
if (fromHeader) return fromHeader;
|
|
||||||
|
|
||||||
const fromModel = resolveAgentIdFromModel(params.model);
|
|
||||||
return fromModel ?? "main";
|
|
||||||
}
|
|
||||||
|
|
||||||
function resolveSessionKey(params: {
|
|
||||||
req: IncomingMessage;
|
req: IncomingMessage;
|
||||||
agentId: string;
|
agentId: string;
|
||||||
user?: string | undefined;
|
user?: string | undefined;
|
||||||
}): string {
|
}): string {
|
||||||
const explicit = getHeader(params.req, "x-clawdbot-session-key")?.trim();
|
return resolveSessionKey({ ...params, prefix: "openai" });
|
||||||
if (explicit) return explicit;
|
|
||||||
|
|
||||||
// Default: stateless per-request session key, but stable if OpenAI "user" is provided.
|
|
||||||
const user = params.user?.trim();
|
|
||||||
const mainKey = user ? `openai-user:${user}` : `openai:${randomUUID()}`;
|
|
||||||
return buildAgentMainSessionKey({ agentId: params.agentId, mainKey });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function coerceRequest(val: unknown): OpenAiChatCompletionRequest {
|
function coerceRequest(val: unknown): OpenAiChatCompletionRequest {
|
||||||
@ -248,7 +196,7 @@ export async function handleOpenAiHttpRequest(
|
|||||||
const user = typeof payload.user === "string" ? payload.user : undefined;
|
const user = typeof payload.user === "string" ? payload.user : undefined;
|
||||||
|
|
||||||
const agentId = resolveAgentIdForRequest({ req, model });
|
const agentId = resolveAgentIdForRequest({ req, model });
|
||||||
const sessionKey = resolveSessionKey({ req, agentId, user });
|
const sessionKey = resolveOpenAiSessionKey({ req, agentId, user });
|
||||||
const prompt = buildAgentPrompt(payload.messages);
|
const prompt = buildAgentPrompt(payload.messages);
|
||||||
if (!prompt.message) {
|
if (!prompt.message) {
|
||||||
sendJson(res, 400, {
|
sendJson(res, 400, {
|
||||||
|
|||||||
688
src/gateway/openresponses-http.e2e.test.ts
Normal file
688
src/gateway/openresponses-http.e2e.test.ts
Normal file
@ -0,0 +1,688 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import { HISTORY_CONTEXT_MARKER } from "../auto-reply/reply/history.js";
|
||||||
|
import { CURRENT_MESSAGE_MARKER } from "../auto-reply/reply/mentions.js";
|
||||||
|
import { emitAgentEvent } from "../infra/agent-events.js";
|
||||||
|
import { agentCommand, getFreePort, installGatewayTestHooks } from "./test-helpers.js";
|
||||||
|
|
||||||
|
installGatewayTestHooks();
|
||||||
|
|
||||||
|
async function startServerWithDefaultConfig(port: number) {
|
||||||
|
const { startGatewayServer } = await import("./server.js");
|
||||||
|
return await startGatewayServer(port, {
|
||||||
|
host: "127.0.0.1",
|
||||||
|
auth: { mode: "token", token: "secret" },
|
||||||
|
controlUiEnabled: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function startServer(port: number, opts?: { openResponsesEnabled?: boolean }) {
|
||||||
|
const { startGatewayServer } = await import("./server.js");
|
||||||
|
return await startGatewayServer(port, {
|
||||||
|
host: "127.0.0.1",
|
||||||
|
auth: { mode: "token", token: "secret" },
|
||||||
|
controlUiEnabled: false,
|
||||||
|
openResponsesEnabled: opts?.openResponsesEnabled ?? true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function postResponses(port: number, body: unknown, headers?: Record<string, string>) {
|
||||||
|
const res = await fetch(`http://127.0.0.1:${port}/v1/responses`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"content-type": "application/json",
|
||||||
|
authorization: "Bearer secret",
|
||||||
|
...headers,
|
||||||
|
},
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseSseEvents(text: string): Array<{ event?: string; data: string }> {
|
||||||
|
const events: Array<{ event?: string; data: string }> = [];
|
||||||
|
const lines = text.split("\n");
|
||||||
|
let currentEvent: string | undefined;
|
||||||
|
let currentData: string[] = [];
|
||||||
|
|
||||||
|
for (const line of lines) {
|
||||||
|
if (line.startsWith("event: ")) {
|
||||||
|
currentEvent = line.slice("event: ".length);
|
||||||
|
} else if (line.startsWith("data: ")) {
|
||||||
|
currentData.push(line.slice("data: ".length));
|
||||||
|
} else if (line.trim() === "" && currentData.length > 0) {
|
||||||
|
events.push({ event: currentEvent, data: currentData.join("\n") });
|
||||||
|
currentEvent = undefined;
|
||||||
|
currentData = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return events;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("OpenResponses HTTP API (e2e)", () => {
|
||||||
|
it("is disabled by default (requires config)", async () => {
|
||||||
|
const port = await getFreePort();
|
||||||
|
const server = await startServerWithDefaultConfig(port);
|
||||||
|
try {
|
||||||
|
const res = await postResponses(port, {
|
||||||
|
model: "clawdbot",
|
||||||
|
input: "hi",
|
||||||
|
});
|
||||||
|
expect(res.status).toBe(404);
|
||||||
|
} finally {
|
||||||
|
await server.close({ reason: "test done" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("can be disabled via config (404)", async () => {
|
||||||
|
const port = await getFreePort();
|
||||||
|
const server = await startServer(port, {
|
||||||
|
openResponsesEnabled: false,
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
const res = await postResponses(port, {
|
||||||
|
model: "clawdbot",
|
||||||
|
input: "hi",
|
||||||
|
});
|
||||||
|
expect(res.status).toBe(404);
|
||||||
|
} finally {
|
||||||
|
await server.close({ reason: "test done" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects non-POST", async () => {
|
||||||
|
const port = await getFreePort();
|
||||||
|
const server = await startServer(port);
|
||||||
|
try {
|
||||||
|
const res = await fetch(`http://127.0.0.1:${port}/v1/responses`, {
|
||||||
|
method: "GET",
|
||||||
|
headers: { authorization: "Bearer secret" },
|
||||||
|
});
|
||||||
|
expect(res.status).toBe(405);
|
||||||
|
} finally {
|
||||||
|
await server.close({ reason: "test done" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects missing auth", async () => {
|
||||||
|
const port = await getFreePort();
|
||||||
|
const server = await startServer(port);
|
||||||
|
try {
|
||||||
|
const res = await fetch(`http://127.0.0.1:${port}/v1/responses`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
body: JSON.stringify({ model: "clawdbot", input: "hi" }),
|
||||||
|
});
|
||||||
|
expect(res.status).toBe(401);
|
||||||
|
} finally {
|
||||||
|
await server.close({ reason: "test done" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects invalid request body (missing model)", async () => {
|
||||||
|
const port = await getFreePort();
|
||||||
|
const server = await startServer(port);
|
||||||
|
try {
|
||||||
|
const res = await postResponses(port, { input: "hi" });
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
const json = (await res.json()) as Record<string, unknown>;
|
||||||
|
expect((json.error as Record<string, unknown> | undefined)?.type).toBe(
|
||||||
|
"invalid_request_error",
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
await server.close({ reason: "test done" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("routes to a specific agent via header", async () => {
|
||||||
|
agentCommand.mockResolvedValueOnce({
|
||||||
|
payloads: [{ text: "hello" }],
|
||||||
|
} as never);
|
||||||
|
|
||||||
|
const port = await getFreePort();
|
||||||
|
const server = await startServer(port);
|
||||||
|
try {
|
||||||
|
const res = await postResponses(
|
||||||
|
port,
|
||||||
|
{ model: "clawdbot", input: "hi" },
|
||||||
|
{ "x-clawdbot-agent-id": "beta" },
|
||||||
|
);
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
|
||||||
|
expect(agentCommand).toHaveBeenCalledTimes(1);
|
||||||
|
const [opts] = agentCommand.mock.calls[0] ?? [];
|
||||||
|
expect((opts as { sessionKey?: string } | undefined)?.sessionKey ?? "").toMatch(
|
||||||
|
/^agent:beta:/,
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
await server.close({ reason: "test done" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("routes to a specific agent via model (no custom headers)", async () => {
|
||||||
|
agentCommand.mockResolvedValueOnce({
|
||||||
|
payloads: [{ text: "hello" }],
|
||||||
|
} as never);
|
||||||
|
|
||||||
|
const port = await getFreePort();
|
||||||
|
const server = await startServer(port);
|
||||||
|
try {
|
||||||
|
const res = await postResponses(port, {
|
||||||
|
model: "clawdbot:beta",
|
||||||
|
input: "hi",
|
||||||
|
});
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
|
||||||
|
expect(agentCommand).toHaveBeenCalledTimes(1);
|
||||||
|
const [opts] = agentCommand.mock.calls[0] ?? [];
|
||||||
|
expect((opts as { sessionKey?: string } | undefined)?.sessionKey ?? "").toMatch(
|
||||||
|
/^agent:beta:/,
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
await server.close({ reason: "test done" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses OpenResponses user for a stable session key", async () => {
|
||||||
|
agentCommand.mockResolvedValueOnce({
|
||||||
|
payloads: [{ text: "hello" }],
|
||||||
|
} as never);
|
||||||
|
|
||||||
|
const port = await getFreePort();
|
||||||
|
const server = await startServer(port);
|
||||||
|
try {
|
||||||
|
const res = await postResponses(port, {
|
||||||
|
user: "alice",
|
||||||
|
model: "clawdbot",
|
||||||
|
input: "hi",
|
||||||
|
});
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
|
||||||
|
const [opts] = agentCommand.mock.calls[0] ?? [];
|
||||||
|
expect((opts as { sessionKey?: string } | undefined)?.sessionKey ?? "").toContain(
|
||||||
|
"openresponses-user:alice",
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
await server.close({ reason: "test done" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts string input", async () => {
|
||||||
|
agentCommand.mockResolvedValueOnce({
|
||||||
|
payloads: [{ text: "hello" }],
|
||||||
|
} as never);
|
||||||
|
|
||||||
|
const port = await getFreePort();
|
||||||
|
const server = await startServer(port);
|
||||||
|
try {
|
||||||
|
const res = await postResponses(port, {
|
||||||
|
model: "clawdbot",
|
||||||
|
input: "hello world",
|
||||||
|
});
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
|
||||||
|
const [opts] = agentCommand.mock.calls[0] ?? [];
|
||||||
|
expect((opts as { message?: string } | undefined)?.message).toBe("hello world");
|
||||||
|
} finally {
|
||||||
|
await server.close({ reason: "test done" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts array input with message items", async () => {
|
||||||
|
agentCommand.mockResolvedValueOnce({
|
||||||
|
payloads: [{ text: "hello" }],
|
||||||
|
} as never);
|
||||||
|
|
||||||
|
const port = await getFreePort();
|
||||||
|
const server = await startServer(port);
|
||||||
|
try {
|
||||||
|
const res = await postResponses(port, {
|
||||||
|
model: "clawdbot",
|
||||||
|
input: [{ type: "message", role: "user", content: "hello there" }],
|
||||||
|
});
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
|
||||||
|
const [opts] = agentCommand.mock.calls[0] ?? [];
|
||||||
|
expect((opts as { message?: string } | undefined)?.message).toBe("hello there");
|
||||||
|
} finally {
|
||||||
|
await server.close({ reason: "test done" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("extracts system and developer messages as extraSystemPrompt", async () => {
|
||||||
|
agentCommand.mockResolvedValueOnce({
|
||||||
|
payloads: [{ text: "hello" }],
|
||||||
|
} as never);
|
||||||
|
|
||||||
|
const port = await getFreePort();
|
||||||
|
const server = await startServer(port);
|
||||||
|
try {
|
||||||
|
const res = await postResponses(port, {
|
||||||
|
model: "clawdbot",
|
||||||
|
input: [
|
||||||
|
{ type: "message", role: "system", content: "You are a helpful assistant." },
|
||||||
|
{ type: "message", role: "developer", content: "Be concise." },
|
||||||
|
{ type: "message", role: "user", content: "Hello" },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
|
||||||
|
const [opts] = agentCommand.mock.calls[0] ?? [];
|
||||||
|
const extraSystemPrompt =
|
||||||
|
(opts as { extraSystemPrompt?: string } | undefined)?.extraSystemPrompt ?? "";
|
||||||
|
expect(extraSystemPrompt).toContain("You are a helpful assistant.");
|
||||||
|
expect(extraSystemPrompt).toContain("Be concise.");
|
||||||
|
} finally {
|
||||||
|
await server.close({ reason: "test done" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("includes instructions in extraSystemPrompt", async () => {
|
||||||
|
agentCommand.mockResolvedValueOnce({
|
||||||
|
payloads: [{ text: "hello" }],
|
||||||
|
} as never);
|
||||||
|
|
||||||
|
const port = await getFreePort();
|
||||||
|
const server = await startServer(port);
|
||||||
|
try {
|
||||||
|
const res = await postResponses(port, {
|
||||||
|
model: "clawdbot",
|
||||||
|
input: "hi",
|
||||||
|
instructions: "Always respond in French.",
|
||||||
|
});
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
|
||||||
|
const [opts] = agentCommand.mock.calls[0] ?? [];
|
||||||
|
const extraSystemPrompt =
|
||||||
|
(opts as { extraSystemPrompt?: string } | undefined)?.extraSystemPrompt ?? "";
|
||||||
|
expect(extraSystemPrompt).toContain("Always respond in French.");
|
||||||
|
} finally {
|
||||||
|
await server.close({ reason: "test done" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("includes conversation history when multiple messages are provided", async () => {
|
||||||
|
agentCommand.mockResolvedValueOnce({
|
||||||
|
payloads: [{ text: "I am Claude" }],
|
||||||
|
} as never);
|
||||||
|
|
||||||
|
const port = await getFreePort();
|
||||||
|
const server = await startServer(port);
|
||||||
|
try {
|
||||||
|
const res = await postResponses(port, {
|
||||||
|
model: "clawdbot",
|
||||||
|
input: [
|
||||||
|
{ type: "message", role: "system", content: "You are a helpful assistant." },
|
||||||
|
{ type: "message", role: "user", content: "Hello, who are you?" },
|
||||||
|
{ type: "message", role: "assistant", content: "I am Claude." },
|
||||||
|
{ type: "message", role: "user", content: "What did I just ask you?" },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
|
||||||
|
const [opts] = agentCommand.mock.calls[0] ?? [];
|
||||||
|
const message = (opts as { message?: string } | undefined)?.message ?? "";
|
||||||
|
expect(message).toContain(HISTORY_CONTEXT_MARKER);
|
||||||
|
expect(message).toContain("User: Hello, who are you?");
|
||||||
|
expect(message).toContain("Assistant: I am Claude.");
|
||||||
|
expect(message).toContain(CURRENT_MESSAGE_MARKER);
|
||||||
|
expect(message).toContain("User: What did I just ask you?");
|
||||||
|
} finally {
|
||||||
|
await server.close({ reason: "test done" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("includes function_call_output when it is the latest item", async () => {
|
||||||
|
agentCommand.mockResolvedValueOnce({
|
||||||
|
payloads: [{ text: "ok" }],
|
||||||
|
} as never);
|
||||||
|
|
||||||
|
const port = await getFreePort();
|
||||||
|
const server = await startServer(port);
|
||||||
|
try {
|
||||||
|
const res = await postResponses(port, {
|
||||||
|
model: "clawdbot",
|
||||||
|
input: [
|
||||||
|
{ type: "message", role: "user", content: "What's the weather?" },
|
||||||
|
{ type: "function_call_output", call_id: "call_1", output: "Sunny, 70F." },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
|
||||||
|
const [opts] = agentCommand.mock.calls[0] ?? [];
|
||||||
|
const message = (opts as { message?: string } | undefined)?.message ?? "";
|
||||||
|
expect(message).toContain("Sunny, 70F.");
|
||||||
|
} finally {
|
||||||
|
await server.close({ reason: "test done" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("moves input_file content into extraSystemPrompt", async () => {
|
||||||
|
agentCommand.mockResolvedValueOnce({
|
||||||
|
payloads: [{ text: "ok" }],
|
||||||
|
} as never);
|
||||||
|
|
||||||
|
const port = await getFreePort();
|
||||||
|
const server = await startServer(port);
|
||||||
|
try {
|
||||||
|
const res = await postResponses(port, {
|
||||||
|
model: "clawdbot",
|
||||||
|
input: [
|
||||||
|
{
|
||||||
|
type: "message",
|
||||||
|
role: "user",
|
||||||
|
content: [
|
||||||
|
{ type: "input_text", text: "read this" },
|
||||||
|
{
|
||||||
|
type: "input_file",
|
||||||
|
source: {
|
||||||
|
type: "base64",
|
||||||
|
media_type: "text/plain",
|
||||||
|
data: Buffer.from("hello").toString("base64"),
|
||||||
|
filename: "hello.txt",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
|
||||||
|
const [opts] = agentCommand.mock.calls[0] ?? [];
|
||||||
|
const message = (opts as { message?: string } | undefined)?.message ?? "";
|
||||||
|
const extraSystemPrompt =
|
||||||
|
(opts as { extraSystemPrompt?: string } | undefined)?.extraSystemPrompt ?? "";
|
||||||
|
expect(message).toBe("read this");
|
||||||
|
expect(extraSystemPrompt).toContain('<file name="hello.txt">');
|
||||||
|
} finally {
|
||||||
|
await server.close({ reason: "test done" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("applies tool_choice=none by dropping tools", async () => {
|
||||||
|
agentCommand.mockResolvedValueOnce({
|
||||||
|
payloads: [{ text: "ok" }],
|
||||||
|
} as never);
|
||||||
|
|
||||||
|
const port = await getFreePort();
|
||||||
|
const server = await startServer(port);
|
||||||
|
try {
|
||||||
|
const res = await postResponses(port, {
|
||||||
|
model: "clawdbot",
|
||||||
|
input: "hi",
|
||||||
|
tools: [
|
||||||
|
{
|
||||||
|
type: "function",
|
||||||
|
function: { name: "get_weather", description: "Get weather" },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
tool_choice: "none",
|
||||||
|
});
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
|
||||||
|
const [opts] = agentCommand.mock.calls[0] ?? [];
|
||||||
|
expect((opts as { clientTools?: unknown[] } | undefined)?.clientTools).toBeUndefined();
|
||||||
|
} finally {
|
||||||
|
await server.close({ reason: "test done" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("applies tool_choice to a specific tool", async () => {
|
||||||
|
agentCommand.mockResolvedValueOnce({
|
||||||
|
payloads: [{ text: "ok" }],
|
||||||
|
} as never);
|
||||||
|
|
||||||
|
const port = await getFreePort();
|
||||||
|
const server = await startServer(port);
|
||||||
|
try {
|
||||||
|
const res = await postResponses(port, {
|
||||||
|
model: "clawdbot",
|
||||||
|
input: "hi",
|
||||||
|
tools: [
|
||||||
|
{
|
||||||
|
type: "function",
|
||||||
|
function: { name: "get_weather", description: "Get weather" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "function",
|
||||||
|
function: { name: "get_time", description: "Get time" },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
tool_choice: { type: "function", function: { name: "get_time" } },
|
||||||
|
});
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
|
||||||
|
const [opts] = agentCommand.mock.calls[0] ?? [];
|
||||||
|
const clientTools =
|
||||||
|
(opts as { clientTools?: Array<{ function?: { name?: string } }> })?.clientTools ?? [];
|
||||||
|
expect(clientTools).toHaveLength(1);
|
||||||
|
expect(clientTools[0]?.function?.name).toBe("get_time");
|
||||||
|
} finally {
|
||||||
|
await server.close({ reason: "test done" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects tool_choice that references an unknown tool", async () => {
|
||||||
|
const port = await getFreePort();
|
||||||
|
const server = await startServer(port);
|
||||||
|
try {
|
||||||
|
const res = await postResponses(port, {
|
||||||
|
model: "clawdbot",
|
||||||
|
input: "hi",
|
||||||
|
tools: [
|
||||||
|
{
|
||||||
|
type: "function",
|
||||||
|
function: { name: "get_weather", description: "Get weather" },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
tool_choice: { type: "function", function: { name: "unknown_tool" } },
|
||||||
|
});
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
} finally {
|
||||||
|
await server.close({ reason: "test done" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("passes max_output_tokens through to the agent stream params", async () => {
|
||||||
|
agentCommand.mockResolvedValueOnce({
|
||||||
|
payloads: [{ text: "ok" }],
|
||||||
|
} as never);
|
||||||
|
|
||||||
|
const port = await getFreePort();
|
||||||
|
const server = await startServer(port);
|
||||||
|
try {
|
||||||
|
const res = await postResponses(port, {
|
||||||
|
model: "clawdbot",
|
||||||
|
input: "hi",
|
||||||
|
max_output_tokens: 123,
|
||||||
|
});
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
|
||||||
|
const [opts] = agentCommand.mock.calls[0] ?? [];
|
||||||
|
expect(
|
||||||
|
(opts as { streamParams?: { maxTokens?: number } } | undefined)?.streamParams?.maxTokens,
|
||||||
|
).toBe(123);
|
||||||
|
} finally {
|
||||||
|
await server.close({ reason: "test done" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns usage when available", async () => {
|
||||||
|
agentCommand.mockResolvedValueOnce({
|
||||||
|
payloads: [{ text: "ok" }],
|
||||||
|
meta: {
|
||||||
|
agentMeta: {
|
||||||
|
usage: { input: 3, output: 5, cacheRead: 1, cacheWrite: 1 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as never);
|
||||||
|
|
||||||
|
const port = await getFreePort();
|
||||||
|
const server = await startServer(port);
|
||||||
|
try {
|
||||||
|
const res = await postResponses(port, {
|
||||||
|
stream: false,
|
||||||
|
model: "clawdbot",
|
||||||
|
input: "hi",
|
||||||
|
});
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const json = (await res.json()) as Record<string, unknown>;
|
||||||
|
expect(json.usage).toEqual({ input_tokens: 3, output_tokens: 5, total_tokens: 10 });
|
||||||
|
} finally {
|
||||||
|
await server.close({ reason: "test done" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns a non-streaming response with correct shape", async () => {
|
||||||
|
agentCommand.mockResolvedValueOnce({
|
||||||
|
payloads: [{ text: "hello" }],
|
||||||
|
} as never);
|
||||||
|
|
||||||
|
const port = await getFreePort();
|
||||||
|
const server = await startServer(port);
|
||||||
|
try {
|
||||||
|
const res = await postResponses(port, {
|
||||||
|
stream: false,
|
||||||
|
model: "clawdbot",
|
||||||
|
input: "hi",
|
||||||
|
});
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const json = (await res.json()) as Record<string, unknown>;
|
||||||
|
expect(json.object).toBe("response");
|
||||||
|
expect(json.status).toBe("completed");
|
||||||
|
expect(Array.isArray(json.output)).toBe(true);
|
||||||
|
|
||||||
|
const output = json.output as Array<Record<string, unknown>>;
|
||||||
|
expect(output.length).toBe(1);
|
||||||
|
const item = output[0] ?? {};
|
||||||
|
expect(item.type).toBe("message");
|
||||||
|
expect(item.role).toBe("assistant");
|
||||||
|
|
||||||
|
const content = item.content as Array<Record<string, unknown>>;
|
||||||
|
expect(content.length).toBe(1);
|
||||||
|
expect(content[0]?.type).toBe("output_text");
|
||||||
|
expect(content[0]?.text).toBe("hello");
|
||||||
|
} finally {
|
||||||
|
await server.close({ reason: "test done" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("requires a user message in input", async () => {
|
||||||
|
const port = await getFreePort();
|
||||||
|
const server = await startServer(port);
|
||||||
|
try {
|
||||||
|
const res = await postResponses(port, {
|
||||||
|
model: "clawdbot",
|
||||||
|
input: [{ type: "message", role: "system", content: "yo" }],
|
||||||
|
});
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
const json = (await res.json()) as Record<string, unknown>;
|
||||||
|
expect((json.error as Record<string, unknown> | undefined)?.type).toBe(
|
||||||
|
"invalid_request_error",
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
await server.close({ reason: "test done" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("streams SSE events when stream=true (delta events)", async () => {
|
||||||
|
agentCommand.mockImplementationOnce(async (opts: unknown) => {
|
||||||
|
const runId = (opts as { runId?: string } | undefined)?.runId ?? "";
|
||||||
|
emitAgentEvent({ runId, stream: "assistant", data: { delta: "he" } });
|
||||||
|
emitAgentEvent({ runId, stream: "assistant", data: { delta: "llo" } });
|
||||||
|
return { payloads: [{ text: "hello" }] } as never;
|
||||||
|
});
|
||||||
|
|
||||||
|
const port = await getFreePort();
|
||||||
|
const server = await startServer(port);
|
||||||
|
try {
|
||||||
|
const res = await postResponses(port, {
|
||||||
|
stream: true,
|
||||||
|
model: "clawdbot",
|
||||||
|
input: "hi",
|
||||||
|
});
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.headers.get("content-type") ?? "").toContain("text/event-stream");
|
||||||
|
|
||||||
|
const text = await res.text();
|
||||||
|
const events = parseSseEvents(text);
|
||||||
|
|
||||||
|
// Check for required event types
|
||||||
|
const eventTypes = events.map((e) => e.event).filter(Boolean);
|
||||||
|
expect(eventTypes).toContain("response.created");
|
||||||
|
expect(eventTypes).toContain("response.output_item.added");
|
||||||
|
expect(eventTypes).toContain("response.in_progress");
|
||||||
|
expect(eventTypes).toContain("response.content_part.added");
|
||||||
|
expect(eventTypes).toContain("response.output_text.delta");
|
||||||
|
expect(eventTypes).toContain("response.output_text.done");
|
||||||
|
expect(eventTypes).toContain("response.content_part.done");
|
||||||
|
expect(eventTypes).toContain("response.completed");
|
||||||
|
|
||||||
|
// Check for [DONE] terminal event
|
||||||
|
expect(events.some((e) => e.data === "[DONE]")).toBe(true);
|
||||||
|
|
||||||
|
// Verify delta content
|
||||||
|
const deltaEvents = events.filter((e) => e.event === "response.output_text.delta");
|
||||||
|
const allDeltas = deltaEvents
|
||||||
|
.map((e) => {
|
||||||
|
const parsed = JSON.parse(e.data) as { delta?: string };
|
||||||
|
return parsed.delta ?? "";
|
||||||
|
})
|
||||||
|
.join("");
|
||||||
|
expect(allDeltas).toBe("hello");
|
||||||
|
} finally {
|
||||||
|
await server.close({ reason: "test done" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("streams SSE events when stream=true (fallback when no deltas)", async () => {
|
||||||
|
agentCommand.mockResolvedValueOnce({
|
||||||
|
payloads: [{ text: "hello" }],
|
||||||
|
} as never);
|
||||||
|
|
||||||
|
const port = await getFreePort();
|
||||||
|
const server = await startServer(port);
|
||||||
|
try {
|
||||||
|
const res = await postResponses(port, {
|
||||||
|
stream: true,
|
||||||
|
model: "clawdbot",
|
||||||
|
input: "hi",
|
||||||
|
});
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const text = await res.text();
|
||||||
|
expect(text).toContain("[DONE]");
|
||||||
|
expect(text).toContain("hello");
|
||||||
|
} finally {
|
||||||
|
await server.close({ reason: "test done" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("event type matches JSON type field", async () => {
|
||||||
|
agentCommand.mockResolvedValueOnce({
|
||||||
|
payloads: [{ text: "hello" }],
|
||||||
|
} as never);
|
||||||
|
|
||||||
|
const port = await getFreePort();
|
||||||
|
const server = await startServer(port);
|
||||||
|
try {
|
||||||
|
const res = await postResponses(port, {
|
||||||
|
stream: true,
|
||||||
|
model: "clawdbot",
|
||||||
|
input: "hi",
|
||||||
|
});
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
|
||||||
|
const text = await res.text();
|
||||||
|
const events = parseSseEvents(text);
|
||||||
|
|
||||||
|
for (const event of events) {
|
||||||
|
if (event.data === "[DONE]") continue;
|
||||||
|
const parsed = JSON.parse(event.data) as { type?: string };
|
||||||
|
expect(event.event).toBe(parsed.type);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
await server.close({ reason: "test done" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
1200
src/gateway/openresponses-http.ts
Normal file
1200
src/gateway/openresponses-http.ts
Normal file
File diff suppressed because it is too large
Load Diff
315
src/gateway/openresponses-parity.e2e.test.ts
Normal file
315
src/gateway/openresponses-parity.e2e.test.ts
Normal file
@ -0,0 +1,315 @@
|
|||||||
|
/**
|
||||||
|
* OpenResponses Feature Parity E2E Tests
|
||||||
|
*
|
||||||
|
* Tests for input_image, input_file, and client-side tools (Hosted Tools)
|
||||||
|
* support in the OpenResponses `/v1/responses` endpoint.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
|
||||||
|
describe("OpenResponses Feature Parity", () => {
|
||||||
|
describe("Schema Validation", () => {
|
||||||
|
it("should validate input_image with url source", async () => {
|
||||||
|
const { InputImageContentPartSchema } = await import("./open-responses.schema.js");
|
||||||
|
|
||||||
|
const validImage = {
|
||||||
|
type: "input_image" as const,
|
||||||
|
source: {
|
||||||
|
type: "url" as const,
|
||||||
|
url: "https://example.com/image.png",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = InputImageContentPartSchema.safeParse(validImage);
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should validate input_image with base64 source", async () => {
|
||||||
|
const { InputImageContentPartSchema } = await import("./open-responses.schema.js");
|
||||||
|
|
||||||
|
const validImage = {
|
||||||
|
type: "input_image" as const,
|
||||||
|
source: {
|
||||||
|
type: "base64" as const,
|
||||||
|
media_type: "image/png" as const,
|
||||||
|
data: "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = InputImageContentPartSchema.safeParse(validImage);
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should reject input_image with invalid mime type", async () => {
|
||||||
|
const { InputImageContentPartSchema } = await import("./open-responses.schema.js");
|
||||||
|
|
||||||
|
const invalidImage = {
|
||||||
|
type: "input_image" as const,
|
||||||
|
source: {
|
||||||
|
type: "base64" as const,
|
||||||
|
media_type: "application/json" as const, // Not an image
|
||||||
|
data: "SGVsbG8gV29ybGQh",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = InputImageContentPartSchema.safeParse(invalidImage);
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should validate input_file with url source", async () => {
|
||||||
|
const { InputFileContentPartSchema } = await import("./open-responses.schema.js");
|
||||||
|
|
||||||
|
const validFile = {
|
||||||
|
type: "input_file" as const,
|
||||||
|
source: {
|
||||||
|
type: "url" as const,
|
||||||
|
url: "https://example.com/document.txt",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = InputFileContentPartSchema.safeParse(validFile);
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should validate input_file with base64 source", async () => {
|
||||||
|
const { InputFileContentPartSchema } = await import("./open-responses.schema.js");
|
||||||
|
|
||||||
|
const validFile = {
|
||||||
|
type: "input_file" as const,
|
||||||
|
source: {
|
||||||
|
type: "base64" as const,
|
||||||
|
media_type: "text/plain" as const,
|
||||||
|
data: "SGVsbG8gV29ybGQh",
|
||||||
|
filename: "hello.txt",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = InputFileContentPartSchema.safeParse(validFile);
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should validate tool definition", async () => {
|
||||||
|
const { ToolDefinitionSchema } = await import("./open-responses.schema.js");
|
||||||
|
|
||||||
|
const validTool = {
|
||||||
|
type: "function" as const,
|
||||||
|
function: {
|
||||||
|
name: "get_weather",
|
||||||
|
description: "Get the current weather",
|
||||||
|
parameters: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
location: { type: "string" },
|
||||||
|
},
|
||||||
|
required: ["location"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = ToolDefinitionSchema.safeParse(validTool);
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should reject tool definition without name", async () => {
|
||||||
|
const { ToolDefinitionSchema } = await import("./open-responses.schema.js");
|
||||||
|
|
||||||
|
const invalidTool = {
|
||||||
|
type: "function" as const,
|
||||||
|
function: {
|
||||||
|
name: "", // Empty name
|
||||||
|
description: "Get the current weather",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = ToolDefinitionSchema.safeParse(invalidTool);
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("CreateResponseBody Schema", () => {
|
||||||
|
it("should validate request with input_image", async () => {
|
||||||
|
const { CreateResponseBodySchema } = await import("./open-responses.schema.js");
|
||||||
|
|
||||||
|
const validRequest = {
|
||||||
|
model: "claude-sonnet-4-20250514",
|
||||||
|
input: [
|
||||||
|
{
|
||||||
|
type: "message" as const,
|
||||||
|
role: "user" as const,
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: "input_image" as const,
|
||||||
|
source: {
|
||||||
|
type: "url" as const,
|
||||||
|
url: "https://example.com/photo.jpg",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "input_text" as const,
|
||||||
|
text: "What's in this image?",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = CreateResponseBodySchema.safeParse(validRequest);
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should validate request with client tools", async () => {
|
||||||
|
const { CreateResponseBodySchema } = await import("./open-responses.schema.js");
|
||||||
|
|
||||||
|
const validRequest = {
|
||||||
|
model: "claude-sonnet-4-20250514",
|
||||||
|
input: [
|
||||||
|
{
|
||||||
|
type: "message" as const,
|
||||||
|
role: "user" as const,
|
||||||
|
content: "What's the weather?",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
tools: [
|
||||||
|
{
|
||||||
|
type: "function" as const,
|
||||||
|
function: {
|
||||||
|
name: "get_weather",
|
||||||
|
description: "Get weather for a location",
|
||||||
|
parameters: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
location: { type: "string" },
|
||||||
|
},
|
||||||
|
required: ["location"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = CreateResponseBodySchema.safeParse(validRequest);
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should validate request with function_call_output for turn-based tools", async () => {
|
||||||
|
const { CreateResponseBodySchema } = await import("./open-responses.schema.js");
|
||||||
|
|
||||||
|
const validRequest = {
|
||||||
|
model: "claude-sonnet-4-20250514",
|
||||||
|
input: [
|
||||||
|
{
|
||||||
|
type: "function_call_output" as const,
|
||||||
|
call_id: "call_123",
|
||||||
|
output: '{"temperature": "72°F", "condition": "sunny"}',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = CreateResponseBodySchema.safeParse(validRequest);
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should validate complete turn-based tool flow", async () => {
|
||||||
|
const { CreateResponseBodySchema } = await import("./open-responses.schema.js");
|
||||||
|
|
||||||
|
const turn1Request = {
|
||||||
|
model: "claude-sonnet-4-20250514",
|
||||||
|
input: [
|
||||||
|
{
|
||||||
|
type: "message" as const,
|
||||||
|
role: "user" as const,
|
||||||
|
content: "What's the weather in San Francisco?",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
tools: [
|
||||||
|
{
|
||||||
|
type: "function" as const,
|
||||||
|
function: {
|
||||||
|
name: "get_weather",
|
||||||
|
description: "Get weather for a location",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const turn1Result = CreateResponseBodySchema.safeParse(turn1Request);
|
||||||
|
expect(turn1Result.success).toBe(true);
|
||||||
|
|
||||||
|
// Turn 2: Client provides tool output
|
||||||
|
const turn2Request = {
|
||||||
|
model: "claude-sonnet-4-20250514",
|
||||||
|
input: [
|
||||||
|
{
|
||||||
|
type: "function_call_output" as const,
|
||||||
|
call_id: "call_123",
|
||||||
|
output: '{"temperature": "72°F", "condition": "sunny"}',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const turn2Result = CreateResponseBodySchema.safeParse(turn2Request);
|
||||||
|
expect(turn2Result.success).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Response Resource Schema", () => {
|
||||||
|
it("should validate response with function_call output", async () => {
|
||||||
|
const { OutputItemSchema } = await import("./open-responses.schema.js");
|
||||||
|
|
||||||
|
const functionCallOutput = {
|
||||||
|
type: "function_call" as const,
|
||||||
|
id: "msg_123",
|
||||||
|
call_id: "call_456",
|
||||||
|
name: "get_weather",
|
||||||
|
arguments: '{"location": "San Francisco"}',
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = OutputItemSchema.safeParse(functionCallOutput);
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("buildAgentPrompt", () => {
|
||||||
|
it("should convert function_call_output to tool entry", async () => {
|
||||||
|
const { buildAgentPrompt } = await import("./openresponses-http.js");
|
||||||
|
|
||||||
|
const result = buildAgentPrompt([
|
||||||
|
{
|
||||||
|
type: "function_call_output" as const,
|
||||||
|
call_id: "call_123",
|
||||||
|
output: '{"temperature": "72°F"}',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
// When there's only a tool output (no history), returns just the body
|
||||||
|
expect(result.message).toBe('{"temperature": "72°F"}');
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle mixed message and function_call_output items", async () => {
|
||||||
|
const { buildAgentPrompt } = await import("./openresponses-http.js");
|
||||||
|
|
||||||
|
const result = buildAgentPrompt([
|
||||||
|
{
|
||||||
|
type: "message" as const,
|
||||||
|
role: "user" as const,
|
||||||
|
content: "What's the weather?",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "function_call_output" as const,
|
||||||
|
call_id: "call_123",
|
||||||
|
output: '{"temperature": "72°F"}',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "message" as const,
|
||||||
|
role: "user" as const,
|
||||||
|
content: "Thanks!",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Should include both user messages and tool output
|
||||||
|
expect(result.message).toContain("weather");
|
||||||
|
expect(result.message).toContain("72°F");
|
||||||
|
expect(result.message).toContain("Thanks");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -26,6 +26,7 @@ import {
|
|||||||
} from "./hooks.js";
|
} from "./hooks.js";
|
||||||
import { applyHookMappings } from "./hooks-mapping.js";
|
import { applyHookMappings } from "./hooks-mapping.js";
|
||||||
import { handleOpenAiHttpRequest } from "./openai-http.js";
|
import { handleOpenAiHttpRequest } from "./openai-http.js";
|
||||||
|
import { handleOpenResponsesHttpRequest } from "./openresponses-http.js";
|
||||||
|
|
||||||
type SubsystemLogger = ReturnType<typeof createSubsystemLogger>;
|
type SubsystemLogger = ReturnType<typeof createSubsystemLogger>;
|
||||||
|
|
||||||
@ -192,6 +193,8 @@ export function createGatewayHttpServer(opts: {
|
|||||||
controlUiEnabled: boolean;
|
controlUiEnabled: boolean;
|
||||||
controlUiBasePath: string;
|
controlUiBasePath: string;
|
||||||
openAiChatCompletionsEnabled: boolean;
|
openAiChatCompletionsEnabled: boolean;
|
||||||
|
openResponsesEnabled: boolean;
|
||||||
|
openResponsesConfig?: import("../config/types.gateway.js").GatewayHttpResponsesConfig;
|
||||||
handleHooksRequest: HooksRequestHandler;
|
handleHooksRequest: HooksRequestHandler;
|
||||||
handlePluginRequest?: HooksRequestHandler;
|
handlePluginRequest?: HooksRequestHandler;
|
||||||
resolvedAuth: import("./auth.js").ResolvedGatewayAuth;
|
resolvedAuth: import("./auth.js").ResolvedGatewayAuth;
|
||||||
@ -202,6 +205,8 @@ export function createGatewayHttpServer(opts: {
|
|||||||
controlUiEnabled,
|
controlUiEnabled,
|
||||||
controlUiBasePath,
|
controlUiBasePath,
|
||||||
openAiChatCompletionsEnabled,
|
openAiChatCompletionsEnabled,
|
||||||
|
openResponsesEnabled,
|
||||||
|
openResponsesConfig,
|
||||||
handleHooksRequest,
|
handleHooksRequest,
|
||||||
handlePluginRequest,
|
handlePluginRequest,
|
||||||
resolvedAuth,
|
resolvedAuth,
|
||||||
@ -222,6 +227,15 @@ export function createGatewayHttpServer(opts: {
|
|||||||
if (await handleHooksRequest(req, res)) return;
|
if (await handleHooksRequest(req, res)) return;
|
||||||
if (await handleSlackHttpRequest(req, res)) return;
|
if (await handleSlackHttpRequest(req, res)) return;
|
||||||
if (handlePluginRequest && (await handlePluginRequest(req, res))) return;
|
if (handlePluginRequest && (await handlePluginRequest(req, res))) return;
|
||||||
|
if (openResponsesEnabled) {
|
||||||
|
if (
|
||||||
|
await handleOpenResponsesHttpRequest(req, res, {
|
||||||
|
auth: resolvedAuth,
|
||||||
|
config: openResponsesConfig,
|
||||||
|
})
|
||||||
|
)
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (openAiChatCompletionsEnabled) {
|
if (openAiChatCompletionsEnabled) {
|
||||||
if (await handleOpenAiHttpRequest(req, res, { auth: resolvedAuth })) return;
|
if (await handleOpenAiHttpRequest(req, res, { auth: resolvedAuth })) return;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -17,6 +17,8 @@ export type GatewayRuntimeConfig = {
|
|||||||
bindHost: string;
|
bindHost: string;
|
||||||
controlUiEnabled: boolean;
|
controlUiEnabled: boolean;
|
||||||
openAiChatCompletionsEnabled: boolean;
|
openAiChatCompletionsEnabled: boolean;
|
||||||
|
openResponsesEnabled: boolean;
|
||||||
|
openResponsesConfig?: import("../config/types.gateway.js").GatewayHttpResponsesConfig;
|
||||||
controlUiBasePath: string;
|
controlUiBasePath: string;
|
||||||
resolvedAuth: ResolvedGatewayAuth;
|
resolvedAuth: ResolvedGatewayAuth;
|
||||||
authMode: ResolvedGatewayAuth["mode"];
|
authMode: ResolvedGatewayAuth["mode"];
|
||||||
@ -33,6 +35,7 @@ export async function resolveGatewayRuntimeConfig(params: {
|
|||||||
host?: string;
|
host?: string;
|
||||||
controlUiEnabled?: boolean;
|
controlUiEnabled?: boolean;
|
||||||
openAiChatCompletionsEnabled?: boolean;
|
openAiChatCompletionsEnabled?: boolean;
|
||||||
|
openResponsesEnabled?: boolean;
|
||||||
auth?: GatewayAuthConfig;
|
auth?: GatewayAuthConfig;
|
||||||
tailscale?: GatewayTailscaleConfig;
|
tailscale?: GatewayTailscaleConfig;
|
||||||
}): Promise<GatewayRuntimeConfig> {
|
}): Promise<GatewayRuntimeConfig> {
|
||||||
@ -45,6 +48,8 @@ export async function resolveGatewayRuntimeConfig(params: {
|
|||||||
params.openAiChatCompletionsEnabled ??
|
params.openAiChatCompletionsEnabled ??
|
||||||
params.cfg.gateway?.http?.endpoints?.chatCompletions?.enabled ??
|
params.cfg.gateway?.http?.endpoints?.chatCompletions?.enabled ??
|
||||||
false;
|
false;
|
||||||
|
const openResponsesConfig = params.cfg.gateway?.http?.endpoints?.responses;
|
||||||
|
const openResponsesEnabled = params.openResponsesEnabled ?? openResponsesConfig?.enabled ?? false;
|
||||||
const controlUiBasePath = normalizeControlUiBasePath(params.cfg.gateway?.controlUi?.basePath);
|
const controlUiBasePath = normalizeControlUiBasePath(params.cfg.gateway?.controlUi?.basePath);
|
||||||
const authBase = params.cfg.gateway?.auth ?? {};
|
const authBase = params.cfg.gateway?.auth ?? {};
|
||||||
const authOverrides = params.auth ?? {};
|
const authOverrides = params.auth ?? {};
|
||||||
@ -88,6 +93,10 @@ export async function resolveGatewayRuntimeConfig(params: {
|
|||||||
bindHost,
|
bindHost,
|
||||||
controlUiEnabled,
|
controlUiEnabled,
|
||||||
openAiChatCompletionsEnabled,
|
openAiChatCompletionsEnabled,
|
||||||
|
openResponsesEnabled,
|
||||||
|
openResponsesConfig: openResponsesConfig
|
||||||
|
? { ...openResponsesConfig, enabled: openResponsesEnabled }
|
||||||
|
: undefined,
|
||||||
controlUiBasePath,
|
controlUiBasePath,
|
||||||
resolvedAuth,
|
resolvedAuth,
|
||||||
authMode,
|
authMode,
|
||||||
|
|||||||
@ -27,6 +27,8 @@ export async function createGatewayRuntimeState(params: {
|
|||||||
controlUiEnabled: boolean;
|
controlUiEnabled: boolean;
|
||||||
controlUiBasePath: string;
|
controlUiBasePath: string;
|
||||||
openAiChatCompletionsEnabled: boolean;
|
openAiChatCompletionsEnabled: boolean;
|
||||||
|
openResponsesEnabled: boolean;
|
||||||
|
openResponsesConfig?: import("../config/types.gateway.js").GatewayHttpResponsesConfig;
|
||||||
resolvedAuth: ResolvedGatewayAuth;
|
resolvedAuth: ResolvedGatewayAuth;
|
||||||
gatewayTls?: GatewayTlsRuntime;
|
gatewayTls?: GatewayTlsRuntime;
|
||||||
hooksConfig: () => HooksConfigResolved | null;
|
hooksConfig: () => HooksConfigResolved | null;
|
||||||
@ -103,6 +105,8 @@ export async function createGatewayRuntimeState(params: {
|
|||||||
controlUiEnabled: params.controlUiEnabled,
|
controlUiEnabled: params.controlUiEnabled,
|
||||||
controlUiBasePath: params.controlUiBasePath,
|
controlUiBasePath: params.controlUiBasePath,
|
||||||
openAiChatCompletionsEnabled: params.openAiChatCompletionsEnabled,
|
openAiChatCompletionsEnabled: params.openAiChatCompletionsEnabled,
|
||||||
|
openResponsesEnabled: params.openResponsesEnabled,
|
||||||
|
openResponsesConfig: params.openResponsesConfig,
|
||||||
handleHooksRequest,
|
handleHooksRequest,
|
||||||
handlePluginRequest,
|
handlePluginRequest,
|
||||||
resolvedAuth: params.resolvedAuth,
|
resolvedAuth: params.resolvedAuth,
|
||||||
|
|||||||
@ -111,6 +111,11 @@ export type GatewayServerOptions = {
|
|||||||
* Default: config `gateway.http.endpoints.chatCompletions.enabled` (or false when absent).
|
* Default: config `gateway.http.endpoints.chatCompletions.enabled` (or false when absent).
|
||||||
*/
|
*/
|
||||||
openAiChatCompletionsEnabled?: boolean;
|
openAiChatCompletionsEnabled?: boolean;
|
||||||
|
/**
|
||||||
|
* If false, do not serve `POST /v1/responses` (OpenResponses API).
|
||||||
|
* Default: config `gateway.http.endpoints.responses.enabled` (or false when absent).
|
||||||
|
*/
|
||||||
|
openResponsesEnabled?: boolean;
|
||||||
/**
|
/**
|
||||||
* Override gateway auth configuration (merges with config).
|
* Override gateway auth configuration (merges with config).
|
||||||
*/
|
*/
|
||||||
@ -205,6 +210,7 @@ export async function startGatewayServer(
|
|||||||
host: opts.host,
|
host: opts.host,
|
||||||
controlUiEnabled: opts.controlUiEnabled,
|
controlUiEnabled: opts.controlUiEnabled,
|
||||||
openAiChatCompletionsEnabled: opts.openAiChatCompletionsEnabled,
|
openAiChatCompletionsEnabled: opts.openAiChatCompletionsEnabled,
|
||||||
|
openResponsesEnabled: opts.openResponsesEnabled,
|
||||||
auth: opts.auth,
|
auth: opts.auth,
|
||||||
tailscale: opts.tailscale,
|
tailscale: opts.tailscale,
|
||||||
});
|
});
|
||||||
@ -212,6 +218,8 @@ export async function startGatewayServer(
|
|||||||
bindHost,
|
bindHost,
|
||||||
controlUiEnabled,
|
controlUiEnabled,
|
||||||
openAiChatCompletionsEnabled,
|
openAiChatCompletionsEnabled,
|
||||||
|
openResponsesEnabled,
|
||||||
|
openResponsesConfig,
|
||||||
controlUiBasePath,
|
controlUiBasePath,
|
||||||
resolvedAuth,
|
resolvedAuth,
|
||||||
tailscaleConfig,
|
tailscaleConfig,
|
||||||
@ -250,6 +258,8 @@ export async function startGatewayServer(
|
|||||||
controlUiEnabled,
|
controlUiEnabled,
|
||||||
controlUiBasePath,
|
controlUiBasePath,
|
||||||
openAiChatCompletionsEnabled,
|
openAiChatCompletionsEnabled,
|
||||||
|
openResponsesEnabled,
|
||||||
|
openResponsesConfig,
|
||||||
resolvedAuth,
|
resolvedAuth,
|
||||||
gatewayTls,
|
gatewayTls,
|
||||||
hooksConfig: () => hooksConfig,
|
hooksConfig: () => hooksConfig,
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user