diff --git a/.agent/.DS_Store b/.agent/.DS_Store new file mode 100644 index 000000000..1f2c43e08 Binary files /dev/null and b/.agent/.DS_Store differ diff --git a/.agent/workflows/update_clawdbot.md b/.agent/workflows/update_clawdbot.md new file mode 100644 index 000000000..692ee84e4 --- /dev/null +++ b/.agent/workflows/update_clawdbot.md @@ -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 + +# 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 +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." +``` diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 000000000..8fb5b4d50 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "Peekaboo"] + path = Peekaboo + url = https://github.com/steipete/Peekaboo.git diff --git a/.serena/.gitignore b/.serena/.gitignore new file mode 100644 index 000000000..14d86ad62 --- /dev/null +++ b/.serena/.gitignore @@ -0,0 +1 @@ +/cache diff --git a/.serena/cache/typescript/document_symbols.pkl b/.serena/cache/typescript/document_symbols.pkl new file mode 100644 index 000000000..328546c76 Binary files /dev/null and b/.serena/cache/typescript/document_symbols.pkl differ diff --git a/.serena/cache/typescript/raw_document_symbols.pkl b/.serena/cache/typescript/raw_document_symbols.pkl new file mode 100644 index 000000000..afe55aa0a Binary files /dev/null and b/.serena/cache/typescript/raw_document_symbols.pkl differ diff --git a/.serena/project.yml b/.serena/project.yml new file mode 100644 index 000000000..0b7f659b9 --- /dev/null +++ b/.serena/project.yml @@ -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: [] diff --git a/CHANGELOG.md b/CHANGELOG.md index 9b9f73fb5..f229bbca0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ Docs: https://docs.clawd.bot ### Changes - Android: remove legacy bridge transport code now that nodes use the gateway protocol. - Android: send structured payloads in node events/invokes and include user-agent metadata in gateway connects. +- Gateway: expand `/v1/responses` to support file/image inputs, tool_choice, usage, and output limits. (#1229) — thanks @RyanLisse. - Docs: surface Amazon Bedrock in provider lists and clarify Bedrock auth env vars. (#1289) — thanks @steipete. ### Fixes @@ -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. ### Changes +- Gateway: add `/v1/responses` endpoint (OpenResponses API) for agentic workflows with item-based input and semantic streaming events. Enable via `gateway.http.endpoints.responses.enabled: true`. - Usage: add `/usage cost` summaries and macOS menu cost submenu with daily charting. - Agents: clarify node_modules read-only guidance in agent instructions. - TUI: add syntax highlighting for code blocks. (#1200) — thanks @vignesh07. diff --git a/Peekaboo b/Peekaboo new file mode 160000 index 000000000..5c195f5e4 --- /dev/null +++ b/Peekaboo @@ -0,0 +1 @@ +Subproject commit 5c195f5e46ebfcc953af74fdd05fbc962d05a50c diff --git a/apps/ios/Sources/Camera/CameraController.swift b/apps/ios/Sources/Camera/CameraController.swift index e361664cf..b33f40a00 100644 --- a/apps/ios/Sources/Camera/CameraController.swift +++ b/apps/ios/Sources/Camera/CameraController.swift @@ -160,14 +160,14 @@ actor CameraController { defer { session.stopRunning() } await Self.warmUpCaptureSession() - let movURL = FileManager.default.temporaryDirectory + let movURL = FileManager().temporaryDirectory .appendingPathComponent("clawdbot-camera-\(UUID().uuidString).mov") - let mp4URL = FileManager.default.temporaryDirectory + let mp4URL = FileManager().temporaryDirectory .appendingPathComponent("clawdbot-camera-\(UUID().uuidString).mp4") defer { - try? FileManager.default.removeItem(at: movURL) - try? FileManager.default.removeItem(at: mp4URL) + try? FileManager().removeItem(at: movURL) + try? FileManager().removeItem(at: mp4URL) } var delegate: MovieFileDelegate? diff --git a/apps/ios/Sources/Model/NodeAppModel.swift b/apps/ios/Sources/Model/NodeAppModel.swift index e788d5db8..2830f17d7 100644 --- a/apps/ios/Sources/Model/NodeAppModel.swift +++ b/apps/ios/Sources/Model/NodeAppModel.swift @@ -837,7 +837,7 @@ final class NodeAppModel { fps: params.fps, includeAudio: params.includeAudio, outPath: nil) - defer { try? FileManager.default.removeItem(atPath: path) } + defer { try? FileManager().removeItem(atPath: path) } let data = try Data(contentsOf: URL(fileURLWithPath: path)) struct Payload: Codable { var format: String diff --git a/apps/ios/Sources/Screen/ScreenRecordService.swift b/apps/ios/Sources/Screen/ScreenRecordService.swift index e31762e51..6bccae5f9 100644 --- a/apps/ios/Sources/Screen/ScreenRecordService.swift +++ b/apps/ios/Sources/Screen/ScreenRecordService.swift @@ -91,7 +91,7 @@ final class ScreenRecordService: @unchecked Sendable { let includeAudio = includeAudio ?? true let outURL = self.makeOutputURL(outPath: outPath) - try? FileManager.default.removeItem(at: outURL) + try? FileManager().removeItem(at: outURL) return RecordConfig( durationMs: durationMs, @@ -104,7 +104,7 @@ final class ScreenRecordService: @unchecked Sendable { if let outPath, !outPath.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { return URL(fileURLWithPath: outPath) } - return FileManager.default.temporaryDirectory + return FileManager().temporaryDirectory .appendingPathComponent("clawdbot-screen-record-\(UUID().uuidString).mp4") } diff --git a/apps/macos/Sources/Clawdbot/AgentWorkspace.swift b/apps/macos/Sources/Clawdbot/AgentWorkspace.swift index 8906f2f4c..f63c1dd4d 100644 --- a/apps/macos/Sources/Clawdbot/AgentWorkspace.swift +++ b/apps/macos/Sources/Clawdbot/AgentWorkspace.swift @@ -23,7 +23,7 @@ enum AgentWorkspace { } static func displayPath(for url: URL) -> String { - let home = FileManager.default.homeDirectoryForCurrentUser.path + let home = FileManager().homeDirectoryForCurrentUser.path let path = url.path if path == home { return "~" } if path.hasPrefix(home + "/") { @@ -44,12 +44,12 @@ enum AgentWorkspace { } static func workspaceEntries(workspaceURL: URL) throws -> [String] { - let contents = try FileManager.default.contentsOfDirectory(atPath: workspaceURL.path) + let contents = try FileManager().contentsOfDirectory(atPath: workspaceURL.path) return contents.filter { !self.ignoredEntries.contains($0) } } static func isWorkspaceEmpty(workspaceURL: URL) -> Bool { - let fm = FileManager.default + let fm = FileManager() var isDir: ObjCBool = false if !fm.fileExists(atPath: workspaceURL.path, isDirectory: &isDir) { return true @@ -66,7 +66,7 @@ enum AgentWorkspace { } static func bootstrapSafety(for workspaceURL: URL) -> BootstrapSafety { - let fm = FileManager.default + let fm = FileManager() var isDir: ObjCBool = false if !fm.fileExists(atPath: workspaceURL.path, isDirectory: &isDir) { return .safe @@ -90,29 +90,29 @@ enum AgentWorkspace { static func bootstrap(workspaceURL: URL) throws -> URL { let shouldSeedBootstrap = self.isWorkspaceEmpty(workspaceURL: workspaceURL) - try FileManager.default.createDirectory(at: workspaceURL, withIntermediateDirectories: true) + try FileManager().createDirectory(at: workspaceURL, withIntermediateDirectories: true) let agentsURL = self.agentsURL(workspaceURL: workspaceURL) - if !FileManager.default.fileExists(atPath: agentsURL.path) { + if !FileManager().fileExists(atPath: agentsURL.path) { try self.defaultTemplate().write(to: agentsURL, atomically: true, encoding: .utf8) self.logger.info("Created AGENTS.md at \(agentsURL.path, privacy: .public)") } let soulURL = workspaceURL.appendingPathComponent(self.soulFilename) - if !FileManager.default.fileExists(atPath: soulURL.path) { + if !FileManager().fileExists(atPath: soulURL.path) { try self.defaultSoulTemplate().write(to: soulURL, atomically: true, encoding: .utf8) self.logger.info("Created SOUL.md at \(soulURL.path, privacy: .public)") } let identityURL = workspaceURL.appendingPathComponent(self.identityFilename) - if !FileManager.default.fileExists(atPath: identityURL.path) { + if !FileManager().fileExists(atPath: identityURL.path) { try self.defaultIdentityTemplate().write(to: identityURL, atomically: true, encoding: .utf8) self.logger.info("Created IDENTITY.md at \(identityURL.path, privacy: .public)") } let userURL = workspaceURL.appendingPathComponent(self.userFilename) - if !FileManager.default.fileExists(atPath: userURL.path) { + if !FileManager().fileExists(atPath: userURL.path) { try self.defaultUserTemplate().write(to: userURL, atomically: true, encoding: .utf8) self.logger.info("Created USER.md at \(userURL.path, privacy: .public)") } let bootstrapURL = workspaceURL.appendingPathComponent(self.bootstrapFilename) - if shouldSeedBootstrap, !FileManager.default.fileExists(atPath: bootstrapURL.path) { + if shouldSeedBootstrap, !FileManager().fileExists(atPath: bootstrapURL.path) { try self.defaultBootstrapTemplate().write(to: bootstrapURL, atomically: true, encoding: .utf8) self.logger.info("Created BOOTSTRAP.md at \(bootstrapURL.path, privacy: .public)") } @@ -120,7 +120,7 @@ enum AgentWorkspace { } static func needsBootstrap(workspaceURL: URL) -> Bool { - let fm = FileManager.default + let fm = FileManager() var isDir: ObjCBool = false if !fm.fileExists(atPath: workspaceURL.path, isDirectory: &isDir) { return true @@ -305,7 +305,7 @@ enum AgentWorkspace { if let dev = self.devTemplateURL(named: named) { urls.append(dev) } - let cwd = URL(fileURLWithPath: FileManager.default.currentDirectoryPath) + let cwd = URL(fileURLWithPath: FileManager().currentDirectoryPath) urls.append(cwd.appendingPathComponent("docs") .appendingPathComponent(self.templateDirname) .appendingPathComponent(named)) diff --git a/apps/macos/Sources/Clawdbot/AnthropicAuthControls.swift b/apps/macos/Sources/Clawdbot/AnthropicAuthControls.swift index 42106e50d..0f5f1d61c 100644 --- a/apps/macos/Sources/Clawdbot/AnthropicAuthControls.swift +++ b/apps/macos/Sources/Clawdbot/AnthropicAuthControls.swift @@ -45,7 +45,7 @@ struct AnthropicAuthControls: View { NSWorkspace.shared.activateFileViewerSelecting([ClawdbotOAuthStore.oauthURL()]) } .buttonStyle(.bordered) - .disabled(!FileManager.default.fileExists(atPath: ClawdbotOAuthStore.oauthURL().path)) + .disabled(!FileManager().fileExists(atPath: ClawdbotOAuthStore.oauthURL().path)) Button("Refresh") { self.refresh() diff --git a/apps/macos/Sources/Clawdbot/AnthropicOAuth.swift b/apps/macos/Sources/Clawdbot/AnthropicOAuth.swift index 09113324a..4ea7f7fb9 100644 --- a/apps/macos/Sources/Clawdbot/AnthropicOAuth.swift +++ b/apps/macos/Sources/Clawdbot/AnthropicOAuth.swift @@ -234,7 +234,7 @@ enum ClawdbotOAuthStore { return URL(fileURLWithPath: expanded, isDirectory: true) } - return FileManager.default.homeDirectoryForCurrentUser + return FileManager().homeDirectoryForCurrentUser .appendingPathComponent(".clawdbot", isDirectory: true) .appendingPathComponent("credentials", isDirectory: true) } @@ -253,7 +253,7 @@ enum ClawdbotOAuthStore { urls.append(URL(fileURLWithPath: expanded, isDirectory: true).appendingPathComponent(self.oauthFilename)) } - let home = FileManager.default.homeDirectoryForCurrentUser + let home = FileManager().homeDirectoryForCurrentUser urls.append(home.appendingPathComponent(".pi/agent/\(self.oauthFilename)")) urls.append(home.appendingPathComponent(".claude/\(self.oauthFilename)")) urls.append(home.appendingPathComponent(".config/claude/\(self.oauthFilename)")) @@ -270,10 +270,10 @@ enum ClawdbotOAuthStore { static func importLegacyAnthropicOAuthIfNeeded() -> URL? { let dest = self.oauthURL() - guard !FileManager.default.fileExists(atPath: dest.path) else { return nil } + guard !FileManager().fileExists(atPath: dest.path) else { return nil } for url in self.legacyOAuthURLs() { - guard FileManager.default.fileExists(atPath: url.path) else { continue } + guard FileManager().fileExists(atPath: url.path) else { continue } guard self.anthropicOAuthStatus(at: url).isConnected else { continue } guard let storage = self.loadStorage(at: url) else { continue } do { @@ -296,7 +296,7 @@ enum ClawdbotOAuthStore { } static func anthropicOAuthStatus(at url: URL) -> AnthropicOAuthStatus { - guard FileManager.default.fileExists(atPath: url.path) else { return .missingFile } + guard FileManager().fileExists(atPath: url.path) else { return .missingFile } guard let data = try? Data(contentsOf: url) else { return .unreadableFile } guard let json = try? JSONSerialization.jsonObject(with: data, options: []) else { return .invalidJSON } @@ -360,7 +360,7 @@ enum ClawdbotOAuthStore { private static func saveStorage(_ storage: [String: Any]) throws { let dir = self.oauthDir() - try FileManager.default.createDirectory( + try FileManager().createDirectory( at: dir, withIntermediateDirectories: true, attributes: [.posixPermissions: 0o700]) @@ -370,7 +370,7 @@ enum ClawdbotOAuthStore { withJSONObject: storage, options: [.prettyPrinted, .sortedKeys]) try data.write(to: url, options: [.atomic]) - try FileManager.default.setAttributes([.posixPermissions: 0o600], ofItemAtPath: url.path) + try FileManager().setAttributes([.posixPermissions: 0o600], ofItemAtPath: url.path) } } diff --git a/apps/macos/Sources/Clawdbot/CLIInstaller.swift b/apps/macos/Sources/Clawdbot/CLIInstaller.swift index d967002f5..b9113e27a 100644 --- a/apps/macos/Sources/Clawdbot/CLIInstaller.swift +++ b/apps/macos/Sources/Clawdbot/CLIInstaller.swift @@ -61,7 +61,7 @@ enum CLIInstaller { } private static func installPrefix() -> String { - FileManager.default.homeDirectoryForCurrentUser + FileManager().homeDirectoryForCurrentUser .appendingPathComponent(".clawdbot") .path } diff --git a/apps/macos/Sources/Clawdbot/CameraCaptureService.swift b/apps/macos/Sources/Clawdbot/CameraCaptureService.swift index b7a7de308..c9893bbfb 100644 --- a/apps/macos/Sources/Clawdbot/CameraCaptureService.swift +++ b/apps/macos/Sources/Clawdbot/CameraCaptureService.swift @@ -167,20 +167,20 @@ actor CameraCaptureService { defer { session.stopRunning() } await Self.warmUpCaptureSession() - let tmpMovURL = FileManager.default.temporaryDirectory + let tmpMovURL = FileManager().temporaryDirectory .appendingPathComponent("clawdbot-camera-\(UUID().uuidString).mov") - defer { try? FileManager.default.removeItem(at: tmpMovURL) } + defer { try? FileManager().removeItem(at: tmpMovURL) } let outputURL: URL = { if let outPath, !outPath.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { return URL(fileURLWithPath: outPath) } - return FileManager.default.temporaryDirectory + return FileManager().temporaryDirectory .appendingPathComponent("clawdbot-camera-\(UUID().uuidString).mp4") }() // Ensure we don't fail exporting due to an existing file. - try? FileManager.default.removeItem(at: outputURL) + try? FileManager().removeItem(at: outputURL) let logger = self.logger var delegate: MovieFileDelegate? diff --git a/apps/macos/Sources/Clawdbot/CanvasManager.swift b/apps/macos/Sources/Clawdbot/CanvasManager.swift index 868e71142..f65c123ca 100644 --- a/apps/macos/Sources/Clawdbot/CanvasManager.swift +++ b/apps/macos/Sources/Clawdbot/CanvasManager.swift @@ -25,7 +25,7 @@ final class CanvasManager { var defaultAnchorProvider: (() -> NSRect?)? private nonisolated static let canvasRoot: URL = { - let base = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first! + let base = FileManager().urls(for: .applicationSupportDirectory, in: .userDomainMask).first! return base.appendingPathComponent("Clawdbot/canvas", isDirectory: true) }() @@ -83,7 +83,7 @@ final class CanvasManager { self.panelSessionKey = nil Self.logger.debug("showDetailed ensure canvas root dir") - try FileManager.default.createDirectory(at: Self.canvasRoot, withIntermediateDirectories: true) + try FileManager().createDirectory(at: Self.canvasRoot, withIntermediateDirectories: true) Self.logger.debug("showDetailed init CanvasWindowController") let controller = try CanvasWindowController( sessionKey: session, @@ -258,7 +258,7 @@ final class CanvasManager { // (Avoid treating Canvas routes like "/" as filesystem paths.) if trimmed.hasPrefix("/") { var isDir: ObjCBool = false - if FileManager.default.fileExists(atPath: trimmed, isDirectory: &isDir), !isDir.boolValue { + if FileManager().fileExists(atPath: trimmed, isDirectory: &isDir), !isDir.boolValue { return URL(fileURLWithPath: trimmed) } } @@ -293,7 +293,7 @@ final class CanvasManager { } private static func localStatus(sessionDir: URL, target: String) -> CanvasShowStatus { - let fm = FileManager.default + let fm = FileManager() let trimmed = target.trimmingCharacters(in: .whitespacesAndNewlines) let withoutQuery = trimmed.split(separator: "?", maxSplits: 1, omittingEmptySubsequences: false).first .map(String.init) ?? trimmed @@ -331,7 +331,7 @@ final class CanvasManager { } private static func indexExists(in dir: URL) -> Bool { - let fm = FileManager.default + let fm = FileManager() let a = dir.appendingPathComponent("index.html", isDirectory: false) if fm.fileExists(atPath: a.path) { return true } let b = dir.appendingPathComponent("index.htm", isDirectory: false) diff --git a/apps/macos/Sources/Clawdbot/CanvasSchemeHandler.swift b/apps/macos/Sources/Clawdbot/CanvasSchemeHandler.swift index 8617a2d49..27dbd93bf 100644 --- a/apps/macos/Sources/Clawdbot/CanvasSchemeHandler.swift +++ b/apps/macos/Sources/Clawdbot/CanvasSchemeHandler.swift @@ -69,8 +69,8 @@ final class CanvasSchemeHandler: NSObject, WKURLSchemeHandler { if path.isEmpty { let indexA = sessionRoot.appendingPathComponent("index.html", isDirectory: false) let indexB = sessionRoot.appendingPathComponent("index.htm", isDirectory: false) - if !FileManager.default.fileExists(atPath: indexA.path), - !FileManager.default.fileExists(atPath: indexB.path) + if !FileManager().fileExists(atPath: indexA.path), + !FileManager().fileExists(atPath: indexB.path) { return self.scaffoldPage(sessionRoot: sessionRoot) } @@ -106,7 +106,7 @@ final class CanvasSchemeHandler: NSObject, WKURLSchemeHandler { } private func resolveFileURL(sessionRoot: URL, requestPath: String) -> URL? { - let fm = FileManager.default + let fm = FileManager() var candidate = sessionRoot.appendingPathComponent(requestPath, isDirectory: false) var isDir: ObjCBool = false @@ -137,7 +137,7 @@ final class CanvasSchemeHandler: NSObject, WKURLSchemeHandler { } private func resolveIndex(in dir: URL) -> URL? { - let fm = FileManager.default + let fm = FileManager() let a = dir.appendingPathComponent("index.html", isDirectory: false) if fm.fileExists(atPath: a.path) { return a } let b = dir.appendingPathComponent("index.htm", isDirectory: false) diff --git a/apps/macos/Sources/Clawdbot/CanvasWindowController.swift b/apps/macos/Sources/Clawdbot/CanvasWindowController.swift index d0d4e4ff5..b119efd85 100644 --- a/apps/macos/Sources/Clawdbot/CanvasWindowController.swift +++ b/apps/macos/Sources/Clawdbot/CanvasWindowController.swift @@ -32,7 +32,7 @@ final class CanvasWindowController: NSWindowController, WKNavigationDelegate, NS let safeSessionKey = CanvasWindowController.sanitizeSessionKey(sessionKey) canvasWindowLogger.debug("CanvasWindowController init sanitized session=\(safeSessionKey, privacy: .public)") self.sessionDir = root.appendingPathComponent(safeSessionKey, isDirectory: true) - try FileManager.default.createDirectory(at: self.sessionDir, withIntermediateDirectories: true) + try FileManager().createDirectory(at: self.sessionDir, withIntermediateDirectories: true) canvasWindowLogger.debug("CanvasWindowController init session dir ready") self.schemeHandler = CanvasSchemeHandler(root: root) @@ -143,8 +143,8 @@ final class CanvasWindowController: NSWindowController, WKNavigationDelegate, NS if path == "/" || path.isEmpty { let indexA = sessionDir.appendingPathComponent("index.html", isDirectory: false) let indexB = sessionDir.appendingPathComponent("index.htm", isDirectory: false) - if !FileManager.default.fileExists(atPath: indexA.path), - !FileManager.default.fileExists(atPath: indexB.path) + if !FileManager().fileExists(atPath: indexA.path), + !FileManager().fileExists(atPath: indexB.path) { return } @@ -233,7 +233,7 @@ final class CanvasWindowController: NSWindowController, WKNavigationDelegate, NS // (Avoid treating Canvas routes like "/" as filesystem paths.) if trimmed.hasPrefix("/") { var isDir: ObjCBool = false - if FileManager.default.fileExists(atPath: trimmed, isDirectory: &isDir), !isDir.boolValue { + if FileManager().fileExists(atPath: trimmed, isDirectory: &isDir), !isDir.boolValue { let url = URL(fileURLWithPath: trimmed) canvasWindowLogger.debug("canvas load file \(url.absoluteString, privacy: .public)") self.loadFile(url) diff --git a/apps/macos/Sources/Clawdbot/ClawdbotConfigFile.swift b/apps/macos/Sources/Clawdbot/ClawdbotConfigFile.swift index 6b2169010..1c054b557 100644 --- a/apps/macos/Sources/Clawdbot/ClawdbotConfigFile.swift +++ b/apps/macos/Sources/Clawdbot/ClawdbotConfigFile.swift @@ -18,7 +18,7 @@ enum ClawdbotConfigFile { static func loadDict() -> [String: Any] { let url = self.url() - guard FileManager.default.fileExists(atPath: url.path) else { return [:] } + guard FileManager().fileExists(atPath: url.path) else { return [:] } do { let data = try Data(contentsOf: url) guard let root = self.parseConfigData(data) else { @@ -38,7 +38,7 @@ enum ClawdbotConfigFile { do { let data = try JSONSerialization.data(withJSONObject: dict, options: [.prettyPrinted, .sortedKeys]) let url = self.url() - try FileManager.default.createDirectory( + try FileManager().createDirectory( at: url.deletingLastPathComponent(), withIntermediateDirectories: true) try data.write(to: url, options: [.atomic]) diff --git a/apps/macos/Sources/Clawdbot/ClawdbotPaths.swift b/apps/macos/Sources/Clawdbot/ClawdbotPaths.swift index 3e32782c0..7cda49ea6 100644 --- a/apps/macos/Sources/Clawdbot/ClawdbotPaths.swift +++ b/apps/macos/Sources/Clawdbot/ClawdbotPaths.swift @@ -21,7 +21,7 @@ enum ClawdbotPaths { if let override = ClawdbotEnv.path(self.stateDirEnv) { return URL(fileURLWithPath: override, isDirectory: true) } - return FileManager.default.homeDirectoryForCurrentUser + return FileManager().homeDirectoryForCurrentUser .appendingPathComponent(".clawdbot", isDirectory: true) } diff --git a/apps/macos/Sources/Clawdbot/CommandResolver.swift b/apps/macos/Sources/Clawdbot/CommandResolver.swift index 9e8ae1c41..117930710 100644 --- a/apps/macos/Sources/Clawdbot/CommandResolver.swift +++ b/apps/macos/Sources/Clawdbot/CommandResolver.swift @@ -6,9 +6,9 @@ enum CommandResolver { static func gatewayEntrypoint(in root: URL) -> String? { let distEntry = root.appendingPathComponent("dist/index.js").path - if FileManager.default.isReadableFile(atPath: distEntry) { return distEntry } + if FileManager().isReadableFile(atPath: distEntry) { return distEntry } let binEntry = root.appendingPathComponent("bin/clawdbot.js").path - if FileManager.default.isReadableFile(atPath: binEntry) { return binEntry } + if FileManager().isReadableFile(atPath: binEntry) { return binEntry } return nil } @@ -47,16 +47,16 @@ enum CommandResolver { static func projectRoot() -> URL { if let stored = UserDefaults.standard.string(forKey: self.projectRootDefaultsKey), let url = self.expandPath(stored), - FileManager.default.fileExists(atPath: url.path) + FileManager().fileExists(atPath: url.path) { return url } - let fallback = FileManager.default.homeDirectoryForCurrentUser + let fallback = FileManager().homeDirectoryForCurrentUser .appendingPathComponent("Projects/clawdbot") - if FileManager.default.fileExists(atPath: fallback.path) { + if FileManager().fileExists(atPath: fallback.path) { return fallback } - return FileManager.default.homeDirectoryForCurrentUser + return FileManager().homeDirectoryForCurrentUser } static func setProjectRoot(_ path: String) { @@ -70,7 +70,7 @@ enum CommandResolver { static func preferredPaths() -> [String] { let current = ProcessInfo.processInfo.environment["PATH"]? .split(separator: ":").map(String.init) ?? [] - let home = FileManager.default.homeDirectoryForCurrentUser + let home = FileManager().homeDirectoryForCurrentUser let projectRoot = self.projectRoot() return self.preferredPaths(home: home, current: current, projectRoot: projectRoot) } @@ -99,10 +99,10 @@ enum CommandResolver { let bin = base.appendingPathComponent("bin") let nodeBin = base.appendingPathComponent("tools/node/bin") var paths: [String] = [] - if FileManager.default.fileExists(atPath: bin.path) { + if FileManager().fileExists(atPath: bin.path) { paths.append(bin.path) } - if FileManager.default.fileExists(atPath: nodeBin.path) { + if FileManager().fileExists(atPath: nodeBin.path) { paths.append(nodeBin.path) } return paths @@ -113,13 +113,13 @@ enum CommandResolver { // Volta let volta = home.appendingPathComponent(".volta/bin") - if FileManager.default.fileExists(atPath: volta.path) { + if FileManager().fileExists(atPath: volta.path) { bins.append(volta.path) } // asdf let asdf = home.appendingPathComponent(".asdf/shims") - if FileManager.default.fileExists(atPath: asdf.path) { + if FileManager().fileExists(atPath: asdf.path) { bins.append(asdf.path) } @@ -137,10 +137,10 @@ enum CommandResolver { } private static func versionedNodeBinPaths(base: URL, suffix: String) -> [String] { - guard FileManager.default.fileExists(atPath: base.path) else { return [] } + guard FileManager().fileExists(atPath: base.path) else { return [] } let entries: [String] do { - entries = try FileManager.default.contentsOfDirectory(atPath: base.path) + entries = try FileManager().contentsOfDirectory(atPath: base.path) } catch { return [] } @@ -167,7 +167,7 @@ enum CommandResolver { for entry in sorted { let binDir = base.appendingPathComponent(entry).appendingPathComponent(suffix) let node = binDir.appendingPathComponent("node") - if FileManager.default.isExecutableFile(atPath: node.path) { + if FileManager().isExecutableFile(atPath: node.path) { paths.append(binDir.path) } } @@ -177,7 +177,7 @@ enum CommandResolver { static func findExecutable(named name: String, searchPaths: [String]? = nil) -> String? { for dir in searchPaths ?? self.preferredPaths() { let candidate = (dir as NSString).appendingPathComponent(name) - if FileManager.default.isExecutableFile(atPath: candidate) { + if FileManager().isExecutableFile(atPath: candidate) { return candidate } } @@ -191,12 +191,12 @@ enum CommandResolver { static func projectClawdbotExecutable(projectRoot: URL? = nil) -> String? { let root = projectRoot ?? self.projectRoot() let candidate = root.appendingPathComponent("node_modules/.bin").appendingPathComponent(self.helperName).path - return FileManager.default.isExecutableFile(atPath: candidate) ? candidate : nil + return FileManager().isExecutableFile(atPath: candidate) ? candidate : nil } static func nodeCliPath() -> String? { let candidate = self.projectRoot().appendingPathComponent("bin/clawdbot.js").path - return FileManager.default.isReadableFile(atPath: candidate) ? candidate : nil + return FileManager().isReadableFile(atPath: candidate) ? candidate : nil } static func hasAnyClawdbotInvoker(searchPaths: [String]? = nil) -> Bool { @@ -459,7 +459,7 @@ enum CommandResolver { private static func expandPath(_ path: String) -> URL? { var expanded = path if expanded.hasPrefix("~") { - let home = FileManager.default.homeDirectoryForCurrentUser.path + let home = FileManager().homeDirectoryForCurrentUser.path expanded.replaceSubrange(expanded.startIndex...expanded.startIndex, with: home) } return URL(fileURLWithPath: expanded) diff --git a/apps/macos/Sources/Clawdbot/DebugActions.swift b/apps/macos/Sources/Clawdbot/DebugActions.swift index 1bfbeeb24..f0f84ed3c 100644 --- a/apps/macos/Sources/Clawdbot/DebugActions.swift +++ b/apps/macos/Sources/Clawdbot/DebugActions.swift @@ -26,7 +26,7 @@ enum DebugActions { static func openLog() { let path = self.pinoLogPath() let url = URL(fileURLWithPath: path) - guard FileManager.default.fileExists(atPath: path) else { + guard FileManager().fileExists(atPath: path) else { let alert = NSAlert() alert.messageText = "Log file not found" alert.informativeText = path @@ -38,7 +38,7 @@ enum DebugActions { @MainActor static func openConfigFolder() { - let url = FileManager.default + let url = FileManager() .homeDirectoryForCurrentUser .appendingPathComponent(".clawdbot", isDirectory: true) NSWorkspace.shared.activateFileViewerSelecting([url]) @@ -55,7 +55,7 @@ enum DebugActions { } let path = self.resolveSessionStorePath() let url = URL(fileURLWithPath: path) - if FileManager.default.fileExists(atPath: path) { + if FileManager().fileExists(atPath: path) { NSWorkspace.shared.activateFileViewerSelecting([url]) } else { NSWorkspace.shared.open(url.deletingLastPathComponent()) @@ -195,7 +195,7 @@ enum DebugActions { @MainActor private static func resolveSessionStorePath() -> String { let defaultPath = SessionLoader.defaultStorePath - let configURL = FileManager.default.homeDirectoryForCurrentUser + let configURL = FileManager().homeDirectoryForCurrentUser .appendingPathComponent(".clawdbot/clawdbot.json") guard let data = try? Data(contentsOf: configURL), diff --git a/apps/macos/Sources/Clawdbot/DebugSettings.swift b/apps/macos/Sources/Clawdbot/DebugSettings.swift index c338edad8..26a1f9830 100644 --- a/apps/macos/Sources/Clawdbot/DebugSettings.swift +++ b/apps/macos/Sources/Clawdbot/DebugSettings.swift @@ -354,7 +354,7 @@ struct DebugSettings: View { Button("Save") { self.saveRelayRoot() } .buttonStyle(.borderedProminent) Button("Reset") { - let def = FileManager.default.homeDirectoryForCurrentUser + let def = FileManager().homeDirectoryForCurrentUser .appendingPathComponent("Projects/clawdbot").path self.gatewayRootInput = def self.saveRelayRoot() @@ -743,7 +743,7 @@ struct DebugSettings: View { do { let data = try JSONSerialization.data(withJSONObject: root, options: [.prettyPrinted, .sortedKeys]) - try FileManager.default.createDirectory( + try FileManager().createDirectory( at: url.deletingLastPathComponent(), withIntermediateDirectories: true) try data.write(to: url, options: [.atomic]) @@ -776,7 +776,7 @@ struct DebugSettings: View { } private func configURL() -> URL { - FileManager.default.homeDirectoryForCurrentUser + FileManager().homeDirectoryForCurrentUser .appendingPathComponent(".clawdbot") .appendingPathComponent("clawdbot.json") } diff --git a/apps/macos/Sources/Clawdbot/DiagnosticsFileLog.swift b/apps/macos/Sources/Clawdbot/DiagnosticsFileLog.swift index f62dd2c07..d42b76081 100644 --- a/apps/macos/Sources/Clawdbot/DiagnosticsFileLog.swift +++ b/apps/macos/Sources/Clawdbot/DiagnosticsFileLog.swift @@ -20,8 +20,8 @@ actor DiagnosticsFileLog { } nonisolated static func logDirectoryURL() -> URL { - let library = FileManager.default.urls(for: .libraryDirectory, in: .userDomainMask).first - ?? FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent("Library", isDirectory: true) + let library = FileManager().urls(for: .libraryDirectory, in: .userDomainMask).first + ?? FileManager().homeDirectoryForCurrentUser.appendingPathComponent("Library", isDirectory: true) return library .appendingPathComponent("Logs", isDirectory: true) .appendingPathComponent("Clawdbot", isDirectory: true) @@ -43,7 +43,7 @@ actor DiagnosticsFileLog { } func clear() throws { - let fm = FileManager.default + let fm = FileManager() let base = Self.logFileURL() if fm.fileExists(atPath: base.path) { try fm.removeItem(at: base) @@ -67,7 +67,7 @@ actor DiagnosticsFileLog { } private func ensureDirectory() throws { - try FileManager.default.createDirectory( + try FileManager().createDirectory( at: Self.logDirectoryURL(), withIntermediateDirectories: true) } @@ -79,7 +79,7 @@ actor DiagnosticsFileLog { line.append(data) line.append(0x0A) // newline - let fm = FileManager.default + let fm = FileManager() if !fm.fileExists(atPath: url.path) { fm.createFile(atPath: url.path, contents: nil) } @@ -92,13 +92,13 @@ actor DiagnosticsFileLog { private func rotateIfNeeded() throws { let url = Self.logFileURL() - guard let attrs = try? FileManager.default.attributesOfItem(atPath: url.path), + guard let attrs = try? FileManager().attributesOfItem(atPath: url.path), let size = attrs[.size] as? NSNumber else { return } if size.int64Value < self.maxBytes { return } - let fm = FileManager.default + let fm = FileManager() let oldest = self.rotatedURL(index: self.maxBackups) if fm.fileExists(atPath: oldest.path) { diff --git a/apps/macos/Sources/Clawdbot/ExecApprovals.swift b/apps/macos/Sources/Clawdbot/ExecApprovals.swift index 907490ba5..3647b5d82 100644 --- a/apps/macos/Sources/Clawdbot/ExecApprovals.swift +++ b/apps/macos/Sources/Clawdbot/ExecApprovals.swift @@ -176,7 +176,7 @@ enum ExecApprovalsStore { static func readSnapshot() -> ExecApprovalsSnapshot { let url = self.fileURL() - guard FileManager.default.fileExists(atPath: url.path) else { + guard FileManager().fileExists(atPath: url.path) else { return ExecApprovalsSnapshot( path: url.path, exists: false, @@ -216,7 +216,7 @@ enum ExecApprovalsStore { static func loadFile() -> ExecApprovalsFile { let url = self.fileURL() - guard FileManager.default.fileExists(atPath: url.path) else { + guard FileManager().fileExists(atPath: url.path) else { return ExecApprovalsFile(version: 1, socket: nil, defaults: nil, agents: [:]) } do { @@ -238,11 +238,11 @@ enum ExecApprovalsStore { encoder.outputFormatting = [.prettyPrinted, .sortedKeys] let data = try encoder.encode(file) let url = self.fileURL() - try FileManager.default.createDirectory( + try FileManager().createDirectory( at: url.deletingLastPathComponent(), withIntermediateDirectories: true) try data.write(to: url, options: [.atomic]) - try? FileManager.default.setAttributes([.posixPermissions: 0o600], ofItemAtPath: url.path) + try? FileManager().setAttributes([.posixPermissions: 0o600], ofItemAtPath: url.path) } catch { self.logger.error("exec approvals save failed: \(error.localizedDescription, privacy: .public)") } @@ -442,11 +442,11 @@ enum ExecApprovalsStore { private static func expandPath(_ raw: String) -> String { let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) if trimmed == "~" { - return FileManager.default.homeDirectoryForCurrentUser.path + return FileManager().homeDirectoryForCurrentUser.path } if trimmed.hasPrefix("~/") { let suffix = trimmed.dropFirst(2) - return FileManager.default.homeDirectoryForCurrentUser + return FileManager().homeDirectoryForCurrentUser .appendingPathComponent(String(suffix)).path } return trimmed @@ -497,7 +497,7 @@ struct ExecCommandResolution: Sendable { return expanded } let base = cwd?.trimmingCharacters(in: .whitespacesAndNewlines) - let root = (base?.isEmpty == false) ? base! : FileManager.default.currentDirectoryPath + let root = (base?.isEmpty == false) ? base! : FileManager().currentDirectoryPath return URL(fileURLWithPath: root).appendingPathComponent(expanded).path } let searchPaths = self.searchPaths(from: env) diff --git a/apps/macos/Sources/Clawdbot/GatewayEndpointStore.swift b/apps/macos/Sources/Clawdbot/GatewayEndpointStore.swift index 031f3dc22..b28f8fe3a 100644 --- a/apps/macos/Sources/Clawdbot/GatewayEndpointStore.swift +++ b/apps/macos/Sources/Clawdbot/GatewayEndpointStore.swift @@ -155,7 +155,8 @@ actor GatewayEndpointStore { let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) if !trimmed.isEmpty { if let configToken = self.resolveConfigToken(isRemote: isRemote, root: root), - !configToken.isEmpty + !configToken.isEmpty, + configToken != trimmed { self.warnEnvOverrideOnce( kind: .token, @@ -164,32 +165,19 @@ actor GatewayEndpointStore { } return trimmed } - if isRemote { - if let gateway = root["gateway"] as? [String: Any], - let remote = gateway["remote"] as? [String: Any], - let token = remote["token"] as? String - { - let value = token.trimmingCharacters(in: .whitespacesAndNewlines) - if !value.isEmpty { - return value - } - } - return nil - } - if let gateway = root["gateway"] as? [String: Any], - let auth = gateway["auth"] as? [String: Any], - let token = auth["token"] as? String + + if let configToken = self.resolveConfigToken(isRemote: isRemote, root: root), + !configToken.isEmpty { - let value = token.trimmingCharacters(in: .whitespacesAndNewlines) - if !value.isEmpty { - return value - } + return configToken } + if let token = launchdSnapshot?.token?.trimmingCharacters(in: .whitespacesAndNewlines), !token.isEmpty { return token } + return nil } diff --git a/apps/macos/Sources/Clawdbot/GatewayLaunchAgentManager.swift b/apps/macos/Sources/Clawdbot/GatewayLaunchAgentManager.swift index 700b79f19..6031677ea 100644 --- a/apps/macos/Sources/Clawdbot/GatewayLaunchAgentManager.swift +++ b/apps/macos/Sources/Clawdbot/GatewayLaunchAgentManager.swift @@ -5,7 +5,7 @@ enum GatewayLaunchAgentManager { private static let disableLaunchAgentMarker = ".clawdbot/disable-launchagent" private static var plistURL: URL { - FileManager.default.homeDirectoryForCurrentUser + FileManager().homeDirectoryForCurrentUser .appendingPathComponent("Library/LaunchAgents/\(gatewayLaunchdLabel).plist") } @@ -67,9 +67,9 @@ enum GatewayLaunchAgentManager { extension GatewayLaunchAgentManager { private static func isLaunchAgentWriteDisabled() -> Bool { - let marker = FileManager.default.homeDirectoryForCurrentUser + let marker = FileManager().homeDirectoryForCurrentUser .appendingPathComponent(self.disableLaunchAgentMarker) - return FileManager.default.fileExists(atPath: marker.path) + return FileManager().fileExists(atPath: marker.path) } private static func readDaemonLoaded() async -> Bool? { diff --git a/apps/macos/Sources/Clawdbot/GatewayProcessManager.swift b/apps/macos/Sources/Clawdbot/GatewayProcessManager.swift index 35d81243b..9c1761544 100644 --- a/apps/macos/Sources/Clawdbot/GatewayProcessManager.swift +++ b/apps/macos/Sources/Clawdbot/GatewayProcessManager.swift @@ -365,7 +365,7 @@ final class GatewayProcessManager { func clearLog() { self.log = "" - try? FileManager.default.removeItem(atPath: GatewayLaunchAgentManager.launchdGatewayLogPath()) + try? FileManager().removeItem(atPath: GatewayLaunchAgentManager.launchdGatewayLogPath()) self.logger.debug("gateway log cleared") } @@ -378,7 +378,7 @@ final class GatewayProcessManager { } private nonisolated static func readGatewayLog(path: String, limit: Int) -> String { - guard FileManager.default.fileExists(atPath: path) else { return "" } + guard FileManager().fileExists(atPath: path) else { return "" } guard let data = try? Data(contentsOf: URL(fileURLWithPath: path)) else { return "" } let text = String(data: data, encoding: .utf8) ?? "" if text.count <= limit { return text } diff --git a/apps/macos/Sources/Clawdbot/LaunchAgentManager.swift b/apps/macos/Sources/Clawdbot/LaunchAgentManager.swift index 2d2c3342a..ca967a133 100644 --- a/apps/macos/Sources/Clawdbot/LaunchAgentManager.swift +++ b/apps/macos/Sources/Clawdbot/LaunchAgentManager.swift @@ -3,17 +3,17 @@ import Foundation enum LaunchAgentManager { private static let legacyLaunchdLabel = "com.steipete.clawdbot" private static var plistURL: URL { - FileManager.default.homeDirectoryForCurrentUser + FileManager().homeDirectoryForCurrentUser .appendingPathComponent("Library/LaunchAgents/com.clawdbot.mac.plist") } private static var legacyPlistURL: URL { - FileManager.default.homeDirectoryForCurrentUser + FileManager().homeDirectoryForCurrentUser .appendingPathComponent("Library/LaunchAgents/\(legacyLaunchdLabel).plist") } static func status() async -> Bool { - guard FileManager.default.fileExists(atPath: self.plistURL.path) else { return false } + guard FileManager().fileExists(atPath: self.plistURL.path) else { return false } let result = await self.runLaunchctl(["print", "gui/\(getuid())/\(launchdLabel)"]) return result == 0 } @@ -21,7 +21,7 @@ enum LaunchAgentManager { static func set(enabled: Bool, bundlePath: String) async { if enabled { _ = await self.runLaunchctl(["bootout", "gui/\(getuid())/\(self.legacyLaunchdLabel)"]) - try? FileManager.default.removeItem(at: self.legacyPlistURL) + try? FileManager().removeItem(at: self.legacyPlistURL) self.writePlist(bundlePath: bundlePath) _ = await self.runLaunchctl(["bootout", "gui/\(getuid())/\(launchdLabel)"]) _ = await self.runLaunchctl(["bootstrap", "gui/\(getuid())", self.plistURL.path]) @@ -29,7 +29,7 @@ enum LaunchAgentManager { } else { // Disable autostart going forward but leave the current app running. // bootout would terminate the launchd job immediately (and crash the app if launched via agent). - try? FileManager.default.removeItem(at: self.plistURL) + try? FileManager().removeItem(at: self.plistURL) } } @@ -46,7 +46,7 @@ enum LaunchAgentManager { \(bundlePath)/Contents/MacOS/Clawdbot WorkingDirectory - \(FileManager.default.homeDirectoryForCurrentUser.path) + \(FileManager().homeDirectoryForCurrentUser.path) RunAtLoad KeepAlive diff --git a/apps/macos/Sources/Clawdbot/LogLocator.swift b/apps/macos/Sources/Clawdbot/LogLocator.swift index 8714f7d0c..0ce7eea2f 100644 --- a/apps/macos/Sources/Clawdbot/LogLocator.swift +++ b/apps/macos/Sources/Clawdbot/LogLocator.swift @@ -18,7 +18,7 @@ enum LogLocator { } private static func ensureLogDirExists() { - try? FileManager.default.createDirectory(at: self.logDir, withIntermediateDirectories: true) + try? FileManager().createDirectory(at: self.logDir, withIntermediateDirectories: true) } private static func modificationDate(for url: URL) -> Date { @@ -28,7 +28,7 @@ enum LogLocator { /// Returns the newest log file under /tmp/clawdbot/ (rolling or stdout), or nil if none exist. static func bestLogFile() -> URL? { self.ensureLogDirExists() - let fm = FileManager.default + let fm = FileManager() let files = (try? fm.contentsOfDirectory( at: self.logDir, includingPropertiesForKeys: [.contentModificationDateKey], diff --git a/apps/macos/Sources/Clawdbot/ModelCatalogLoader.swift b/apps/macos/Sources/Clawdbot/ModelCatalogLoader.swift index bf55c564e..48001b4e9 100644 --- a/apps/macos/Sources/Clawdbot/ModelCatalogLoader.swift +++ b/apps/macos/Sources/Clawdbot/ModelCatalogLoader.swift @@ -2,7 +2,7 @@ import Foundation import JavaScriptCore enum ModelCatalogLoader { - static let defaultPath: String = FileManager.default.homeDirectoryForCurrentUser + static let defaultPath: String = FileManager().homeDirectoryForCurrentUser .appendingPathComponent("Projects/pi-mono/packages/ai/src/models.generated.ts").path private static let logger = Logger(subsystem: "com.clawdbot", category: "models") diff --git a/apps/macos/Sources/Clawdbot/NodeMode/MacNodeRuntime.swift b/apps/macos/Sources/Clawdbot/NodeMode/MacNodeRuntime.swift index 51f6c8e82..16f340415 100644 --- a/apps/macos/Sources/Clawdbot/NodeMode/MacNodeRuntime.swift +++ b/apps/macos/Sources/Clawdbot/NodeMode/MacNodeRuntime.swift @@ -133,7 +133,7 @@ actor MacNodeRuntime { let sessionKey = self.mainSessionKey let path = try await CanvasManager.shared.snapshot(sessionKey: sessionKey, outPath: nil) - defer { try? FileManager.default.removeItem(atPath: path) } + defer { try? FileManager().removeItem(atPath: path) } let data = try Data(contentsOf: URL(fileURLWithPath: path)) guard let image = NSImage(data: data) else { return Self.errorResponse(req, code: .unavailable, message: "canvas snapshot decode failed") @@ -206,7 +206,7 @@ actor MacNodeRuntime { includeAudio: params.includeAudio ?? true, deviceId: params.deviceId, outPath: nil) - defer { try? FileManager.default.removeItem(atPath: res.path) } + defer { try? FileManager().removeItem(atPath: res.path) } let data = try Data(contentsOf: URL(fileURLWithPath: res.path)) struct ClipPayload: Encodable { var format: String @@ -312,7 +312,7 @@ actor MacNodeRuntime { fps: params.fps, includeAudio: params.includeAudio, outPath: nil) - defer { try? FileManager.default.removeItem(atPath: res.path) } + defer { try? FileManager().removeItem(atPath: res.path) } let data = try Data(contentsOf: URL(fileURLWithPath: res.path)) struct ScreenPayload: Encodable { var format: String diff --git a/apps/macos/Sources/Clawdbot/PortGuardian.swift b/apps/macos/Sources/Clawdbot/PortGuardian.swift index 071de5b2a..50d4e0e9f 100644 --- a/apps/macos/Sources/Clawdbot/PortGuardian.swift +++ b/apps/macos/Sources/Clawdbot/PortGuardian.swift @@ -24,7 +24,7 @@ actor PortGuardian { private var records: [Record] = [] private let logger = Logger(subsystem: "com.clawdbot", category: "portguard") private nonisolated static let appSupportDir: URL = { - let base = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first! + let base = FileManager().urls(for: .applicationSupportDirectory, in: .userDomainMask).first! return base.appendingPathComponent("Clawdbot", isDirectory: true) }() @@ -71,7 +71,7 @@ actor PortGuardian { } func record(port: Int, pid: Int32, command: String, mode: AppState.ConnectionMode) async { - try? FileManager.default.createDirectory(at: Self.appSupportDir, withIntermediateDirectories: true) + try? FileManager().createDirectory(at: Self.appSupportDir, withIntermediateDirectories: true) self.records.removeAll { $0.pid == pid } self.records.append( Record( diff --git a/apps/macos/Sources/Clawdbot/RuntimeLocator.swift b/apps/macos/Sources/Clawdbot/RuntimeLocator.swift index 761c63b17..aadbe2d21 100644 --- a/apps/macos/Sources/Clawdbot/RuntimeLocator.swift +++ b/apps/macos/Sources/Clawdbot/RuntimeLocator.swift @@ -111,7 +111,7 @@ enum RuntimeLocator { // MARK: - Internals private static func findExecutable(named name: String, searchPaths: [String]) -> String? { - let fm = FileManager.default + let fm = FileManager() for dir in searchPaths { let candidate = (dir as NSString).appendingPathComponent(name) if fm.isExecutableFile(atPath: candidate) { diff --git a/apps/macos/Sources/Clawdbot/ScreenRecordService.swift b/apps/macos/Sources/Clawdbot/ScreenRecordService.swift index d48538e26..e0878a5c9 100644 --- a/apps/macos/Sources/Clawdbot/ScreenRecordService.swift +++ b/apps/macos/Sources/Clawdbot/ScreenRecordService.swift @@ -42,10 +42,10 @@ final class ScreenRecordService { if let outPath, !outPath.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { return URL(fileURLWithPath: outPath) } - return FileManager.default.temporaryDirectory + return FileManager().temporaryDirectory .appendingPathComponent("clawdbot-screen-record-\(UUID().uuidString).mp4") }() - try? FileManager.default.removeItem(at: outURL) + try? FileManager().removeItem(at: outURL) let content = try await SCShareableContent.current let displays = content.displays.sorted { $0.displayID < $1.displayID } diff --git a/apps/macos/Sources/Clawdbot/SessionActions.swift b/apps/macos/Sources/Clawdbot/SessionActions.swift index 6315f08c7..6e11c93cb 100644 --- a/apps/macos/Sources/Clawdbot/SessionActions.swift +++ b/apps/macos/Sources/Clawdbot/SessionActions.swift @@ -66,12 +66,12 @@ enum SessionActions { let dir = URL(fileURLWithPath: storePath).deletingLastPathComponent() urls.append(dir.appendingPathComponent("\(sessionId).jsonl")) } - let home = FileManager.default.homeDirectoryForCurrentUser + let home = FileManager().homeDirectoryForCurrentUser urls.append(home.appendingPathComponent(".clawdbot/sessions/\(sessionId).jsonl")) return urls }() - let existing = candidates.first(where: { FileManager.default.fileExists(atPath: $0.path) }) + let existing = candidates.first(where: { FileManager().fileExists(atPath: $0.path) }) guard let url = existing else { let alert = NSAlert() alert.messageText = "Session log not found" diff --git a/apps/macos/Sources/Clawdbot/SessionData.swift b/apps/macos/Sources/Clawdbot/SessionData.swift index 7ce1dc8fc..d4c85a4a2 100644 --- a/apps/macos/Sources/Clawdbot/SessionData.swift +++ b/apps/macos/Sources/Clawdbot/SessionData.swift @@ -246,7 +246,7 @@ enum SessionLoader { static let fallbackContextTokens = 200_000 static let defaultStorePath = standardize( - FileManager.default.homeDirectoryForCurrentUser + FileManager().homeDirectoryForCurrentUser .appendingPathComponent(".clawdbot/sessions/sessions.json").path) static func loadSnapshot( diff --git a/apps/macos/Sources/Clawdbot/SoundEffects.swift b/apps/macos/Sources/Clawdbot/SoundEffects.swift index c1f0cb9b4..b32123829 100644 --- a/apps/macos/Sources/Clawdbot/SoundEffects.swift +++ b/apps/macos/Sources/Clawdbot/SoundEffects.swift @@ -44,7 +44,7 @@ enum SoundEffectCatalog { ] private static let searchRoots: [URL] = [ - FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent("Library/Sounds"), + FileManager().homeDirectoryForCurrentUser.appendingPathComponent("Library/Sounds"), URL(fileURLWithPath: "/Library/Sounds"), URL(fileURLWithPath: "/System/Applications/Mail.app/Contents/Resources"), // Mail “swoosh” URL(fileURLWithPath: "/System/Library/Sounds"), @@ -53,7 +53,7 @@ enum SoundEffectCatalog { private static let discoveredSoundMap: [String: URL] = { var map: [String: URL] = [:] for root in Self.searchRoots { - guard let contents = try? FileManager.default.contentsOfDirectory( + guard let contents = try? FileManager().contentsOfDirectory( at: root, includingPropertiesForKeys: nil, options: [.skipsHiddenFiles]) diff --git a/apps/macos/Sources/Clawdbot/TailscaleService.swift b/apps/macos/Sources/Clawdbot/TailscaleService.swift index 262ac371e..53a3b7109 100644 --- a/apps/macos/Sources/Clawdbot/TailscaleService.swift +++ b/apps/macos/Sources/Clawdbot/TailscaleService.swift @@ -53,7 +53,7 @@ final class TailscaleService { #endif func checkAppInstallation() -> Bool { - let installed = FileManager.default.fileExists(atPath: "/Applications/Tailscale.app") + let installed = FileManager().fileExists(atPath: "/Applications/Tailscale.app") self.logger.info("Tailscale app installed: \(installed)") return installed } diff --git a/apps/macos/Sources/Clawdbot/VoicePushToTalk.swift b/apps/macos/Sources/Clawdbot/VoicePushToTalk.swift index 5984a8641..2bb1ec1f5 100644 --- a/apps/macos/Sources/Clawdbot/VoicePushToTalk.swift +++ b/apps/macos/Sources/Clawdbot/VoicePushToTalk.swift @@ -37,7 +37,7 @@ final class VoicePushToTalkHotkey: @unchecked Sendable { } private func startMonitoring() { - assert(Thread.isMainThread) + // assert(Thread.isMainThread) - Removed for Swift 6 guard self.globalMonitor == nil, self.localMonitor == nil else { return } // Listen-only global monitor; we rely on Input Monitoring permission to receive events. self.globalMonitor = NSEvent.addGlobalMonitorForEvents(matching: .flagsChanged) { [weak self] event in @@ -55,7 +55,7 @@ final class VoicePushToTalkHotkey: @unchecked Sendable { } private func stopMonitoring() { - assert(Thread.isMainThread) + // assert(Thread.isMainThread) - Removed for Swift 6 if let globalMonitor { NSEvent.removeMonitor(globalMonitor) self.globalMonitor = nil @@ -75,15 +75,11 @@ final class VoicePushToTalkHotkey: @unchecked Sendable { } private func withMainThread(_ block: @escaping @Sendable () -> Void) { - if Thread.isMainThread { - block() - } else { - DispatchQueue.main.async(execute: block) - } + DispatchQueue.main.async(execute: block) } private func updateModifierState(keyCode: UInt16, modifierFlags: NSEvent.ModifierFlags) { - assert(Thread.isMainThread) + // assert(Thread.isMainThread) - Removed for Swift 6 // Right Option (keyCode 61) acts as a hold-to-talk modifier. if keyCode == 61 { self.optionDown = modifierFlags.contains(.option) diff --git a/apps/macos/Sources/ClawdbotIPC/IPC.swift b/apps/macos/Sources/ClawdbotIPC/IPC.swift index dc066a10d..5ad0afcd1 100644 --- a/apps/macos/Sources/ClawdbotIPC/IPC.swift +++ b/apps/macos/Sources/ClawdbotIPC/IPC.swift @@ -409,7 +409,7 @@ extension Request: Codable { // Shared transport settings public let controlSocketPath = - FileManager.default + FileManager() .homeDirectoryForCurrentUser .appendingPathComponent("Library/Application Support/clawdbot/control.sock") .path diff --git a/apps/macos/Sources/ClawdbotWizardCLI/main.swift b/apps/macos/Sources/ClawdbotWizardCLI/main.swift index bf328b87c..b94053f65 100644 --- a/apps/macos/Sources/ClawdbotWizardCLI/main.swift +++ b/apps/macos/Sources/ClawdbotWizardCLI/main.swift @@ -187,7 +187,7 @@ private func resolvedPassword(opts: WizardCliOptions, config: GatewayConfig) -> } private func loadGatewayConfig() -> GatewayConfig { - let url = FileManager.default.homeDirectoryForCurrentUser + let url = FileManager().homeDirectoryForCurrentUser .appendingPathComponent(".clawdbot") .appendingPathComponent("clawdbot.json") guard let data = try? Data(contentsOf: url) else { return GatewayConfig() } diff --git a/apps/macos/Tests/ClawdbotIPCTests/AgentWorkspaceTests.swift b/apps/macos/Tests/ClawdbotIPCTests/AgentWorkspaceTests.swift index 320a44c39..9368f643c 100644 --- a/apps/macos/Tests/ClawdbotIPCTests/AgentWorkspaceTests.swift +++ b/apps/macos/Tests/ClawdbotIPCTests/AgentWorkspaceTests.swift @@ -6,7 +6,7 @@ import Testing struct AgentWorkspaceTests { @Test func displayPathUsesTildeForHome() { - let home = FileManager.default.homeDirectoryForCurrentUser + let home = FileManager().homeDirectoryForCurrentUser #expect(AgentWorkspace.displayPath(for: home) == "~") let inside = home.appendingPathComponent("Projects", isDirectory: true) @@ -28,12 +28,12 @@ struct AgentWorkspaceTests { @Test func bootstrapCreatesAgentsFileWhenMissing() throws { - let tmp = FileManager.default.temporaryDirectory + let tmp = FileManager().temporaryDirectory .appendingPathComponent("clawdbot-ws-\(UUID().uuidString)", isDirectory: true) - defer { try? FileManager.default.removeItem(at: tmp) } + defer { try? FileManager().removeItem(at: tmp) } let agentsURL = try AgentWorkspace.bootstrap(workspaceURL: tmp) - #expect(FileManager.default.fileExists(atPath: agentsURL.path)) + #expect(FileManager().fileExists(atPath: agentsURL.path)) let contents = try String(contentsOf: agentsURL, encoding: .utf8) #expect(contents.contains("# AGENTS.md")) @@ -41,9 +41,9 @@ struct AgentWorkspaceTests { let identityURL = tmp.appendingPathComponent(AgentWorkspace.identityFilename) let userURL = tmp.appendingPathComponent(AgentWorkspace.userFilename) let bootstrapURL = tmp.appendingPathComponent(AgentWorkspace.bootstrapFilename) - #expect(FileManager.default.fileExists(atPath: identityURL.path)) - #expect(FileManager.default.fileExists(atPath: userURL.path)) - #expect(FileManager.default.fileExists(atPath: bootstrapURL.path)) + #expect(FileManager().fileExists(atPath: identityURL.path)) + #expect(FileManager().fileExists(atPath: userURL.path)) + #expect(FileManager().fileExists(atPath: bootstrapURL.path)) let second = try AgentWorkspace.bootstrap(workspaceURL: tmp) #expect(second == agentsURL) @@ -51,10 +51,10 @@ struct AgentWorkspaceTests { @Test func bootstrapSafetyRejectsNonEmptyFolderWithoutAgents() throws { - let tmp = FileManager.default.temporaryDirectory + let tmp = FileManager().temporaryDirectory .appendingPathComponent("clawdbot-ws-\(UUID().uuidString)", isDirectory: true) - defer { try? FileManager.default.removeItem(at: tmp) } - try FileManager.default.createDirectory(at: tmp, withIntermediateDirectories: true) + defer { try? FileManager().removeItem(at: tmp) } + try FileManager().createDirectory(at: tmp, withIntermediateDirectories: true) let marker = tmp.appendingPathComponent("notes.txt") try "hello".write(to: marker, atomically: true, encoding: .utf8) @@ -69,10 +69,10 @@ struct AgentWorkspaceTests { @Test func bootstrapSafetyAllowsExistingAgentsFile() throws { - let tmp = FileManager.default.temporaryDirectory + let tmp = FileManager().temporaryDirectory .appendingPathComponent("clawdbot-ws-\(UUID().uuidString)", isDirectory: true) - defer { try? FileManager.default.removeItem(at: tmp) } - try FileManager.default.createDirectory(at: tmp, withIntermediateDirectories: true) + defer { try? FileManager().removeItem(at: tmp) } + try FileManager().createDirectory(at: tmp, withIntermediateDirectories: true) let agents = tmp.appendingPathComponent(AgentWorkspace.agentsFilename) try "# AGENTS.md".write(to: agents, atomically: true, encoding: .utf8) @@ -87,25 +87,25 @@ struct AgentWorkspaceTests { @Test func bootstrapSkipsBootstrapFileWhenWorkspaceHasContent() throws { - let tmp = FileManager.default.temporaryDirectory + let tmp = FileManager().temporaryDirectory .appendingPathComponent("clawdbot-ws-\(UUID().uuidString)", isDirectory: true) - defer { try? FileManager.default.removeItem(at: tmp) } - try FileManager.default.createDirectory(at: tmp, withIntermediateDirectories: true) + defer { try? FileManager().removeItem(at: tmp) } + try FileManager().createDirectory(at: tmp, withIntermediateDirectories: true) let marker = tmp.appendingPathComponent("notes.txt") try "hello".write(to: marker, atomically: true, encoding: .utf8) _ = try AgentWorkspace.bootstrap(workspaceURL: tmp) let bootstrapURL = tmp.appendingPathComponent(AgentWorkspace.bootstrapFilename) - #expect(!FileManager.default.fileExists(atPath: bootstrapURL.path)) + #expect(!FileManager().fileExists(atPath: bootstrapURL.path)) } @Test func needsBootstrapFalseWhenIdentityAlreadySet() throws { - let tmp = FileManager.default.temporaryDirectory + let tmp = FileManager().temporaryDirectory .appendingPathComponent("clawdbot-ws-\(UUID().uuidString)", isDirectory: true) - defer { try? FileManager.default.removeItem(at: tmp) } - try FileManager.default.createDirectory(at: tmp, withIntermediateDirectories: true) + defer { try? FileManager().removeItem(at: tmp) } + try FileManager().createDirectory(at: tmp, withIntermediateDirectories: true) let identityURL = tmp.appendingPathComponent(AgentWorkspace.identityFilename) try """ # IDENTITY.md - Agent Identity diff --git a/apps/macos/Tests/ClawdbotIPCTests/AnthropicAuthResolverTests.swift b/apps/macos/Tests/ClawdbotIPCTests/AnthropicAuthResolverTests.swift index 41f9ffefb..1af9108c2 100644 --- a/apps/macos/Tests/ClawdbotIPCTests/AnthropicAuthResolverTests.swift +++ b/apps/macos/Tests/ClawdbotIPCTests/AnthropicAuthResolverTests.swift @@ -6,9 +6,9 @@ import Testing struct AnthropicAuthResolverTests { @Test func prefersOAuthFileOverEnv() throws { - let dir = FileManager.default.temporaryDirectory + let dir = FileManager().temporaryDirectory .appendingPathComponent("clawdbot-oauth-\(UUID().uuidString)", isDirectory: true) - try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + try FileManager().createDirectory(at: dir, withIntermediateDirectories: true) let oauthFile = dir.appendingPathComponent("oauth.json") let payload = [ "anthropic": [ diff --git a/apps/macos/Tests/ClawdbotIPCTests/CLIInstallerTests.swift b/apps/macos/Tests/ClawdbotIPCTests/CLIInstallerTests.swift index 829dd28a4..46144a455 100644 --- a/apps/macos/Tests/ClawdbotIPCTests/CLIInstallerTests.swift +++ b/apps/macos/Tests/ClawdbotIPCTests/CLIInstallerTests.swift @@ -6,7 +6,7 @@ import Testing @MainActor struct CLIInstallerTests { @Test func installedLocationFindsExecutable() throws { - let fm = FileManager.default + let fm = FileManager() let root = fm.temporaryDirectory.appendingPathComponent( "clawdbot-cli-installer-\(UUID().uuidString)") defer { try? fm.removeItem(at: root) } diff --git a/apps/macos/Tests/ClawdbotIPCTests/CanvasFileWatcherTests.swift b/apps/macos/Tests/ClawdbotIPCTests/CanvasFileWatcherTests.swift index 03fe58fda..28093abc8 100644 --- a/apps/macos/Tests/ClawdbotIPCTests/CanvasFileWatcherTests.swift +++ b/apps/macos/Tests/ClawdbotIPCTests/CanvasFileWatcherTests.swift @@ -7,13 +7,13 @@ import Testing private func makeTempDir() throws -> URL { let base = URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true) let dir = base.appendingPathComponent("clawdbot-canvaswatch-\(UUID().uuidString)", isDirectory: true) - try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + try FileManager().createDirectory(at: dir, withIntermediateDirectories: true) return dir } @Test func detectsInPlaceFileWrites() async throws { let dir = try self.makeTempDir() - defer { try? FileManager.default.removeItem(at: dir) } + defer { try? FileManager().removeItem(at: dir) } let file = dir.appendingPathComponent("index.html") try "hello".write(to: file, atomically: false, encoding: .utf8) diff --git a/apps/macos/Tests/ClawdbotIPCTests/CanvasWindowSmokeTests.swift b/apps/macos/Tests/ClawdbotIPCTests/CanvasWindowSmokeTests.swift index 1168802b2..92f20bf0b 100644 --- a/apps/macos/Tests/ClawdbotIPCTests/CanvasWindowSmokeTests.swift +++ b/apps/macos/Tests/ClawdbotIPCTests/CanvasWindowSmokeTests.swift @@ -8,10 +8,10 @@ import Testing @MainActor struct CanvasWindowSmokeTests { @Test func panelControllerShowsAndHides() async throws { - let root = FileManager.default.temporaryDirectory + let root = FileManager().temporaryDirectory .appendingPathComponent("clawdbot-canvas-test-\(UUID().uuidString)") - try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) - defer { try? FileManager.default.removeItem(at: root) } + try FileManager().createDirectory(at: root, withIntermediateDirectories: true) + defer { try? FileManager().removeItem(at: root) } let anchor = { NSRect(x: 200, y: 400, width: 40, height: 40) } let controller = try CanvasWindowController( @@ -31,10 +31,10 @@ struct CanvasWindowSmokeTests { } @Test func windowControllerShowsAndCloses() async throws { - let root = FileManager.default.temporaryDirectory + let root = FileManager().temporaryDirectory .appendingPathComponent("clawdbot-canvas-test-\(UUID().uuidString)") - try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) - defer { try? FileManager.default.removeItem(at: root) } + try FileManager().createDirectory(at: root, withIntermediateDirectories: true) + defer { try? FileManager().removeItem(at: root) } let controller = try CanvasWindowController( sessionKey: "main", diff --git a/apps/macos/Tests/ClawdbotIPCTests/ClawdbotConfigFileTests.swift b/apps/macos/Tests/ClawdbotIPCTests/ClawdbotConfigFileTests.swift index 513117c72..15a0f3905 100644 --- a/apps/macos/Tests/ClawdbotIPCTests/ClawdbotConfigFileTests.swift +++ b/apps/macos/Tests/ClawdbotIPCTests/ClawdbotConfigFileTests.swift @@ -6,7 +6,7 @@ import Testing struct ClawdbotConfigFileTests { @Test func configPathRespectsEnvOverride() async { - let override = FileManager.default.temporaryDirectory + let override = FileManager().temporaryDirectory .appendingPathComponent("clawdbot-config-\(UUID().uuidString)") .appendingPathComponent("clawdbot.json") .path @@ -19,7 +19,7 @@ struct ClawdbotConfigFileTests { @MainActor @Test func remoteGatewayPortParsesAndMatchesHost() async { - let override = FileManager.default.temporaryDirectory + let override = FileManager().temporaryDirectory .appendingPathComponent("clawdbot-config-\(UUID().uuidString)") .appendingPathComponent("clawdbot.json") .path @@ -42,7 +42,7 @@ struct ClawdbotConfigFileTests { @MainActor @Test func setRemoteGatewayUrlPreservesScheme() async { - let override = FileManager.default.temporaryDirectory + let override = FileManager().temporaryDirectory .appendingPathComponent("clawdbot-config-\(UUID().uuidString)") .appendingPathComponent("clawdbot.json") .path @@ -64,7 +64,7 @@ struct ClawdbotConfigFileTests { @Test func stateDirOverrideSetsConfigPath() async { - let dir = FileManager.default.temporaryDirectory + let dir = FileManager().temporaryDirectory .appendingPathComponent("clawdbot-state-\(UUID().uuidString)", isDirectory: true) .path diff --git a/apps/macos/Tests/ClawdbotIPCTests/ClawdbotOAuthStoreTests.swift b/apps/macos/Tests/ClawdbotIPCTests/ClawdbotOAuthStoreTests.swift index 4f4c8542a..0fcfdec84 100644 --- a/apps/macos/Tests/ClawdbotIPCTests/ClawdbotOAuthStoreTests.swift +++ b/apps/macos/Tests/ClawdbotIPCTests/ClawdbotOAuthStoreTests.swift @@ -6,7 +6,7 @@ import Testing struct ClawdbotOAuthStoreTests { @Test func returnsMissingWhenFileAbsent() { - let url = FileManager.default.temporaryDirectory + let url = FileManager().temporaryDirectory .appendingPathComponent("clawdbot-oauth-\(UUID().uuidString)") .appendingPathComponent("oauth.json") #expect(ClawdbotOAuthStore.anthropicOAuthStatus(at: url) == .missingFile) @@ -24,7 +24,7 @@ struct ClawdbotOAuthStoreTests { } } - let dir = FileManager.default.temporaryDirectory + let dir = FileManager().temporaryDirectory .appendingPathComponent("clawdbot-oauth-\(UUID().uuidString)", isDirectory: true) setenv(key, dir.path, 1) @@ -85,9 +85,9 @@ struct ClawdbotOAuthStoreTests { } private func writeOAuthFile(_ json: [String: Any]) throws -> URL { - let dir = FileManager.default.temporaryDirectory + let dir = FileManager().temporaryDirectory .appendingPathComponent("clawdbot-oauth-\(UUID().uuidString)", isDirectory: true) - try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + try FileManager().createDirectory(at: dir, withIntermediateDirectories: true) let url = dir.appendingPathComponent("oauth.json") let data = try JSONSerialization.data(withJSONObject: json, options: [.prettyPrinted, .sortedKeys]) diff --git a/apps/macos/Tests/ClawdbotIPCTests/CommandResolverTests.swift b/apps/macos/Tests/ClawdbotIPCTests/CommandResolverTests.swift index 8feb00f12..827057888 100644 --- a/apps/macos/Tests/ClawdbotIPCTests/CommandResolverTests.swift +++ b/apps/macos/Tests/ClawdbotIPCTests/CommandResolverTests.swift @@ -12,16 +12,16 @@ import Testing private func makeTempDir() throws -> URL { let base = URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true) let dir = base.appendingPathComponent(UUID().uuidString, isDirectory: true) - try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + try FileManager().createDirectory(at: dir, withIntermediateDirectories: true) return dir } private func makeExec(at path: URL) throws { - try FileManager.default.createDirectory( + try FileManager().createDirectory( at: path.deletingLastPathComponent(), withIntermediateDirectories: true) - FileManager.default.createFile(atPath: path.path, contents: Data("echo ok\n".utf8)) - try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: path.path) + FileManager().createFile(atPath: path.path, contents: Data("echo ok\n".utf8)) + try FileManager().setAttributes([.posixPermissions: 0o755], ofItemAtPath: path.path) } @Test func prefersClawdbotBinary() async throws { @@ -49,7 +49,7 @@ import Testing let scriptPath = tmp.appendingPathComponent("bin/clawdbot.js") try self.makeExec(at: nodePath) try "#!/bin/sh\necho v22.0.0\n".write(to: nodePath, atomically: true, encoding: .utf8) - try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: nodePath.path) + try FileManager().setAttributes([.posixPermissions: 0o755], ofItemAtPath: nodePath.path) try self.makeExec(at: scriptPath) let cmd = CommandResolver.clawdbotCommand( diff --git a/apps/macos/Tests/ClawdbotIPCTests/FileHandleLegacyAPIGuardTests.swift b/apps/macos/Tests/ClawdbotIPCTests/FileHandleLegacyAPIGuardTests.swift index 5e30cf584..2de87f693 100644 --- a/apps/macos/Tests/ClawdbotIPCTests/FileHandleLegacyAPIGuardTests.swift +++ b/apps/macos/Tests/ClawdbotIPCTests/FileHandleLegacyAPIGuardTests.swift @@ -32,7 +32,7 @@ import Testing } private static func swiftFiles(under root: URL) throws -> [URL] { - let fm = FileManager.default + let fm = FileManager() guard let enumerator = fm.enumerator(at: root, includingPropertiesForKeys: [.isRegularFileKey]) else { return [] } diff --git a/apps/macos/Tests/ClawdbotIPCTests/GatewayLaunchAgentManagerTests.swift b/apps/macos/Tests/ClawdbotIPCTests/GatewayLaunchAgentManagerTests.swift index ae8357b0c..af41a2a8e 100644 --- a/apps/macos/Tests/ClawdbotIPCTests/GatewayLaunchAgentManagerTests.swift +++ b/apps/macos/Tests/ClawdbotIPCTests/GatewayLaunchAgentManagerTests.swift @@ -4,7 +4,7 @@ import Testing @Suite struct GatewayLaunchAgentManagerTests { @Test func launchAgentPlistSnapshotParsesArgsAndEnv() throws { - let url = FileManager.default.temporaryDirectory + let url = FileManager().temporaryDirectory .appendingPathComponent("clawdbot-launchd-\(UUID().uuidString).plist") let plist: [String: Any] = [ "ProgramArguments": ["clawdbot", "gateway-daemon", "--port", "18789", "--bind", "loopback"], @@ -15,7 +15,7 @@ import Testing ] let data = try PropertyListSerialization.data(fromPropertyList: plist, format: .xml, options: 0) try data.write(to: url, options: [.atomic]) - defer { try? FileManager.default.removeItem(at: url) } + defer { try? FileManager().removeItem(at: url) } let snapshot = try #require(LaunchAgentPlist.snapshot(url: url)) #expect(snapshot.port == 18789) @@ -25,14 +25,14 @@ import Testing } @Test func launchAgentPlistSnapshotAllowsMissingBind() throws { - let url = FileManager.default.temporaryDirectory + let url = FileManager().temporaryDirectory .appendingPathComponent("clawdbot-launchd-\(UUID().uuidString).plist") let plist: [String: Any] = [ "ProgramArguments": ["clawdbot", "gateway-daemon", "--port", "18789"], ] let data = try PropertyListSerialization.data(fromPropertyList: plist, format: .xml, options: 0) try data.write(to: url, options: [.atomic]) - defer { try? FileManager.default.removeItem(at: url) } + defer { try? FileManager().removeItem(at: url) } let snapshot = try #require(LaunchAgentPlist.snapshot(url: url)) #expect(snapshot.port == 18789) diff --git a/apps/macos/Tests/ClawdbotIPCTests/LogLocatorTests.swift b/apps/macos/Tests/ClawdbotIPCTests/LogLocatorTests.swift index bc29f9e2b..6d87208ea 100644 --- a/apps/macos/Tests/ClawdbotIPCTests/LogLocatorTests.swift +++ b/apps/macos/Tests/ClawdbotIPCTests/LogLocatorTests.swift @@ -5,7 +5,7 @@ import Testing @Suite struct LogLocatorTests { @Test func launchdGatewayLogPathEnsuresTmpDirExists() throws { - let fm = FileManager.default + let fm = FileManager() let baseDir = URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true) let logDir = baseDir.appendingPathComponent("clawdbot-tests-\(UUID().uuidString)") diff --git a/apps/macos/Tests/ClawdbotIPCTests/LowCoverageHelperTests.swift b/apps/macos/Tests/ClawdbotIPCTests/LowCoverageHelperTests.swift index b68f98cb9..7d934d6e0 100644 --- a/apps/macos/Tests/ClawdbotIPCTests/LowCoverageHelperTests.swift +++ b/apps/macos/Tests/ClawdbotIPCTests/LowCoverageHelperTests.swift @@ -77,9 +77,9 @@ struct LowCoverageHelperTests { } @Test func pairedNodesStorePersists() async throws { - let dir = FileManager.default.temporaryDirectory + let dir = FileManager().temporaryDirectory .appendingPathComponent("paired-\(UUID().uuidString)", isDirectory: true) - try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + try FileManager().createDirectory(at: dir, withIntermediateDirectories: true) let url = dir.appendingPathComponent("nodes.json") let store = PairedNodesStore(fileURL: url) await store.load() @@ -143,12 +143,12 @@ struct LowCoverageHelperTests { } @Test @MainActor func canvasSchemeHandlerResolvesFilesAndErrors() throws { - let root = FileManager.default.temporaryDirectory + let root = FileManager().temporaryDirectory .appendingPathComponent("canvas-\(UUID().uuidString)", isDirectory: true) - defer { try? FileManager.default.removeItem(at: root) } - try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? FileManager().removeItem(at: root) } + try FileManager().createDirectory(at: root, withIntermediateDirectories: true) let session = root.appendingPathComponent("main", isDirectory: true) - try FileManager.default.createDirectory(at: session, withIntermediateDirectories: true) + try FileManager().createDirectory(at: session, withIntermediateDirectories: true) let index = session.appendingPathComponent("index.html") try "

