--- a/src/claude.js +++ b/src/claude.js @@ -83,20 +83,16 @@ const IDLE_TIMEOUT_MS = parseInt(process.env.IDLE_TIMEOUT_MS) || 120000; // 2 min idle = dead /** - * Map OC reasoning_effort levels to Claude CLI --effort levels. - * OC sends: "minimal" | "low" | "medium" | "high" | "xhigh" - * Claude CLI accepts: "low" | "medium" | "high" + * Map alvin-bot reasoning_effort levels to the Claude CLI --effort flag. + * PATCHED for alvin-bot (2026-06-16): the upstream had an OpenClaw-specific + * off-by-one map (low→medium, high→max). alvin-bot's EffortLevel is + * "low" | "medium" | "high" | "max", and the CLI accepts low|medium|high| + * xhigh|max — so we pass valid levels straight through 1:1. */ function mapEffort(reasoningEffort) { if (!reasoningEffort) return null; - const map = { - 'minimal': 'low', - 'low': 'medium', - 'medium': 'high', - 'high': 'max', - 'xhigh': 'max', - }; - return map[reasoningEffort] || null; + const allowed = new Set(['low', 'medium', 'high', 'xhigh', 'max']); + return allowed.has(reasoningEffort) ? reasoningEffort : null; } // --- Dynamic auto-scrub for OC detection bypass --- @@ -238,7 +234,27 @@ // Always disable native tools (CLI flag, not session property) args.push('--tools', ''); + + // PATCHED for alvin-bot (2026-06-16): load NO setting sources. Without + // this, the CLI auto-discovers the host's ~/.claude/CLAUDE.md + memory + // and injects them — for alvin-bot's operator that is a ~32KB file + // describing a rich MCP/browser tool environment. It (a) bloated input + // from ~600 to ~72,000 tokens and (b) confused Claude about which tools + // it actually has, making it REFUSE the text protocol + // ("that tool isn't available in this environment"). The orchestrator + // supplies the only system prompt + tool contract we want; nothing else + // should leak in. Empty value = load user/project/local: none. + args.push('--setting-sources', ''); + // PATCHED for alvin-bot (2026-06-16): use NO MCP servers. Same class of + // bug as setting-sources — the host has Canva/Gmail/computer-use/etc. + // MCP servers configured globally; without this the CLI loads all of + // them, injecting ~34KB of MCP tool schemas and making Claude believe + // "my tools are Canva and Gmail", so it refuses the read_file/run_shell + // protocol. --strict-mcp-config + no --mcp-config = zero MCP + // servers, so the only tools Claude sees are the ones we inject as text. + args.push('--strict-mcp-config'); + // Map OC reasoning_effort → Claude CLI --effort const effort = mapEffort(reasoningEffort); if (effort) { --- a/src/server.js +++ b/src/server.js @@ -3,6 +3,7 @@ const express = require('express'); const fs = require('fs'); const path = require('path'); +const os = require('os'); const { v4: uuidv4 } = require('uuid'); const { convertMessages, convertMessagesCompact, extractNewMessages, extractNewUserMessages } = require('./convert'); const { buildToolInstructions } = require('./tools'); @@ -60,7 +61,11 @@ } // --- Persistence --- -const STATE_FILE = path.join(__dirname, '..', 'state.json'); +// PATCHED for alvin-bot: write state to a WRITABLE dir, not the package dir +// (the bundled bridge lives inside a possibly read-only global node_modules). +// The bot passes CLAUDE_BRIDGE_STATE_FILE; fall back to ~/.alvin-bot. +const STATE_FILE = process.env.CLAUDE_BRIDGE_STATE_FILE + || path.join(os.homedir(), '.alvin-bot', 'claude-bridge-state.json'); function saveState() { try { @@ -267,8 +272,48 @@ // Load persisted state (channelMap, responseMap, requestLog, stats, globalActivity) loadState(); + +/** + * PATCHED for alvin-bot (2026-06-16): escape raw control chars that appear + * INSIDE JSON string values, so a tool_call whose argument contains a literal + * newline/tab still parses. Walks the string tracking quote state; only touches + * chars inside string literals. Belt-and-suspenders for the text protocol. + */ +function repairJsonControlChars(s) { + let out = '', inStr = false, esc = false; + for (let i = 0; i < s.length; i++) { + const ch = s[i]; + if (esc) { out += ch; esc = false; continue; } + if (ch === '\\') { out += ch; esc = true; continue; } + if (ch === '"') { inStr = !inStr; out += ch; continue; } + if (inStr) { + if (ch === '\n') { out += '\\n'; continue; } + if (ch === '\r') { out += '\\r'; continue; } + if (ch === '\t') { out += '\\t'; continue; } + } + out += ch; + } + return out; +} /** + * PATCHED for alvin-bot (2026-06-16): normalize the arguments object. Claude + * does not always nest args under "arguments" — it commonly FLATTENS them to + * the top level, e.g. {"name":"python_execute","code":"..."} instead of + * {"name":"python_execute","arguments":{"code":"..."}}. Without recovery the + * args are silently dropped (the tool runs with {} → python/run_shell error). + * Also accept "input"/"parameters" aliases. Recovers every non-reserved key. + */ +function extractToolArgs(parsed) { + for (const key of ['arguments', 'input', 'parameters']) { + const v = parsed[key]; + if (v && typeof v === 'object' && !Array.isArray(v)) return v; + } + const { name, arguments: _a, input: _i, parameters: _p, ...rest } = parsed; + return rest; +} + +/** * Parse blocks from Claude's response text. * Returns array of { id, name, arguments } or empty array. */ @@ -285,23 +330,27 @@ continue; } const jsonText = raw.slice(start, end + 1); + let parsed; try { - const parsed = JSON.parse(jsonText); - if (!parsed || typeof parsed.name !== 'string') { - console.error(`[parseToolCalls] Invalid tool_call payload: ${jsonText.slice(0, 300)}`); + parsed = JSON.parse(jsonText); + } catch (err) { + // Lenient retry: repair raw control chars inside string values. + try { + parsed = JSON.parse(repairJsonControlChars(jsonText)); + } catch (err2) { + console.error(`[parseToolCalls] Failed to parse JSON: ${jsonText.slice(0, 300)}`); continue; } - const args = (parsed.arguments && typeof parsed.arguments === 'object' && !Array.isArray(parsed.arguments)) - ? parsed.arguments - : {}; - calls.push({ - id: `call_${uuidv4().slice(0, 8)}`, - name: parsed.name, - arguments: args, - }); - } catch (err) { - console.error(`[parseToolCalls] Failed to parse JSON: ${jsonText.slice(0, 300)}`); } + if (!parsed || typeof parsed.name !== 'string') { + console.error(`[parseToolCalls] Invalid tool_call payload: ${jsonText.slice(0, 300)}`); + continue; + } + calls.push({ + id: `call_${uuidv4().slice(0, 8)}`, + name: parsed.name, + arguments: extractToolArgs(parsed), + }); } return calls; } @@ -936,8 +985,12 @@ }); } -// Serve React dashboard (built files) -statusApp.use(express.static(path.join(__dirname, '../dashboard/dist'))); +// Serve React dashboard (built files) — only if present. PATCHED for alvin-bot: +// the bundled slim bridge ships no dashboard, so guard the static mount + skip +// the SPA fallback below; the /status JSON endpoint still works. +const DASHBOARD_DIR = path.join(__dirname, '../dashboard/dist'); +const HAS_DASHBOARD = fs.existsSync(DASHBOARD_DIR); +if (HAS_DASHBOARD) statusApp.use(express.static(DASHBOARD_DIR)); statusApp.get('/status', (req, res) => { res.json({ @@ -971,9 +1024,11 @@ res.json(result); }); -// SPA fallback — serve index.html for any non-API route +// SPA fallback — serve index.html for any non-API route, only when the +// dashboard is present (PATCHED for alvin-bot: the bundled bridge has none). statusApp.get('*', (req, res) => { - res.sendFile(path.join(__dirname, '../dashboard/dist/index.html')); + if (HAS_DASHBOARD) return res.sendFile(path.join(DASHBOARD_DIR, 'index.html')); + res.status(404).json({ error: 'no dashboard in this build; use /status for JSON' }); }); module.exports = { app, statusApp, stats, saveState }; --- a/src/tools.js +++ b/src/tools.js @@ -5,6 +5,40 @@ const GATEWAY_BLOCKED = new Set(['sessions_send', 'sessions_spawn', 'gateway']); /** + * Render a single OpenAI function-calling parameter schema into a compact, + * human/Claude-readable signature plus per-arg notes. + * + * PATCHED for alvin-bot (2026-06-16): the upstream bridge only passed each + * tool's name + description to Claude, dropping the JSON-Schema entirely. That + * forced Claude to GUESS argument keys/types — unreliable for anything beyond + * the obvious (path/command). We now surface the full parameter contract + * (names, types, required-ness, descriptions, enums) so Claude emits + * arguments that match what alvin-bot's tool-executor actually expects. + */ +function renderParams(fn) { + const schema = fn.parameters || {}; + const props = schema.properties || {}; + const required = Array.isArray(schema.required) ? schema.required : []; + const keys = Object.keys(props); + if (keys.length === 0) { + return { signature: '', notes: [] }; + } + const sigParts = []; + const notes = []; + for (const k of keys) { + const p = props[k] || {}; + const opt = required.includes(k) ? '' : '?'; + const type = p.type || 'any'; + sigParts.push(`${k}${opt}: ${type}`); + const bits = []; + if (p.description) bits.push(p.description); + if (Array.isArray(p.enum)) bits.push(`one of: ${p.enum.join(', ')}`); + if (bits.length > 0) notes.push(` - ${k}: ${bits.join(' — ')}`); + } + return { signature: sigParts.join(', '), notes }; +} + +/** * Build tool instructions for the system prompt. * * In the new architecture, Claude does NOT execute tools. @@ -21,8 +55,13 @@ '', '## Tool Calling Protocol', '', - 'When you need to use a tool, output EXACTLY this format and then STOP:', + 'You HAVE access to the tools listed below. They are live, real, and fully', + 'functional in this environment. An external orchestrator executes them for', + 'you and returns the real output. This is how you read files, run commands,', + 'search the web, etc.', '', + 'To use a tool, output EXACTLY this format and then STOP immediately:', + '', '', '{"name": "tool_name", "arguments": {"key": "value"}}', '', @@ -33,24 +72,44 @@ '{"name": "web_search", "arguments": {"query": "bitcoin price"}}', '', '', - '{"name": "memory_search", "arguments": {"query": "user preferences"}}', + '{"name": "read_file", "arguments": {"path": "/etc/hosts"}}', '', '', - 'CRITICAL RULES:', - '- Do NOT execute tools yourself. Do NOT use Bash, Read, Write, Edit, WebSearch, WebFetch, Glob, Grep, or any native tools.', - '- Output blocks and STOP. The orchestrator will execute them and provide results.', - '- If you do not need any tools, just respond with your answer directly.', - '- The conversation may already contain tool results from previous turns — use them, do not re-request.', + 'CRITICAL RULES — follow these exactly:', + '- NEVER say a tool is "not available", that you "cannot access" a file/URL/', + ' command, or ask the user to paste content. If a listed tool can get it,', + ' CALL the tool instead. Claiming unavailability is always wrong here.', + '- Do NOT execute tools yourself and do NOT use any native Claude Code tools', + ' (Bash, Read, Write, Edit, WebSearch, WebFetch, Glob, Grep, Task). Use ONLY', + ' the protocol above.', + '- After emitting your block(s), STOP your response immediately.', + ' Do NOT write "Here are the results", do NOT guess, invent, or fabricate the', + ' tool output. The orchestrator runs the tool and gives you the REAL result', + ' in the next turn — only then do you continue.', + '- Put ALL arguments INSIDE the "arguments" object — never as top-level', + ' keys. Correct: {"name":"python_execute","arguments":{"code":"..."}}.', + ' Wrong: {"name":"python_execute","code":"..."}.', + '- Use the EXACT argument names shown in each tool signature below. Include', + ' every required argument (those without a "?").', + '- "arguments" must be valid JSON: escape newlines in string values as \\n,', + ' tabs as \\t and double-quotes as \\". Never put a raw line break inside a', + ' JSON string.', + '- If you genuinely need no tools, just answer directly.', + '- The conversation may already contain tool results from previous turns —', + ' use them, do not re-request the same call.', '', 'Available tools:', ]; for (const tool of tools) { - const name = tool.function?.name || tool.name; + const fn = tool.function || tool; + const name = fn.name; if (!name) continue; if (GATEWAY_BLOCKED.has(name)) continue; - const desc = tool.function?.description || tool.description || ''; - lines.push(`- **${name}**: ${desc}`); + const desc = fn.description || ''; + const { signature, notes } = renderParams(fn); + lines.push(`- **${name}**(${signature}): ${desc}`); + for (const note of notes) lines.push(note); } return lines.join('\n'); --- a/src/index.js +++ b/src/index.js @@ -11,9 +11,11 @@ console.log(`[openclaw-claude-bridge] API → http://127.0.0.1:${API_PORT}`); }); -// Status server — all interfaces (LAN access for dashboard) -const statusServer = http.createServer(statusApp).listen(STATUS_PORT, '0.0.0.0', () => { - console.log(`[openclaw-claude-bridge] Status → http://0.0.0.0:${STATUS_PORT}`); +// Status server — PATCHED for alvin-bot: bind loopback only (was 0.0.0.0). +// The bundled bridge auto-runs on every user's machine; a status server on all +// interfaces would expose request history/usage on the LAN. Loopback only. +const statusServer = http.createServer(statusApp).listen(STATUS_PORT, '127.0.0.1', () => { + console.log(`[openclaw-claude-bridge] Status → http://127.0.0.1:${STATUS_PORT}`); }); // --- Graceful shutdown ---