Hello

".write(to: index, atomically: true, encoding: .utf8) diff --git a/apps/macos/Tests/ClawdbotIPCTests/MacNodeRuntimeTests.swift b/apps/macos/Tests/ClawdbotIPCTests/MacNodeRuntimeTests.swift index 12d03c185..6c7343725 100644 --- a/apps/macos/Tests/ClawdbotIPCTests/MacNodeRuntimeTests.swift +++ b/apps/macos/Tests/ClawdbotIPCTests/MacNodeRuntimeTests.swift @@ -59,7 +59,7 @@ struct MacNodeRuntimeTests { includeAudio: Bool?, outPath: String?) async throws -> (path: String, hasAudio: Bool) { - let url = FileManager.default.temporaryDirectory + let url = FileManager().temporaryDirectory .appendingPathComponent("clawdbot-test-screen-record-\(UUID().uuidString).mp4") try Data("ok".utf8).write(to: url) return (path: url.path, hasAudio: false) diff --git a/apps/macos/Tests/ClawdbotIPCTests/ModelCatalogLoaderTests.swift b/apps/macos/Tests/ClawdbotIPCTests/ModelCatalogLoaderTests.swift index d446dad64..7b87dc5ec 100644 --- a/apps/macos/Tests/ClawdbotIPCTests/ModelCatalogLoaderTests.swift +++ b/apps/macos/Tests/ClawdbotIPCTests/ModelCatalogLoaderTests.swift @@ -19,9 +19,9 @@ struct ModelCatalogLoaderTests { }; """ - let tmp = FileManager.default.temporaryDirectory + let tmp = FileManager().temporaryDirectory .appendingPathComponent("models-\(UUID().uuidString).ts") - defer { try? FileManager.default.removeItem(at: tmp) } + defer { try? FileManager().removeItem(at: tmp) } try src.write(to: tmp, atomically: true, encoding: .utf8) let choices = try await ModelCatalogLoader.load(from: tmp.path) @@ -42,9 +42,9 @@ struct ModelCatalogLoaderTests { @Test func loadWithNoExportReturnsEmptyChoices() async throws { let src = "const NOPE = 1;" - let tmp = FileManager.default.temporaryDirectory + let tmp = FileManager().temporaryDirectory .appendingPathComponent("models-\(UUID().uuidString).ts") - defer { try? FileManager.default.removeItem(at: tmp) } + defer { try? FileManager().removeItem(at: tmp) } try src.write(to: tmp, atomically: true, encoding: .utf8) let choices = try await ModelCatalogLoader.load(from: tmp.path) diff --git a/apps/macos/Tests/ClawdbotIPCTests/NodeManagerPathsTests.swift b/apps/macos/Tests/ClawdbotIPCTests/NodeManagerPathsTests.swift index 1300cbf7e..bd4a3c0f9 100644 --- a/apps/macos/Tests/ClawdbotIPCTests/NodeManagerPathsTests.swift +++ b/apps/macos/Tests/ClawdbotIPCTests/NodeManagerPathsTests.swift @@ -6,16 +6,16 @@ import Testing private func makeTempDir() throws -> URL { let base = URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true) let dir = base.appendingPathComponent(UUID().uuidString, isDirectory: true) - try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + try FileManager().createDirectory(at: dir, withIntermediateDirectories: true) return dir } private func makeExec(at path: URL) throws { - try FileManager.default.createDirectory( + try FileManager().createDirectory( at: path.deletingLastPathComponent(), withIntermediateDirectories: true) - FileManager.default.createFile(atPath: path.path, contents: Data("echo ok\n".utf8)) - try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: path.path) + FileManager().createFile(atPath: path.path, contents: Data("echo ok\n".utf8)) + try FileManager().setAttributes([.posixPermissions: 0o755], ofItemAtPath: path.path) } @Test func fnmNodeBinsPreferNewestInstalledVersion() throws { @@ -37,7 +37,7 @@ import Testing let home = try self.makeTempDir() let missingNodeBin = home .appendingPathComponent(".local/share/fnm/node-versions/v99.0.0/installation/bin") - try FileManager.default.createDirectory(at: missingNodeBin, withIntermediateDirectories: true) + try FileManager().createDirectory(at: missingNodeBin, withIntermediateDirectories: true) let bins = CommandResolver._testNodeManagerBinPaths(home: home) #expect(!bins.contains(missingNodeBin.path)) diff --git a/apps/macos/Tests/ClawdbotIPCTests/RuntimeLocatorTests.swift b/apps/macos/Tests/ClawdbotIPCTests/RuntimeLocatorTests.swift index c1bcc957f..81d2c7494 100644 --- a/apps/macos/Tests/ClawdbotIPCTests/RuntimeLocatorTests.swift +++ b/apps/macos/Tests/ClawdbotIPCTests/RuntimeLocatorTests.swift @@ -6,10 +6,10 @@ import Testing private func makeTempExecutable(contents: String) throws -> URL { let dir = URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true) .appendingPathComponent(UUID().uuidString, isDirectory: true) - try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + try FileManager().createDirectory(at: dir, withIntermediateDirectories: true) let path = dir.appendingPathComponent("node") try contents.write(to: path, atomically: true, encoding: .utf8) - try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: path.path) + try FileManager().setAttributes([.posixPermissions: 0o755], ofItemAtPath: path.path) return path } diff --git a/apps/macos/Tests/ClawdbotIPCTests/TestIsolation.swift b/apps/macos/Tests/ClawdbotIPCTests/TestIsolation.swift index c15606a06..6207df141 100644 --- a/apps/macos/Tests/ClawdbotIPCTests/TestIsolation.swift +++ b/apps/macos/Tests/ClawdbotIPCTests/TestIsolation.swift @@ -109,7 +109,7 @@ enum TestIsolation { } nonisolated static func tempConfigPath() -> String { - FileManager.default.temporaryDirectory + FileManager().temporaryDirectory .appendingPathComponent("clawdbot-test-config-\(UUID().uuidString).json") .path } diff --git a/apps/macos/Tests/ClawdbotIPCTests/UtilitiesTests.swift b/apps/macos/Tests/ClawdbotIPCTests/UtilitiesTests.swift index 4a01e8aee..7b8753ef1 100644 --- a/apps/macos/Tests/ClawdbotIPCTests/UtilitiesTests.swift +++ b/apps/macos/Tests/ClawdbotIPCTests/UtilitiesTests.swift @@ -47,17 +47,17 @@ import Testing .appendingPathComponent(UUID().uuidString, isDirectory: true) let dist = tmp.appendingPathComponent("dist/index.js") let bin = tmp.appendingPathComponent("bin/clawdbot.js") - try FileManager.default.createDirectory(at: dist.deletingLastPathComponent(), withIntermediateDirectories: true) - try FileManager.default.createDirectory(at: bin.deletingLastPathComponent(), withIntermediateDirectories: true) - FileManager.default.createFile(atPath: dist.path, contents: Data()) - FileManager.default.createFile(atPath: bin.path, contents: Data()) + try FileManager().createDirectory(at: dist.deletingLastPathComponent(), withIntermediateDirectories: true) + try FileManager().createDirectory(at: bin.deletingLastPathComponent(), withIntermediateDirectories: true) + FileManager().createFile(atPath: dist.path, contents: Data()) + FileManager().createFile(atPath: bin.path, contents: Data()) let entry = CommandResolver.gatewayEntrypoint(in: tmp) #expect(entry == dist.path) } @Test func logLocatorPicksNewestLogFile() throws { - let fm = FileManager.default + let fm = FileManager() let dir = URL(fileURLWithPath: "/tmp/clawdbot", isDirectory: true) try? fm.createDirectory(at: dir, withIntermediateDirectories: true) diff --git a/apps/shared/ClawdbotKit/Sources/ClawdbotKit/StoragePaths.swift b/apps/shared/ClawdbotKit/Sources/ClawdbotKit/StoragePaths.swift index bcea1aaab..b785efee7 100644 --- a/apps/shared/ClawdbotKit/Sources/ClawdbotKit/StoragePaths.swift +++ b/apps/shared/ClawdbotKit/Sources/ClawdbotKit/StoragePaths.swift @@ -2,7 +2,7 @@ import Foundation public enum ClawdbotNodeStorage { public static func appSupportDir() throws -> URL { - let base = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first + let base = FileManager().urls(for: .applicationSupportDirectory, in: .userDomainMask).first guard let base else { throw NSError(domain: "ClawdbotNodeStorage", code: 1, userInfo: [ NSLocalizedDescriptionKey: "Application Support directory unavailable", @@ -19,7 +19,7 @@ public enum ClawdbotNodeStorage { } public static func cachesDir() throws -> URL { - let base = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first + let base = FileManager().urls(for: .cachesDirectory, in: .userDomainMask).first guard let base else { throw NSError(domain: "ClawdbotNodeStorage", code: 2, userInfo: [ NSLocalizedDescriptionKey: "Caches directory unavailable", diff --git a/docs/experiments/plans/openresponses-gateway.md b/docs/experiments/plans/openresponses-gateway.md new file mode 100644 index 000000000..96b9be671 --- /dev/null +++ b/docs/experiments/plans/openresponses-gateway.md @@ -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`. diff --git a/docs/gateway/configuration.md b/docs/gateway/configuration.md index 0d7f7ce74..e89af4b54 100644 --- a/docs/gateway/configuration.md +++ b/docs/gateway/configuration.md @@ -2669,6 +2669,7 @@ Notes: - `clawdbot gateway` refuses to start unless `gateway.mode` is set to `local` (or you pass the override flag). - `gateway.port` controls the single multiplexed port used for WebSocket + HTTP (control UI, hooks, A2UI). - OpenAI Chat Completions endpoint: **disabled by default**; enable with `gateway.http.endpoints.chatCompletions.enabled: true`. +- OpenResponses endpoint: **disabled by default**; enable with `gateway.http.endpoints.responses.enabled: true`. - Precedence: `--port` > `CLAWDBOT_GATEWAY_PORT` > `gateway.port` > default `18789`. - Non-loopback binds (`lan`/`tailnet`/`auto`) require auth. Use `gateway.auth.token` (or `CLAWDBOT_GATEWAY_TOKEN`). - The onboarding wizard generates a gateway token by default (even on loopback). diff --git a/docs/gateway/index.md b/docs/gateway/index.md index fb19a1263..83ed93952 100644 --- a/docs/gateway/index.md +++ b/docs/gateway/index.md @@ -29,6 +29,7 @@ pnpm gateway:watch - Binds WebSocket control plane to `127.0.0.1:` (default 18789). - The same port also serves HTTP (control UI, hooks, A2UI). Single-port multiplex. - OpenAI Chat Completions (HTTP): [`/v1/chat/completions`](/gateway/openai-http-api). + - OpenResponses (HTTP): [`/v1/responses`](/gateway/openresponses-http-api). - Starts a Canvas file server by default on `canvasHost.port` (default `18793`), serving `http://:18793/__clawdbot__/canvas/` from `~/clawd/canvas`. Disable with `canvasHost.enabled=false` or `CLAWDBOT_SKIP_CANVAS_HOST=1`. - Logs to stdout; use launchd/systemd to keep it alive and rotate logs. - Pass `--verbose` to mirror debug logging (handshakes, req/res, events) from the log file into stdio when troubleshooting. diff --git a/docs/gateway/openresponses-http-api.md b/docs/gateway/openresponses-http-api.md new file mode 100644 index 000000000..5abf1f445 --- /dev/null +++ b/docs/gateway/openresponses-http-api.md @@ -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://:/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 ` + +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:"` (example: `"clawdbot:main"`, `"clawdbot:beta"`) +- `model: "agent:"` (alias) + +Or target a specific Clawdbot agent by header: + +- `x-clawdbot-agent-id: ` (default: `main`) + +Advanced: +- `x-clawdbot-session-key: ` 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: ` and `data: ` +- 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" + }' +``` diff --git a/package.json b/package.json index bf274dee1..c31058980 100644 --- a/package.json +++ b/package.json @@ -155,6 +155,7 @@ "@mariozechner/pi-coding-agent": "^0.46.0", "@mariozechner/pi-tui": "^0.46.0", "@mozilla/readability": "^0.6.0", + "@napi-rs/canvas": "^0.1.88", "@sinclair/typebox": "0.34.47", "@slack/bolt": "^4.6.0", "@slack/web-api": "^7.13.0", @@ -181,6 +182,7 @@ "long": "5.3.2", "markdown-it": "^14.1.0", "osc-progress": "^0.2.0", + "pdfjs-dist": "^5.4.530", "playwright-core": "1.57.0", "proper-lockfile": "^4.1.2", "qrcode-terminal": "^0.12.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c60188274..23e901215 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -49,6 +49,9 @@ importers: '@mozilla/readability': specifier: ^0.6.0 version: 0.6.0 + '@napi-rs/canvas': + specifier: ^0.1.88 + version: 0.1.88 '@sinclair/typebox': specifier: 0.34.47 version: 0.34.47 @@ -127,6 +130,9 @@ importers: osc-progress: specifier: ^0.2.0 version: 0.2.0 + pdfjs-dist: + specifier: ^5.4.530 + version: 5.4.530 playwright-core: specifier: 1.57.0 version: 1.57.0 @@ -1205,6 +1211,76 @@ packages: resolution: {integrity: sha512-juG5VWh4qAivzTAeMzvY9xs9HY5rAcr2E4I7tiSSCokRFi7XIZCAu92ZkSTsIj1OPceCifL3cpfteP3pDT9/QQ==} engines: {node: '>=14.0.0'} + '@napi-rs/canvas-android-arm64@0.1.88': + resolution: {integrity: sha512-KEaClPnZuVxJ8smUWjV1wWFkByBO/D+vy4lN+Dm5DFH514oqwukxKGeck9xcKJhaWJGjfruGmYGiwRe//+/zQQ==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [android] + + '@napi-rs/canvas-darwin-arm64@0.1.88': + resolution: {integrity: sha512-Xgywz0dDxOKSgx3eZnK85WgGMmGrQEW7ZLA/E7raZdlEE+xXCozobgqz2ZvYigpB6DJFYkqnwHjqCOTSDGlFdg==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [darwin] + + '@napi-rs/canvas-darwin-x64@0.1.88': + resolution: {integrity: sha512-Yz4wSCIQOUgNucgk+8NFtQxQxZV5NO8VKRl9ePKE6XoNyNVC8JDqtvhh3b3TPqKK8W5p2EQpAr1rjjm0mfBxdg==} + engines: {node: '>= 10'} + cpu: [x64] + os: [darwin] + + '@napi-rs/canvas-linux-arm-gnueabihf@0.1.88': + resolution: {integrity: sha512-9gQM2SlTo76hYhxHi2XxWTAqpTOb+JtxMPEIr+H5nAhHhyEtNmTSDRtz93SP7mGd2G3Ojf2oF5tP9OdgtgXyKg==} + engines: {node: '>= 10'} + cpu: [arm] + os: [linux] + + '@napi-rs/canvas-linux-arm64-gnu@0.1.88': + resolution: {integrity: sha512-7qgaOBMXuVRk9Fzztzr3BchQKXDxGbY+nwsovD3I/Sx81e+sX0ReEDYHTItNb0Je4NHbAl7D0MKyd4SvUc04sg==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + + '@napi-rs/canvas-linux-arm64-musl@0.1.88': + resolution: {integrity: sha512-kYyNrUsHLkoGHBc77u4Unh067GrfiCUMbGHC2+OTxbeWfZkPt2o32UOQkhnSswKd9Fko/wSqqGkY956bIUzruA==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + + '@napi-rs/canvas-linux-riscv64-gnu@0.1.88': + resolution: {integrity: sha512-HVuH7QgzB0yavYdNZDRyAsn/ejoXB0hn8twwFnOqUbCCdkV+REna7RXjSR7+PdfW0qMQ2YYWsLvVBT5iL/mGpw==} + engines: {node: '>= 10'} + cpu: [riscv64] + os: [linux] + + '@napi-rs/canvas-linux-x64-gnu@0.1.88': + resolution: {integrity: sha512-hvcvKIcPEQrvvJtJnwD35B3qk6umFJ8dFIr8bSymfrSMem0EQsfn1ztys8ETIFndTwdNWJKWluvxztA41ivsEw==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + + '@napi-rs/canvas-linux-x64-musl@0.1.88': + resolution: {integrity: sha512-eSMpGYY2xnZSQ6UxYJ6plDboxq4KeJ4zT5HaVkUnbObNN6DlbJe0Mclh3wifAmquXfrlgTZt6zhHsUgz++AK6g==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + + '@napi-rs/canvas-win32-arm64-msvc@0.1.88': + resolution: {integrity: sha512-qcIFfEgHrchyYqRrxsCeTQgpJZ/GqHiqPcU/Fvw/ARVlQeDX1VyFH+X+0gCR2tca6UJrq96vnW+5o7buCq+erA==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [win32] + + '@napi-rs/canvas-win32-x64-msvc@0.1.88': + resolution: {integrity: sha512-ROVqbfS4QyZxYkqmaIBBpbz/BQvAR+05FXM5PAtTYVc0uyY8Y4BHJSMdGAaMf6TdIVRsQsiq+FG/dH9XhvWCFQ==} + engines: {node: '>= 10'} + cpu: [x64] + os: [win32] + + '@napi-rs/canvas@0.1.88': + resolution: {integrity: sha512-/p08f93LEbsL5mDZFQ3DBxcPv/I4QG9EDYRRq1WNlCOXVfAHBTHMSVMwxlqG/AtnSfUr9+vgfN7MKiyDo0+Weg==} + engines: {node: '>= 10'} + '@napi-rs/wasm-runtime@1.1.1': resolution: {integrity: sha512-p64ah1M1ld8xjWv3qbvFwHiFVWrq1yFvV4f7w+mzaqiR4IlSgkqhcRdHwsGgomwzBH51sRY4NEowLxnaBjcW/A==} @@ -3756,6 +3832,10 @@ packages: pathe@2.0.3: resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + pdfjs-dist@5.4.530: + resolution: {integrity: sha512-r1hWsSIGGmyYUAHR26zSXkxYWLXLMd6AwqcaFYG9YUZ0GBf5GvcjJSeo512tabM4GYFhxhl5pMCmPr7Q72Rq2Q==} + engines: {node: '>=20.16.0 || >=22.3.0'} + picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} @@ -5671,6 +5751,53 @@ snapshots: '@mozilla/readability@0.6.0': {} + '@napi-rs/canvas-android-arm64@0.1.88': + optional: true + + '@napi-rs/canvas-darwin-arm64@0.1.88': + optional: true + + '@napi-rs/canvas-darwin-x64@0.1.88': + optional: true + + '@napi-rs/canvas-linux-arm-gnueabihf@0.1.88': + optional: true + + '@napi-rs/canvas-linux-arm64-gnu@0.1.88': + optional: true + + '@napi-rs/canvas-linux-arm64-musl@0.1.88': + optional: true + + '@napi-rs/canvas-linux-riscv64-gnu@0.1.88': + optional: true + + '@napi-rs/canvas-linux-x64-gnu@0.1.88': + optional: true + + '@napi-rs/canvas-linux-x64-musl@0.1.88': + optional: true + + '@napi-rs/canvas-win32-arm64-msvc@0.1.88': + optional: true + + '@napi-rs/canvas-win32-x64-msvc@0.1.88': + optional: true + + '@napi-rs/canvas@0.1.88': + optionalDependencies: + '@napi-rs/canvas-android-arm64': 0.1.88 + '@napi-rs/canvas-darwin-arm64': 0.1.88 + '@napi-rs/canvas-darwin-x64': 0.1.88 + '@napi-rs/canvas-linux-arm-gnueabihf': 0.1.88 + '@napi-rs/canvas-linux-arm64-gnu': 0.1.88 + '@napi-rs/canvas-linux-arm64-musl': 0.1.88 + '@napi-rs/canvas-linux-riscv64-gnu': 0.1.88 + '@napi-rs/canvas-linux-x64-gnu': 0.1.88 + '@napi-rs/canvas-linux-x64-musl': 0.1.88 + '@napi-rs/canvas-win32-arm64-msvc': 0.1.88 + '@napi-rs/canvas-win32-x64-msvc': 0.1.88 + '@napi-rs/wasm-runtime@1.1.1': dependencies: '@emnapi/core': 1.8.1 @@ -8462,6 +8589,10 @@ snapshots: pathe@2.0.3: {} + pdfjs-dist@5.4.530: + optionalDependencies: + '@napi-rs/canvas': 0.1.88 + picocolors@1.1.1: {} picomatch@2.3.1: {} diff --git a/src/agents/cli-runner.ts b/src/agents/cli-runner.ts index 0f1b27e86..01d5432fc 100644 --- a/src/agents/cli-runner.ts +++ b/src/agents/cli-runner.ts @@ -45,6 +45,7 @@ export async function runCliAgent(params: { timeoutMs: number; runId: string; extraSystemPrompt?: string; + streamParams?: import("../commands/agent/types.js").AgentStreamParams; ownerNumbers?: string[]; cliSessionId?: string; images?: ImageContent[]; diff --git a/src/agents/pi-embedded-runner/extra-params.ts b/src/agents/pi-embedded-runner/extra-params.ts index a58ba1c71..fbb1e161b 100644 --- a/src/agents/pi-embedded-runner/extra-params.ts +++ b/src/agents/pi-embedded-runner/extra-params.ts @@ -63,13 +63,21 @@ export function applyExtraParamsToAgent( cfg: ClawdbotConfig | undefined, provider: string, modelId: string, + extraParamsOverride?: Record, ): void { const extraParams = resolveExtraParams({ cfg, provider, 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) { log.debug(`applying extraParams to agent streamFn for ${provider}/${modelId}`); diff --git a/src/agents/pi-embedded-runner/run.ts b/src/agents/pi-embedded-runner/run.ts index c0e17b103..3e59ff5ed 100644 --- a/src/agents/pi-embedded-runner/run.ts +++ b/src/agents/pi-embedded-runner/run.ts @@ -239,6 +239,7 @@ export async function runEmbeddedPiAgent( onToolResult: params.onToolResult, onAgentEvent: params.onAgentEvent, extraSystemPrompt: params.extraSystemPrompt, + streamParams: params.streamParams, ownerNumbers: params.ownerNumbers, enforceFinalTag: params.enforceFinalTag, }); @@ -482,6 +483,17 @@ export async function runEmbeddedPiAgent( agentMeta, aborted, 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, messagingToolSentTexts: attempt.messagingToolSentTexts, diff --git a/src/agents/pi-embedded-runner/run/attempt.ts b/src/agents/pi-embedded-runner/run/attempt.ts index 06272d7ce..92fdc9ee5 100644 --- a/src/agents/pi-embedded-runner/run/attempt.ts +++ b/src/agents/pi-embedded-runner/run/attempt.ts @@ -64,6 +64,7 @@ import { prewarmSessionFile, trackSessionManagerAccess } from "../session-manage import { prepareSessionManagerForRun } from "../session-manager-init.js"; import { buildEmbeddedSystemPrompt, createSystemPromptOverride } from "../system-prompt.js"; import { splitSdkTools } from "../tool-split.js"; +import { toClientToolDefinitions } from "../../pi-tool-definition-adapter.js"; import { buildSystemPromptParams } from "../../system-prompt-params.js"; import { describeUnknownError, mapThinkingLevel } from "../utils.js"; import { resolveSandboxRuntimeStatus } from "../../sandbox/runtime-status.js"; @@ -314,6 +315,16 @@ export async function runEmbeddedAttempt( sandboxEnabled: !!sandbox?.enabled, }); + // Add client tools (OpenResponses hosted tools) to customTools + let clientToolCallDetected: { name: string; params: Record } | null = null; + const clientToolDefs = params.clientTools + ? toClientToolDefinitions(params.clientTools, (toolName, toolParams) => { + clientToolCallDetected = { name: toolName, params: toolParams }; + }) + : []; + + const allCustomTools = [...customTools, ...clientToolDefs]; + ({ session } = await createAgentSession({ cwd: resolvedWorkspace, agentDir, @@ -323,7 +334,7 @@ export async function runEmbeddedAttempt( thinkingLevel: mapThinkingLevel(params.thinkLevel), systemPrompt, tools: builtInTools, - customTools, + customTools: allCustomTools, sessionManager, settingsManager, skills: [], @@ -338,7 +349,13 @@ export async function runEmbeddedAttempt( // Force a stable streamFn reference so vitest can reliably mock @mariozechner/pi-ai. 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 { const prior = await sanitizeSessionHistory({ @@ -681,6 +698,8 @@ export async function runEmbeddedAttempt( cloudCodeAssistFormatError: Boolean( lastAssistant?.errorMessage && isCloudCodeAssistFormatError(lastAssistant.errorMessage), ), + // Client tool call detected (OpenResponses hosted tools) + clientToolCall: clientToolCallDetected ?? undefined, }; } finally { // Always tear down the session (and release the lock) before we leave this attempt. diff --git a/src/agents/pi-embedded-runner/run/params.ts b/src/agents/pi-embedded-runner/run/params.ts index ea8e0f5d5..1ebc129b1 100644 --- a/src/agents/pi-embedded-runner/run/params.ts +++ b/src/agents/pi-embedded-runner/run/params.ts @@ -1,11 +1,22 @@ import type { ImageContent } from "@mariozechner/pi-ai"; import type { ReasoningLevel, ThinkLevel, VerboseLevel } from "../../../auto-reply/thinking.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 { ExecElevatedDefaults, ExecToolDefaults } from "../../bash-tools.js"; import type { BlockReplyChunking, ToolResultFormat } from "../../pi-embedded-subscribe.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; + }; +}; + export type RunEmbeddedPiAgentParams = { sessionId: string; sessionKey?: string; @@ -27,6 +38,8 @@ export type RunEmbeddedPiAgentParams = { skillsSnapshot?: SkillSnapshot; prompt: string; images?: ImageContent[]; + /** Optional client-provided tools (OpenResponses hosted tools). */ + clientTools?: ClientToolDefinition[]; provider?: string; model?: string; authProfileId?: string; @@ -58,6 +71,7 @@ export type RunEmbeddedPiAgentParams = { lane?: string; enqueue?: typeof enqueueCommand; extraSystemPrompt?: string; + streamParams?: AgentStreamParams; ownerNumbers?: string[]; enforceFinalTag?: boolean; }; diff --git a/src/agents/pi-embedded-runner/run/types.ts b/src/agents/pi-embedded-runner/run/types.ts index b1ae7670f..87940f4d4 100644 --- a/src/agents/pi-embedded-runner/run/types.ts +++ b/src/agents/pi-embedded-runner/run/types.ts @@ -4,11 +4,13 @@ import type { discoverAuthStorage, discoverModels } from "@mariozechner/pi-codin import type { ReasoningLevel, ThinkLevel, VerboseLevel } from "../../../auto-reply/thinking.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 { MessagingToolSend } from "../../pi-embedded-messaging.js"; import type { BlockReplyChunking, ToolResultFormat } from "../../pi-embedded-subscribe.js"; import type { SkillSnapshot } from "../../skills.js"; import type { SessionSystemPromptReport } from "../../../config/sessions/types.js"; +import type { ClientToolDefinition } from "./params.js"; type AuthStorage = ReturnType; type ModelRegistry = ReturnType; @@ -30,6 +32,8 @@ export type EmbeddedRunAttemptParams = { skillsSnapshot?: SkillSnapshot; prompt: string; images?: ImageContent[]; + /** Optional client-provided tools (OpenResponses hosted tools). */ + clientTools?: ClientToolDefinition[]; provider: string; modelId: string; model: Model; @@ -60,6 +64,7 @@ export type EmbeddedRunAttemptParams = { onToolResult?: (payload: { text?: string; mediaUrls?: string[] }) => void | Promise; onAgentEvent?: (evt: { stream: string; data: Record }) => void; extraSystemPrompt?: string; + streamParams?: AgentStreamParams; ownerNumbers?: string[]; enforceFinalTag?: boolean; }; @@ -79,4 +84,6 @@ export type EmbeddedRunAttemptResult = { messagingToolSentTexts: string[]; messagingToolSentTargets: MessagingToolSend[]; cloudCodeAssistFormatError: boolean; + /** Client tool call detected (OpenResponses hosted tools). */ + clientToolCall?: { name: string; params: Record }; }; diff --git a/src/agents/pi-embedded-runner/types.ts b/src/agents/pi-embedded-runner/types.ts index e2d33047a..a8aa3c48c 100644 --- a/src/agents/pi-embedded-runner/types.ts +++ b/src/agents/pi-embedded-runner/types.ts @@ -23,6 +23,14 @@ export type EmbeddedPiRunMeta = { kind: "context_overflow" | "compaction_failure" | "role_ordering"; 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 = { diff --git a/src/agents/pi-tool-definition-adapter.ts b/src/agents/pi-tool-definition-adapter.ts index 0b9b5bbe1..963292822 100644 --- a/src/agents/pi-tool-definition-adapter.ts +++ b/src/agents/pi-tool-definition-adapter.ts @@ -4,6 +4,7 @@ import type { AgentToolUpdateCallback, } from "@mariozechner/pi-agent-core"; 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 { normalizeToolName } from "./tool-policy.js"; import { jsonResult } from "./tools/common.js"; @@ -65,3 +66,38 @@ export function toToolDefinitions(tools: AnyAgentTool[]): 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) => 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 | undefined, + _ctx, + _signal, + ): Promise> => { + // Notify handler that a client tool was called + if (onClientToolCall) { + onClientToolCall(func.name, params as Record); + } + // 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; + }); +} diff --git a/src/canvas-host/a2ui/.bundle.hash b/src/canvas-host/a2ui/.bundle.hash index deb2c89b7..29b79cb99 100644 --- a/src/canvas-host/a2ui/.bundle.hash +++ b/src/canvas-host/a2ui/.bundle.hash @@ -1 +1 @@ -c51e9080b032ccb0dd153452854f2ffdaca8e1db14d7b98ed56cca8f0f1a5257 +2d9d05442e500250f72da79cc23fd2a688d8d44e8a2a0f40dc0401375f96ef8f diff --git a/src/commands/agent.ts b/src/commands/agent.ts index 4cef10216..63258a4b8 100644 --- a/src/commands/agent.ts +++ b/src/commands/agent.ts @@ -396,6 +396,7 @@ export async function agentCommand( extraSystemPrompt: opts.extraSystemPrompt, cliSessionId, images: opts.images, + streamParams: opts.streamParams, }); } const authProfileId = @@ -415,6 +416,7 @@ export async function agentCommand( skillsSnapshot, prompt: body, images: opts.images, + clientTools: opts.clientTools, provider: providerOverride, model: modelOverride, authProfileId, @@ -428,6 +430,7 @@ export async function agentCommand( lane: opts.lane, abortSignal: opts.abortSignal, extraSystemPrompt: opts.extraSystemPrompt, + streamParams: opts.streamParams, agentDir, onAgentEvent: (evt) => { if ( diff --git a/src/commands/agent/types.ts b/src/commands/agent/types.ts index f02282a4d..deb1b7bc8 100644 --- a/src/commands/agent/types.ts +++ b/src/commands/agent/types.ts @@ -1,4 +1,5 @@ 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. */ export type ImageContent = { @@ -7,6 +8,12 @@ export type ImageContent = { mimeType: string; }; +export type AgentStreamParams = { + /** Provider stream params override (best-effort). */ + temperature?: number; + maxTokens?: number; +}; + export type AgentRunContext = { messageChannel?: string; accountId?: string; @@ -20,6 +27,8 @@ export type AgentCommandOpts = { message: string; /** Optional image attachments for multimodal messages. */ images?: ImageContent[]; + /** Optional client-provided tools (OpenResponses hosted tools). */ + clientTools?: ClientToolDefinition[]; /** Agent id override (must exist in config). */ agentId?: string; to?: string; @@ -50,4 +59,6 @@ export type AgentCommandOpts = { lane?: string; runId?: string; extraSystemPrompt?: string; + /** Per-call stream param overrides (best-effort). */ + streamParams?: AgentStreamParams; }; diff --git a/src/config/types.gateway.ts b/src/config/types.gateway.ts index 122bf470b..77e6c88a2 100644 --- a/src/config/types.gateway.ts +++ b/src/config/types.gateway.ts @@ -105,8 +105,65 @@ export type GatewayHttpChatCompletionsConfig = { 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 = { chatCompletions?: GatewayHttpChatCompletionsConfig; + responses?: GatewayHttpResponsesConfig; }; export type GatewayHttpConfig = { diff --git a/src/config/zod-schema.ts b/src/config/zod-schema.ts index 049f53028..58612d459 100644 --- a/src/config/zod-schema.ts +++ b/src/config/zod-schema.ts @@ -299,6 +299,42 @@ export const ClawdbotSchema = z }) .strict() .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() .optional(), diff --git a/src/gateway/client.ts b/src/gateway/client.ts index 8a9922c4e..2c52526b2 100644 --- a/src/gateway/client.ts +++ b/src/gateway/client.ts @@ -90,7 +90,7 @@ export class GatewayClient { }; if (url.startsWith("wss://") && this.opts.tlsFingerprint) { wsOptions.rejectUnauthorized = false; - wsOptions.checkServerIdentity = (_host: string, cert: CertMeta) => { + wsOptions.checkServerIdentity = ((_host: string, cert: CertMeta) => { const fingerprintValue = typeof cert === "object" && cert && "fingerprint256" in cert ? ((cert as { fingerprint256?: string }).fingerprint256 ?? "") @@ -99,9 +99,17 @@ export class GatewayClient { typeof fingerprintValue === "string" ? fingerprintValue : "", ); const expected = normalizeFingerprint(this.opts.tlsFingerprint ?? ""); - if (!expected || !fingerprint) return false; - return fingerprint === expected; - }; + if (!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); diff --git a/src/gateway/http-utils.ts b/src/gateway/http-utils.ts new file mode 100644 index 000000000..dde9dd3f6 --- /dev/null +++ b/src/gateway/http-utils.ts @@ -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[:/](?[a-z0-9][a-z0-9_-]{0,63})$/i) ?? + raw.match(/^agent:(?[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 }); +} diff --git a/src/gateway/open-responses.schema.ts b/src/gateway/open-responses.schema.ts new file mode 100644 index 000000000..e07288610 --- /dev/null +++ b/src/gateway/open-responses.schema.ts @@ -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; + +// ───────────────────────────────────────────────────────────────────────────── +// Item Types (ItemParam) +// ───────────────────────────────────────────────────────────────────────────── + +export const MessageItemRoleSchema = z.enum(["system", "developer", "user", "assistant"]); + +export type MessageItemRole = z.infer; + +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; + +// ───────────────────────────────────────────────────────────────────────────── +// 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; + +// ───────────────────────────────────────────────────────────────────────────── +// 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; + +// ───────────────────────────────────────────────────────────────────────────── +// Response Resource +// ───────────────────────────────────────────────────────────────────────────── + +export const ResponseStatusSchema = z.enum([ + "in_progress", + "completed", + "failed", + "cancelled", + "incomplete", +]); + +export type ResponseStatus = z.infer; + +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; + +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; + +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; + +// ───────────────────────────────────────────────────────────────────────────── +// 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 + | z.infer + | z.infer + | z.infer + | z.infer + | z.infer + | z.infer + | z.infer + | z.infer + | z.infer; diff --git a/src/gateway/openai-http.ts b/src/gateway/openai-http.ts index 64709c4df..6e7f1d521 100644 --- a/src/gateway/openai-http.ts +++ b/src/gateway/openai-http.ts @@ -5,9 +5,9 @@ import { buildHistoryContextFromEntries, type HistoryEntry } from "../auto-reply import { createDefaultDeps } from "../cli/deps.js"; import { agentCommand } from "../commands/agent.js"; import { emitAgentEvent, onAgentEvent } from "../infra/agent-events.js"; -import { buildAgentMainSessionKey, normalizeAgentId } from "../routing/session-key.js"; import { defaultRuntime } from "../runtime.js"; import { authorizeGatewayConnect, type ResolvedGatewayAuth } from "./auth.js"; +import { getBearerToken, resolveAgentIdForRequest, resolveSessionKey } from "./http-utils.js"; import { readJsonBody } from "./hooks.js"; type OpenAiHttpOptions = { @@ -34,20 +34,6 @@ function sendJson(res: ServerResponse, status: number, body: unknown) { 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) { res.write(`data: ${JSON.stringify(data)}\n\n`); } @@ -154,50 +140,12 @@ function buildAgentPrompt(messagesUnknown: unknown): { }; } -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); -} - -function resolveAgentIdFromModel(model: string | undefined): string | undefined { - const raw = model?.trim(); - if (!raw) return undefined; - - const m = - raw.match(/^clawdbot[:/](?[a-z0-9][a-z0-9_-]{0,63})$/i) ?? - raw.match(/^agent:(?[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: { +function resolveOpenAiSessionKey(params: { req: IncomingMessage; agentId: string; user?: string | undefined; }): string { - const explicit = getHeader(params.req, "x-clawdbot-session-key")?.trim(); - 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 }); + return resolveSessionKey({ ...params, prefix: "openai" }); } function coerceRequest(val: unknown): OpenAiChatCompletionRequest { @@ -248,7 +196,7 @@ export async function handleOpenAiHttpRequest( const user = typeof payload.user === "string" ? payload.user : undefined; const agentId = resolveAgentIdForRequest({ req, model }); - const sessionKey = resolveSessionKey({ req, agentId, user }); + const sessionKey = resolveOpenAiSessionKey({ req, agentId, user }); const prompt = buildAgentPrompt(payload.messages); if (!prompt.message) { sendJson(res, 400, { diff --git a/src/gateway/openresponses-http.e2e.test.ts b/src/gateway/openresponses-http.e2e.test.ts new file mode 100644 index 000000000..189d67864 --- /dev/null +++ b/src/gateway/openresponses-http.e2e.test.ts @@ -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) { + 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; + expect((json.error as Record | 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(''); + } 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; + 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; + expect(json.object).toBe("response"); + expect(json.status).toBe("completed"); + expect(Array.isArray(json.output)).toBe(true); + + const output = json.output as Array>; + 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>; + 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; + expect((json.error as Record | 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" }); + } + }); +}); diff --git a/src/gateway/openresponses-http.ts b/src/gateway/openresponses-http.ts new file mode 100644 index 000000000..af2a44a28 --- /dev/null +++ b/src/gateway/openresponses-http.ts @@ -0,0 +1,1200 @@ +/** + * OpenResponses HTTP Handler + * + * Implements the OpenResponses `/v1/responses` endpoint for Clawdbot Gateway. + * + * @see https://www.open-responses.com/ + */ + +import { randomUUID } from "node:crypto"; +import { lookup } from "node:dns/promises"; +import type { IncomingMessage, ServerResponse } from "node:http"; + +import { createCanvas } from "@napi-rs/canvas"; +import { buildHistoryContextFromEntries, type HistoryEntry } from "../auto-reply/reply/history.js"; +import { createDefaultDeps } from "../cli/deps.js"; +import { agentCommand } from "../commands/agent.js"; +import { emitAgentEvent, onAgentEvent } from "../infra/agent-events.js"; +import { defaultRuntime } from "../runtime.js"; +import { authorizeGatewayConnect, type ResolvedGatewayAuth } from "./auth.js"; +import { getBearerToken, resolveAgentIdForRequest, resolveSessionKey } from "./http-utils.js"; +import { readJsonBody } from "./hooks.js"; +import { + CreateResponseBodySchema, + type ContentPart, + type CreateResponseBody, + type ItemParam, + type OutputItem, + type ResponseResource, + type StreamingEvent, + type Usage, +} from "./open-responses.schema.js"; +import type { GatewayHttpResponsesConfig } from "../config/types.gateway.js"; +import type { ClientToolDefinition } from "../agents/pi-embedded-runner/run/params.js"; +import type { ImageContent } from "../commands/agent/types.js"; +import { getDocument } from "pdfjs-dist/legacy/build/pdf.mjs"; + +type OpenResponsesHttpOptions = { + auth: ResolvedGatewayAuth; + maxBodyBytes?: number; + config?: GatewayHttpResponsesConfig; +}; + +function sendJson(res: ServerResponse, status: number, body: unknown) { + res.statusCode = status; + res.setHeader("Content-Type", "application/json; charset=utf-8"); + res.end(JSON.stringify(body)); +} + +const DEFAULT_BODY_BYTES = 20 * 1024 * 1024; + +function writeSseEvent(res: ServerResponse, event: StreamingEvent) { + res.write(`event: ${event.type}\n`); + res.write(`data: ${JSON.stringify(event)}\n\n`); +} + +function writeDone(res: ServerResponse) { + res.write("data: [DONE]\n\n"); +} + +function extractTextContent(content: string | ContentPart[]): string { + if (typeof content === "string") return content; + return content + .map((part) => { + if (part.type === "input_text") return part.text; + if (part.type === "output_text") return part.text; + return ""; + }) + .filter(Boolean) + .join("\n"); +} + +type ResolvedResponsesLimits = { + maxBodyBytes: number; + files: { + allowUrl: boolean; + allowedMimes: Set; + maxBytes: number; + maxChars: number; + maxRedirects: number; + timeoutMs: number; + pdf: { + maxPages: number; + maxPixels: number; + minTextChars: number; + }; + }; + images: { + allowUrl: boolean; + allowedMimes: Set; + maxBytes: number; + maxRedirects: number; + timeoutMs: number; + }; +}; + +const DEFAULT_IMAGE_MIMES = ["image/jpeg", "image/png", "image/gif", "image/webp"]; +const DEFAULT_FILE_MIMES = [ + "text/plain", + "text/markdown", + "text/html", + "text/csv", + "application/json", + "application/pdf", +]; +const DEFAULT_IMAGE_MAX_BYTES = 10 * 1024 * 1024; +const DEFAULT_FILE_MAX_BYTES = 5 * 1024 * 1024; +const DEFAULT_FILE_MAX_CHARS = 200_000; +const DEFAULT_MAX_REDIRECTS = 3; +const DEFAULT_TIMEOUT_MS = 10_000; +const DEFAULT_PDF_MAX_PAGES = 4; +const DEFAULT_PDF_MAX_PIXELS = 4_000_000; +const DEFAULT_PDF_MIN_TEXT_CHARS = 200; + +function normalizeMimeType(value: string | undefined): string | undefined { + if (!value) return undefined; + const [raw] = value.split(";"); + const normalized = raw?.trim().toLowerCase(); + return normalized || undefined; +} + +function parseContentType(value: string | undefined): { mimeType?: string; charset?: string } { + if (!value) return {}; + const parts = value.split(";").map((part) => part.trim()); + const mimeType = normalizeMimeType(parts[0]); + const charset = parts + .map((part) => part.match(/^charset=(.+)$/i)?.[1]?.trim()) + .find((part) => part && part.length > 0); + return { mimeType, charset }; +} + +function normalizeMimeList(values: string[] | undefined, fallback: string[]): Set { + const input = values && values.length > 0 ? values : fallback; + return new Set(input.map((value) => normalizeMimeType(value)).filter(Boolean) as string[]); +} + +function resolveResponsesLimits( + config: GatewayHttpResponsesConfig | undefined, +): ResolvedResponsesLimits { + const files = config?.files; + const images = config?.images; + return { + maxBodyBytes: config?.maxBodyBytes ?? DEFAULT_BODY_BYTES, + files: { + allowUrl: files?.allowUrl ?? true, + allowedMimes: normalizeMimeList(files?.allowedMimes, DEFAULT_FILE_MIMES), + maxBytes: files?.maxBytes ?? DEFAULT_FILE_MAX_BYTES, + maxChars: files?.maxChars ?? DEFAULT_FILE_MAX_CHARS, + maxRedirects: files?.maxRedirects ?? DEFAULT_MAX_REDIRECTS, + timeoutMs: files?.timeoutMs ?? DEFAULT_TIMEOUT_MS, + pdf: { + maxPages: files?.pdf?.maxPages ?? DEFAULT_PDF_MAX_PAGES, + maxPixels: files?.pdf?.maxPixels ?? DEFAULT_PDF_MAX_PIXELS, + minTextChars: files?.pdf?.minTextChars ?? DEFAULT_PDF_MIN_TEXT_CHARS, + }, + }, + images: { + allowUrl: images?.allowUrl ?? true, + allowedMimes: normalizeMimeList(images?.allowedMimes, DEFAULT_IMAGE_MIMES), + maxBytes: images?.maxBytes ?? DEFAULT_IMAGE_MAX_BYTES, + maxRedirects: images?.maxRedirects ?? DEFAULT_MAX_REDIRECTS, + timeoutMs: images?.timeoutMs ?? DEFAULT_TIMEOUT_MS, + }, + }; +} + +const PRIVATE_IPV4_PATTERNS = [ + /^127\./, + /^10\./, + /^192\.168\./, + /^172\.(1[6-9]|2[0-9]|3[0-1])\./, + /^0\./, +]; +const PRIVATE_IPV6_PREFIXES = ["::1", "fe80:", "fec0:", "fc", "fd"]; + +function isPrivateIpAddress(address: string): boolean { + if (address.includes(":")) { + const lower = address.toLowerCase(); + if (lower === "::1") return true; + return PRIVATE_IPV6_PREFIXES.some((prefix) => lower.startsWith(prefix)); + } + return PRIVATE_IPV4_PATTERNS.some((pattern) => pattern.test(address)); +} + +function isBlockedHostname(hostname: string): boolean { + const lower = hostname.toLowerCase(); + return ( + lower === "localhost" || + lower.endsWith(".localhost") || + lower.endsWith(".local") || + lower.endsWith(".internal") + ); +} + +async function assertPublicHostname(hostname: string): Promise { + if (isBlockedHostname(hostname)) { + throw new Error(`Blocked hostname: ${hostname}`); + } + + const results = await lookup(hostname, { all: true }); + if (results.length === 0) { + throw new Error(`Unable to resolve hostname: ${hostname}`); + } + for (const entry of results) { + if (isPrivateIpAddress(entry.address)) { + throw new Error(`Private IP addresses are not allowed: ${entry.address}`); + } + } +} + +function isRedirectStatus(status: number): boolean { + return status === 301 || status === 302 || status === 303 || status === 307 || status === 308; +} + +// Fetch with SSRF protection, timeout, redirect limits, and size limits. +async function fetchWithGuard(params: { + url: string; + maxBytes: number; + timeoutMs: number; + maxRedirects: number; +}): Promise<{ data: string; mimeType: string; contentType?: string }> { + let currentUrl = params.url; + let redirectCount = 0; + + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), params.timeoutMs); + + try { + while (true) { + const parsedUrl = new URL(currentUrl); + if (!["http:", "https:"].includes(parsedUrl.protocol)) { + throw new Error(`Invalid URL protocol: ${parsedUrl.protocol}. Only HTTP/HTTPS allowed.`); + } + await assertPublicHostname(parsedUrl.hostname); + + const response = await fetch(parsedUrl, { + signal: controller.signal, + headers: { "User-Agent": "Clawdbot-Gateway/1.0" }, + redirect: "manual", + }); + + if (isRedirectStatus(response.status)) { + const location = response.headers.get("location"); + if (!location) { + throw new Error(`Redirect missing location header (${response.status})`); + } + redirectCount += 1; + if (redirectCount > params.maxRedirects) { + throw new Error(`Too many redirects (limit: ${params.maxRedirects})`); + } + currentUrl = new URL(location, parsedUrl).toString(); + continue; + } + + if (!response.ok) { + throw new Error(`Failed to fetch: ${response.status} ${response.statusText}`); + } + + const contentLength = response.headers.get("content-length"); + if (contentLength) { + const size = parseInt(contentLength, 10); + if (size > params.maxBytes) { + throw new Error(`Content too large: ${size} bytes (limit: ${params.maxBytes} bytes)`); + } + } + + const buffer = await response.arrayBuffer(); + if (buffer.byteLength > params.maxBytes) { + throw new Error( + `Content too large: ${buffer.byteLength} bytes (limit: ${params.maxBytes} bytes)`, + ); + } + + const contentType = response.headers.get("content-type") || undefined; + const parsed = parseContentType(contentType); + const mimeType = parsed.mimeType ?? "application/octet-stream"; + return { data: Buffer.from(buffer).toString("base64"), mimeType, contentType }; + } + } finally { + clearTimeout(timeoutId); + } +} + +type FileExtractResult = { + filename: string; + text?: string; + images?: ImageContent[]; +}; + +function decodeTextContent(buffer: Buffer, charset: string | undefined): string { + const encoding = charset?.trim().toLowerCase() || "utf-8"; + try { + return new TextDecoder(encoding).decode(buffer); + } catch { + return new TextDecoder("utf-8").decode(buffer); + } +} + +function clampText(text: string, maxChars: number): string { + if (text.length <= maxChars) return text; + return text.slice(0, maxChars); +} + +async function extractPdfContent(params: { + buffer: Buffer; + limits: ResolvedResponsesLimits; +}): Promise<{ text: string; images: ImageContent[] }> { + const { buffer, limits } = params; + const pdf = await getDocument({ + data: new Uint8Array(buffer), + // @ts-expect-error pdfjs-dist legacy option not in current type defs. + disableWorker: true, + }).promise; + const maxPages = Math.min(pdf.numPages, limits.files.pdf.maxPages); + const textParts: string[] = []; + + for (let pageNum = 1; pageNum <= maxPages; pageNum += 1) { + const page = await pdf.getPage(pageNum); + const textContent = await page.getTextContent(); + const pageText = textContent.items + .map((item) => ("str" in item ? String(item.str) : "")) + .filter(Boolean) + .join(" "); + if (pageText) textParts.push(pageText); + } + + const text = textParts.join("\n\n"); + if (text.trim().length >= limits.files.pdf.minTextChars) { + return { text, images: [] }; + } + + const images: ImageContent[] = []; + for (let pageNum = 1; pageNum <= maxPages; pageNum += 1) { + const page = await pdf.getPage(pageNum); + const viewport = page.getViewport({ scale: 1 }); + const maxPixels = limits.files.pdf.maxPixels; + const pixelBudget = Math.max(1, maxPixels); + const pagePixels = viewport.width * viewport.height; + const scale = Math.min(1, Math.sqrt(pixelBudget / pagePixels)); + const scaled = page.getViewport({ scale: Math.max(0.1, scale) }); + const canvas = createCanvas(Math.ceil(scaled.width), Math.ceil(scaled.height)); + await page.render({ + canvas: canvas as unknown as HTMLCanvasElement, + viewport: scaled, + }).promise; + const png = canvas.toBuffer("image/png"); + images.push({ type: "image", data: png.toString("base64"), mimeType: "image/png" }); + } + + return { text, images }; +} + +async function extractImageContent( + part: ContentPart, + limits: ResolvedResponsesLimits, +): Promise { + if (part.type !== "input_image") return null; + + const source = part.source as { type: string; url?: string; data?: string; media_type?: string }; + + if (source.type === "base64") { + if (!source.data) { + throw new Error("input_image base64 source missing 'data' field"); + } + const mimeType = normalizeMimeType(source.media_type) ?? "image/png"; + if (!limits.images.allowedMimes.has(mimeType)) { + throw new Error(`Unsupported image MIME type: ${mimeType}`); + } + const buffer = Buffer.from(source.data, "base64"); + if (buffer.byteLength > limits.images.maxBytes) { + throw new Error( + `Image too large: ${buffer.byteLength} bytes (limit: ${limits.images.maxBytes} bytes)`, + ); + } + return { type: "image", data: source.data, mimeType }; + } + + if (source.type === "url" && source.url) { + if (!limits.images.allowUrl) { + throw new Error("input_image URL sources are disabled by config"); + } + const result = await fetchWithGuard({ + url: source.url, + maxBytes: limits.images.maxBytes, + timeoutMs: limits.images.timeoutMs, + maxRedirects: limits.images.maxRedirects, + }); + if (!limits.images.allowedMimes.has(result.mimeType)) { + throw new Error(`Unsupported image MIME type from URL: ${result.mimeType}`); + } + return { type: "image", data: result.data, mimeType: result.mimeType }; + } + + throw new Error("input_image must have 'source.url' or 'source.data'"); +} + +async function extractFileContent( + part: ContentPart, + limits: ResolvedResponsesLimits, +): Promise { + if (part.type !== "input_file") return null; + + const source = part.source as { + type: string; + url?: string; + data?: string; + media_type?: string; + filename?: string; + }; + const filename = source.filename || "file"; + + let buffer: Buffer; + let mimeType: string | undefined; + let charset: string | undefined; + + if (source.type === "base64") { + if (!source.data) { + throw new Error("input_file base64 source missing 'data' field"); + } + const parsed = parseContentType(source.media_type); + mimeType = parsed.mimeType; + charset = parsed.charset; + buffer = Buffer.from(source.data, "base64"); + } else if (source.type === "url" && source.url) { + if (!limits.files.allowUrl) { + throw new Error("input_file URL sources are disabled by config"); + } + const result = await fetchWithGuard({ + url: source.url, + maxBytes: limits.files.maxBytes, + timeoutMs: limits.files.timeoutMs, + maxRedirects: limits.files.maxRedirects, + }); + const parsed = parseContentType(result.contentType); + mimeType = parsed.mimeType ?? normalizeMimeType(result.mimeType); + charset = parsed.charset; + buffer = Buffer.from(result.data, "base64"); + } else { + throw new Error("input_file must have 'source.url' or 'source.data'"); + } + + if (buffer.byteLength > limits.files.maxBytes) { + throw new Error( + `File too large: ${buffer.byteLength} bytes (limit: ${limits.files.maxBytes} bytes)`, + ); + } + + if (!mimeType) { + throw new Error("input_file missing media type"); + } + if (!limits.files.allowedMimes.has(mimeType)) { + throw new Error(`Unsupported file MIME type: ${mimeType}`); + } + + if (mimeType === "application/pdf") { + const extracted = await extractPdfContent({ buffer, limits }); + const text = extracted.text ? clampText(extracted.text, limits.files.maxChars) : ""; + return { + filename, + text, + images: extracted.images.length > 0 ? extracted.images : undefined, + }; + } + + const text = clampText(decodeTextContent(buffer, charset), limits.files.maxChars); + return { filename, text }; +} + +function extractClientTools(body: CreateResponseBody): ClientToolDefinition[] { + return (body.tools ?? []) as ClientToolDefinition[]; +} + +function applyToolChoice(params: { + tools: ClientToolDefinition[]; + toolChoice: CreateResponseBody["tool_choice"]; +}): { tools: ClientToolDefinition[]; extraSystemPrompt?: string } { + const { tools, toolChoice } = params; + if (!toolChoice) return { tools }; + + if (toolChoice === "none") { + return { tools: [] }; + } + + if (toolChoice === "required") { + if (tools.length === 0) { + throw new Error("tool_choice=required but no tools were provided"); + } + return { + tools, + extraSystemPrompt: "You must call one of the available tools before responding.", + }; + } + + if (typeof toolChoice === "object" && toolChoice.type === "function") { + const targetName = toolChoice.function?.name?.trim(); + if (!targetName) { + throw new Error("tool_choice.function.name is required"); + } + const matched = tools.filter((tool) => tool.function?.name === targetName); + if (matched.length === 0) { + throw new Error(`tool_choice requested unknown tool: ${targetName}`); + } + return { + tools: matched, + extraSystemPrompt: `You must call the ${targetName} tool before responding.`, + }; + } + + return { tools }; +} + +export function buildAgentPrompt(input: string | ItemParam[]): { + message: string; + extraSystemPrompt?: string; +} { + if (typeof input === "string") { + return { message: input }; + } + + const systemParts: string[] = []; + const conversationEntries: Array<{ role: "user" | "assistant" | "tool"; entry: HistoryEntry }> = + []; + + for (const item of input) { + if (item.type === "message") { + const content = extractTextContent(item.content).trim(); + if (!content) continue; + + if (item.role === "system" || item.role === "developer") { + systemParts.push(content); + continue; + } + + const normalizedRole = item.role === "assistant" ? "assistant" : "user"; + const sender = normalizedRole === "assistant" ? "Assistant" : "User"; + + conversationEntries.push({ + role: normalizedRole, + entry: { sender, body: content }, + }); + } else if (item.type === "function_call_output") { + conversationEntries.push({ + role: "tool", + entry: { sender: `Tool:${item.call_id}`, body: item.output }, + }); + } + // Skip reasoning and item_reference for prompt building (Phase 1) + } + + let message = ""; + if (conversationEntries.length > 0) { + // Find the last user or tool message as the current message + let currentIndex = -1; + for (let i = conversationEntries.length - 1; i >= 0; i -= 1) { + const entryRole = conversationEntries[i]?.role; + if (entryRole === "user" || entryRole === "tool") { + currentIndex = i; + break; + } + } + if (currentIndex < 0) currentIndex = conversationEntries.length - 1; + + const currentEntry = conversationEntries[currentIndex]?.entry; + if (currentEntry) { + const historyEntries = conversationEntries.slice(0, currentIndex).map((entry) => entry.entry); + if (historyEntries.length === 0) { + message = currentEntry.body; + } else { + const formatEntry = (entry: HistoryEntry) => `${entry.sender}: ${entry.body}`; + message = buildHistoryContextFromEntries({ + entries: [...historyEntries, currentEntry], + currentMessage: formatEntry(currentEntry), + formatEntry, + }); + } + } + } + + return { + message, + extraSystemPrompt: systemParts.length > 0 ? systemParts.join("\n\n") : undefined, + }; +} + +function resolveOpenResponsesSessionKey(params: { + req: IncomingMessage; + agentId: string; + user?: string | undefined; +}): string { + return resolveSessionKey({ ...params, prefix: "openresponses" }); +} + +function createEmptyUsage(): Usage { + return { input_tokens: 0, output_tokens: 0, total_tokens: 0 }; +} + +function toUsage( + value: + | { + input?: number; + output?: number; + cacheRead?: number; + cacheWrite?: number; + total?: number; + } + | undefined, +): Usage { + if (!value) return createEmptyUsage(); + const input = value.input ?? 0; + const output = value.output ?? 0; + const cacheRead = value.cacheRead ?? 0; + const cacheWrite = value.cacheWrite ?? 0; + const total = value.total ?? input + output + cacheRead + cacheWrite; + return { + input_tokens: Math.max(0, input), + output_tokens: Math.max(0, output), + total_tokens: Math.max(0, total), + }; +} + +function extractUsageFromResult(result: unknown): Usage { + const meta = (result as { meta?: { agentMeta?: { usage?: unknown } } } | null)?.meta; + const usage = meta && typeof meta === "object" ? meta.agentMeta?.usage : undefined; + return toUsage( + usage as + | { input?: number; output?: number; cacheRead?: number; cacheWrite?: number; total?: number } + | undefined, + ); +} + +function createResponseResource(params: { + id: string; + model: string; + status: ResponseResource["status"]; + output: OutputItem[]; + usage?: Usage; + error?: { code: string; message: string }; +}): ResponseResource { + return { + id: params.id, + object: "response", + created_at: Math.floor(Date.now() / 1000), + status: params.status, + model: params.model, + output: params.output, + usage: params.usage ?? createEmptyUsage(), + error: params.error, + }; +} + +function createAssistantOutputItem(params: { + id: string; + text: string; + status?: "in_progress" | "completed"; +}): OutputItem { + return { + type: "message", + id: params.id, + role: "assistant", + content: [{ type: "output_text", text: params.text }], + status: params.status, + }; +} + +export async function handleOpenResponsesHttpRequest( + req: IncomingMessage, + res: ServerResponse, + opts: OpenResponsesHttpOptions, +): Promise { + const url = new URL(req.url ?? "/", `http://${req.headers.host || "localhost"}`); + if (url.pathname !== "/v1/responses") return false; + + if (req.method !== "POST") { + res.statusCode = 405; + res.setHeader("Allow", "POST"); + res.setHeader("Content-Type", "text/plain; charset=utf-8"); + res.end("Method Not Allowed"); + return true; + } + + const token = getBearerToken(req); + const authResult = await authorizeGatewayConnect({ + auth: opts.auth, + connectAuth: { token, password: token }, + req, + }); + if (!authResult.ok) { + sendJson(res, 401, { + error: { message: "Unauthorized", type: "unauthorized" }, + }); + return true; + } + + const limits = resolveResponsesLimits(opts.config); + const maxBodyBytes = + opts.maxBodyBytes ?? + (opts.config?.maxBodyBytes + ? limits.maxBodyBytes + : Math.max(limits.maxBodyBytes, limits.files.maxBytes * 2, limits.images.maxBytes * 2)); + const body = await readJsonBody(req, maxBodyBytes); + if (!body.ok) { + sendJson(res, 400, { + error: { message: body.error, type: "invalid_request_error" }, + }); + return true; + } + + // Validate request body with Zod + const parseResult = CreateResponseBodySchema.safeParse(body.value); + if (!parseResult.success) { + const issue = parseResult.error.issues[0]; + const message = issue ? `${issue.path.join(".")}: ${issue.message}` : "Invalid request body"; + sendJson(res, 400, { + error: { message, type: "invalid_request_error" }, + }); + return true; + } + + const payload: CreateResponseBody = parseResult.data; + const stream = Boolean(payload.stream); + const model = payload.model; + const user = payload.user; + + // Extract images + files from input (Phase 2) + let images: ImageContent[] = []; + let fileContexts: string[] = []; + try { + if (Array.isArray(payload.input)) { + for (const item of payload.input) { + if (item.type === "message" && typeof item.content !== "string") { + for (const part of item.content) { + const image = await extractImageContent(part, limits); + if (image) { + images.push(image); + continue; + } + const file = await extractFileContent(part, limits); + if (file) { + if (file.text?.trim()) { + fileContexts.push(`\n${file.text}\n`); + } else if (file.images && file.images.length > 0) { + fileContexts.push( + `[PDF content rendered to images]`, + ); + } + if (file.images && file.images.length > 0) { + images = images.concat(file.images); + } + } + } + } + } + } + } catch (err) { + sendJson(res, 400, { + error: { message: String(err), type: "invalid_request_error" }, + }); + return true; + } + + const clientTools = extractClientTools(payload); + let toolChoicePrompt: string | undefined; + let resolvedClientTools = clientTools; + try { + const toolChoiceResult = applyToolChoice({ + tools: clientTools, + toolChoice: payload.tool_choice, + }); + resolvedClientTools = toolChoiceResult.tools; + toolChoicePrompt = toolChoiceResult.extraSystemPrompt; + } catch (err) { + sendJson(res, 400, { + error: { message: String(err), type: "invalid_request_error" }, + }); + return true; + } + const agentId = resolveAgentIdForRequest({ req, model }); + const sessionKey = resolveOpenResponsesSessionKey({ req, agentId, user }); + + // Build prompt from input + const prompt = buildAgentPrompt(payload.input); + + const fileContext = fileContexts.length > 0 ? fileContexts.join("\n\n") : undefined; + const toolChoiceContext = toolChoicePrompt?.trim(); + + // Handle instructions + file context as extra system prompt + const extraSystemPrompt = [ + payload.instructions, + prompt.extraSystemPrompt, + toolChoiceContext, + fileContext, + ] + .filter(Boolean) + .join("\n\n"); + + if (!prompt.message) { + sendJson(res, 400, { + error: { + message: "Missing user message in `input`.", + type: "invalid_request_error", + }, + }); + return true; + } + + const responseId = `resp_${randomUUID()}`; + const outputItemId = `msg_${randomUUID()}`; + const deps = createDefaultDeps(); + const streamParams = + typeof payload.max_output_tokens === "number" + ? { maxTokens: payload.max_output_tokens } + : undefined; + + if (!stream) { + try { + const result = await agentCommand( + { + message: prompt.message, + images: images.length > 0 ? images : undefined, + clientTools: resolvedClientTools.length > 0 ? resolvedClientTools : undefined, + extraSystemPrompt: extraSystemPrompt || undefined, + streamParams: streamParams ?? undefined, + sessionKey, + runId: responseId, + deliver: false, + messageChannel: "webchat", + bestEffortDeliver: false, + }, + defaultRuntime, + deps, + ); + + const payloads = (result as { payloads?: Array<{ text?: string }> } | null)?.payloads; + const usage = extractUsageFromResult(result); + const meta = (result as { meta?: unknown } | null)?.meta; + const stopReason = + meta && typeof meta === "object" ? (meta as { stopReason?: string }).stopReason : undefined; + const pendingToolCalls = + meta && typeof meta === "object" + ? (meta as { pendingToolCalls?: Array<{ id: string; name: string; arguments: string }> }) + .pendingToolCalls + : undefined; + + // If agent called a client tool, return function_call instead of text + if (stopReason === "tool_calls" && pendingToolCalls && pendingToolCalls.length > 0) { + const functionCall = pendingToolCalls[0]; + const functionCallItemId = `call_${randomUUID()}`; + const response = createResponseResource({ + id: responseId, + model, + status: "incomplete", + output: [ + { + type: "function_call", + id: functionCallItemId, + call_id: functionCall.id, + name: functionCall.name, + arguments: functionCall.arguments, + }, + ], + usage, + }); + sendJson(res, 200, response); + return true; + } + + const content = + Array.isArray(payloads) && payloads.length > 0 + ? payloads + .map((p) => (typeof p.text === "string" ? p.text : "")) + .filter(Boolean) + .join("\n\n") + : "No response from Clawdbot."; + + const response = createResponseResource({ + id: responseId, + model, + status: "completed", + output: [ + createAssistantOutputItem({ id: outputItemId, text: content, status: "completed" }), + ], + usage, + }); + + sendJson(res, 200, response); + } catch (err) { + const response = createResponseResource({ + id: responseId, + model, + status: "failed", + output: [], + error: { code: "api_error", message: String(err) }, + }); + sendJson(res, 500, response); + } + return true; + } + + // ───────────────────────────────────────────────────────────────────────── + // Streaming mode + // ───────────────────────────────────────────────────────────────────────── + + res.statusCode = 200; + res.setHeader("Content-Type", "text/event-stream; charset=utf-8"); + res.setHeader("Cache-Control", "no-cache"); + res.setHeader("Connection", "keep-alive"); + res.flushHeaders?.(); + + let accumulatedText = ""; + let sawAssistantDelta = false; + let closed = false; + let unsubscribe = () => {}; + let finalUsage: Usage | undefined; + let finalizeRequested: { status: ResponseResource["status"]; text: string } | null = null; + + const maybeFinalize = () => { + if (closed) return; + if (!finalizeRequested) return; + if (!finalUsage) return; + const usage = finalUsage; + + closed = true; + unsubscribe(); + + writeSseEvent(res, { + type: "response.output_text.done", + item_id: outputItemId, + output_index: 0, + content_index: 0, + text: finalizeRequested.text, + }); + + writeSseEvent(res, { + type: "response.content_part.done", + item_id: outputItemId, + output_index: 0, + content_index: 0, + part: { type: "output_text", text: finalizeRequested.text }, + }); + + const completedItem = createAssistantOutputItem({ + id: outputItemId, + text: finalizeRequested.text, + status: "completed", + }); + + writeSseEvent(res, { + type: "response.output_item.done", + output_index: 0, + item: completedItem, + }); + + const finalResponse = createResponseResource({ + id: responseId, + model, + status: finalizeRequested.status, + output: [completedItem], + usage, + }); + + writeSseEvent(res, { type: "response.completed", response: finalResponse }); + writeDone(res); + res.end(); + }; + + const requestFinalize = (status: ResponseResource["status"], text: string) => { + if (finalizeRequested) return; + finalizeRequested = { status, text }; + maybeFinalize(); + }; + + // Send initial events + const initialResponse = createResponseResource({ + id: responseId, + model, + status: "in_progress", + output: [], + }); + + writeSseEvent(res, { type: "response.created", response: initialResponse }); + writeSseEvent(res, { type: "response.in_progress", response: initialResponse }); + + // Add output item + const outputItem = createAssistantOutputItem({ + id: outputItemId, + text: "", + status: "in_progress", + }); + + writeSseEvent(res, { + type: "response.output_item.added", + output_index: 0, + item: outputItem, + }); + + // Add content part + writeSseEvent(res, { + type: "response.content_part.added", + item_id: outputItemId, + output_index: 0, + content_index: 0, + part: { type: "output_text", text: "" }, + }); + + unsubscribe = onAgentEvent((evt) => { + if (evt.runId !== responseId) return; + if (closed) return; + + if (evt.stream === "assistant") { + const delta = evt.data?.delta; + const text = evt.data?.text; + const content = typeof delta === "string" ? delta : typeof text === "string" ? text : ""; + if (!content) return; + + sawAssistantDelta = true; + accumulatedText += content; + + writeSseEvent(res, { + type: "response.output_text.delta", + item_id: outputItemId, + output_index: 0, + content_index: 0, + delta: content, + }); + return; + } + + if (evt.stream === "lifecycle") { + const phase = evt.data?.phase; + if (phase === "end" || phase === "error") { + const finalText = accumulatedText || "No response from Clawdbot."; + const finalStatus = phase === "error" ? "failed" : "completed"; + requestFinalize(finalStatus, finalText); + } + } + }); + + req.on("close", () => { + closed = true; + unsubscribe(); + }); + + void (async () => { + try { + const result = await agentCommand( + { + message: prompt.message, + images: images.length > 0 ? images : undefined, + clientTools: resolvedClientTools.length > 0 ? resolvedClientTools : undefined, + extraSystemPrompt: extraSystemPrompt || undefined, + streamParams: streamParams ?? undefined, + sessionKey, + runId: responseId, + deliver: false, + messageChannel: "webchat", + bestEffortDeliver: false, + }, + defaultRuntime, + deps, + ); + + finalUsage = extractUsageFromResult(result); + maybeFinalize(); + + if (closed) return; + + // Fallback: if no streaming deltas were received, send the full response + if (!sawAssistantDelta) { + const resultAny = result as { payloads?: Array<{ text?: string }>; meta?: unknown }; + const payloads = resultAny.payloads; + const meta = resultAny.meta; + const stopReason = + meta && typeof meta === "object" + ? (meta as { stopReason?: string }).stopReason + : undefined; + const pendingToolCalls = + meta && typeof meta === "object" + ? ( + meta as { + pendingToolCalls?: Array<{ id: string; name: string; arguments: string }>; + } + ).pendingToolCalls + : undefined; + + // If agent called a client tool, emit function_call instead of text + if (stopReason === "tool_calls" && pendingToolCalls && pendingToolCalls.length > 0) { + const functionCall = pendingToolCalls[0]; + const usage = finalUsage ?? createEmptyUsage(); + + writeSseEvent(res, { + type: "response.output_text.done", + item_id: outputItemId, + output_index: 0, + content_index: 0, + text: "", + }); + writeSseEvent(res, { + type: "response.content_part.done", + item_id: outputItemId, + output_index: 0, + content_index: 0, + part: { type: "output_text", text: "" }, + }); + + const completedItem = createAssistantOutputItem({ + id: outputItemId, + text: "", + status: "completed", + }); + writeSseEvent(res, { + type: "response.output_item.done", + output_index: 0, + item: completedItem, + }); + + const functionCallItemId = `call_${randomUUID()}`; + const functionCallItem = { + type: "function_call" as const, + id: functionCallItemId, + call_id: functionCall.id, + name: functionCall.name, + arguments: functionCall.arguments, + }; + writeSseEvent(res, { + type: "response.output_item.added", + output_index: 1, + item: functionCallItem, + }); + writeSseEvent(res, { + type: "response.output_item.done", + output_index: 1, + item: { ...functionCallItem, status: "completed" as const }, + }); + + const incompleteResponse = createResponseResource({ + id: responseId, + model, + status: "incomplete", + output: [completedItem, functionCallItem], + usage, + }); + closed = true; + unsubscribe(); + writeSseEvent(res, { type: "response.completed", response: incompleteResponse }); + writeDone(res); + res.end(); + return; + } + + const content = + Array.isArray(payloads) && payloads.length > 0 + ? payloads + .map((p) => (typeof p.text === "string" ? p.text : "")) + .filter(Boolean) + .join("\n\n") + : "No response from Clawdbot."; + + accumulatedText = content; + sawAssistantDelta = true; + + writeSseEvent(res, { + type: "response.output_text.delta", + item_id: outputItemId, + output_index: 0, + content_index: 0, + delta: content, + }); + } + } catch (err) { + if (closed) return; + + finalUsage = finalUsage ?? createEmptyUsage(); + const errorResponse = createResponseResource({ + id: responseId, + model, + status: "failed", + output: [], + error: { code: "api_error", message: String(err) }, + usage: finalUsage, + }); + + writeSseEvent(res, { type: "response.failed", response: errorResponse }); + emitAgentEvent({ + runId: responseId, + stream: "lifecycle", + data: { phase: "error" }, + }); + } finally { + if (!closed) { + // Emit lifecycle end to trigger completion + emitAgentEvent({ + runId: responseId, + stream: "lifecycle", + data: { phase: "end" }, + }); + } + } + })(); + + return true; +} diff --git a/src/gateway/openresponses-parity.e2e.test.ts b/src/gateway/openresponses-parity.e2e.test.ts new file mode 100644 index 000000000..278855b87 --- /dev/null +++ b/src/gateway/openresponses-parity.e2e.test.ts @@ -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"); + }); + }); +}); diff --git a/src/gateway/server-http.ts b/src/gateway/server-http.ts index 52dec48f9..8638f0823 100644 --- a/src/gateway/server-http.ts +++ b/src/gateway/server-http.ts @@ -26,6 +26,7 @@ import { } from "./hooks.js"; import { applyHookMappings } from "./hooks-mapping.js"; import { handleOpenAiHttpRequest } from "./openai-http.js"; +import { handleOpenResponsesHttpRequest } from "./openresponses-http.js"; type SubsystemLogger = ReturnType; @@ -192,6 +193,8 @@ export function createGatewayHttpServer(opts: { controlUiEnabled: boolean; controlUiBasePath: string; openAiChatCompletionsEnabled: boolean; + openResponsesEnabled: boolean; + openResponsesConfig?: import("../config/types.gateway.js").GatewayHttpResponsesConfig; handleHooksRequest: HooksRequestHandler; handlePluginRequest?: HooksRequestHandler; resolvedAuth: import("./auth.js").ResolvedGatewayAuth; @@ -202,6 +205,8 @@ export function createGatewayHttpServer(opts: { controlUiEnabled, controlUiBasePath, openAiChatCompletionsEnabled, + openResponsesEnabled, + openResponsesConfig, handleHooksRequest, handlePluginRequest, resolvedAuth, @@ -222,6 +227,15 @@ export function createGatewayHttpServer(opts: { if (await handleHooksRequest(req, res)) return; if (await handleSlackHttpRequest(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 (await handleOpenAiHttpRequest(req, res, { auth: resolvedAuth })) return; } diff --git a/src/gateway/server-runtime-config.ts b/src/gateway/server-runtime-config.ts index 5f4a4f834..c8b4a1721 100644 --- a/src/gateway/server-runtime-config.ts +++ b/src/gateway/server-runtime-config.ts @@ -17,6 +17,8 @@ export type GatewayRuntimeConfig = { bindHost: string; controlUiEnabled: boolean; openAiChatCompletionsEnabled: boolean; + openResponsesEnabled: boolean; + openResponsesConfig?: import("../config/types.gateway.js").GatewayHttpResponsesConfig; controlUiBasePath: string; resolvedAuth: ResolvedGatewayAuth; authMode: ResolvedGatewayAuth["mode"]; @@ -33,6 +35,7 @@ export async function resolveGatewayRuntimeConfig(params: { host?: string; controlUiEnabled?: boolean; openAiChatCompletionsEnabled?: boolean; + openResponsesEnabled?: boolean; auth?: GatewayAuthConfig; tailscale?: GatewayTailscaleConfig; }): Promise { @@ -45,6 +48,8 @@ export async function resolveGatewayRuntimeConfig(params: { params.openAiChatCompletionsEnabled ?? params.cfg.gateway?.http?.endpoints?.chatCompletions?.enabled ?? 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 authBase = params.cfg.gateway?.auth ?? {}; const authOverrides = params.auth ?? {}; @@ -88,6 +93,10 @@ export async function resolveGatewayRuntimeConfig(params: { bindHost, controlUiEnabled, openAiChatCompletionsEnabled, + openResponsesEnabled, + openResponsesConfig: openResponsesConfig + ? { ...openResponsesConfig, enabled: openResponsesEnabled } + : undefined, controlUiBasePath, resolvedAuth, authMode, diff --git a/src/gateway/server-runtime-state.ts b/src/gateway/server-runtime-state.ts index 43e82c759..788467518 100644 --- a/src/gateway/server-runtime-state.ts +++ b/src/gateway/server-runtime-state.ts @@ -27,6 +27,8 @@ export async function createGatewayRuntimeState(params: { controlUiEnabled: boolean; controlUiBasePath: string; openAiChatCompletionsEnabled: boolean; + openResponsesEnabled: boolean; + openResponsesConfig?: import("../config/types.gateway.js").GatewayHttpResponsesConfig; resolvedAuth: ResolvedGatewayAuth; gatewayTls?: GatewayTlsRuntime; hooksConfig: () => HooksConfigResolved | null; @@ -103,6 +105,8 @@ export async function createGatewayRuntimeState(params: { controlUiEnabled: params.controlUiEnabled, controlUiBasePath: params.controlUiBasePath, openAiChatCompletionsEnabled: params.openAiChatCompletionsEnabled, + openResponsesEnabled: params.openResponsesEnabled, + openResponsesConfig: params.openResponsesConfig, handleHooksRequest, handlePluginRequest, resolvedAuth: params.resolvedAuth, diff --git a/src/gateway/server.impl.ts b/src/gateway/server.impl.ts index 268d59f32..9572b5a32 100644 --- a/src/gateway/server.impl.ts +++ b/src/gateway/server.impl.ts @@ -111,6 +111,11 @@ export type GatewayServerOptions = { * Default: config `gateway.http.endpoints.chatCompletions.enabled` (or false when absent). */ 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). */ @@ -205,6 +210,7 @@ export async function startGatewayServer( host: opts.host, controlUiEnabled: opts.controlUiEnabled, openAiChatCompletionsEnabled: opts.openAiChatCompletionsEnabled, + openResponsesEnabled: opts.openResponsesEnabled, auth: opts.auth, tailscale: opts.tailscale, }); @@ -212,6 +218,8 @@ export async function startGatewayServer( bindHost, controlUiEnabled, openAiChatCompletionsEnabled, + openResponsesEnabled, + openResponsesConfig, controlUiBasePath, resolvedAuth, tailscaleConfig, @@ -250,6 +258,8 @@ export async function startGatewayServer( controlUiEnabled, controlUiBasePath, openAiChatCompletionsEnabled, + openResponsesEnabled, + openResponsesConfig, resolvedAuth, gatewayTls, hooksConfig: () => hooksConfig,