diff --git a/cli.js b/cli.js index 2906e436..78814306 100644 --- a/cli.js +++ b/cli.js @@ -83,6 +83,20 @@ const { dispatchAutomationNotifiers, formatTaskRunNotificationPayload } = require('./lib/automation'); +const { + ALLOWED_EVENTS: WEBHOOK_ALLOWED_EVENTS, + defaultConfigPath: defaultWebhookConfigPath, + loadWebhookConfig, + saveWebhookConfig, + notifyWebhook +} = require('./lib/cli-webhook'); +const { + performHandshake: performWsHandshake, + sendText: wsSendText, + sendJson: wsSendJson, + sendClose: wsSendClose, + makeFrameReader: makeWsFrameReader +} = require('./lib/cli-ws-server'); const { buildConfigHealthReport: buildConfigHealthReportCore } = require('./cli/config-health'); const { buildDoctorReport, buildDoctorLegacyPayload, renderDoctorMarkdown } = require('./cli/doctor-core'); const { @@ -9764,6 +9778,89 @@ const PUBLIC_WEB_UI_STATIC_ASSETS = new Set([ 'session-helpers.mjs' ]); +const TERMINAL_WS_ALLOWED_COMMAND_BASENAMES = new Set([ + 'codexmate', 'codexmate.cmd', 'codexmate.exe', + 'node', 'node.exe', + 'echo', 'echo.exe' +]); + +function isTerminalWsCommandAllowed(cmd) { + if (!cmd || typeof cmd !== 'string') return false; + const trimmed = cmd.trim(); + if (!trimmed) return false; + const base = path.basename(trimmed).toLowerCase(); + return TERMINAL_WS_ALLOWED_COMMAND_BASENAMES.has(base); +} + +function attachTerminalSession(socket) { + let child = null; + let closed = false; + const close = () => { + if (closed) return; + closed = true; + if (child && !child.killed) { + try { child.kill(); } catch (_) {} + } + try { wsSendClose(socket, 1000); } catch (_) {} + }; + socket.on('error', close); + socket.on('end', close); + socket.on('close', close); + + const reader = makeWsFrameReader((message) => { + if (closed) return; + let parsed; + try { parsed = JSON.parse(message); } catch (_) { return; } + if (!parsed || typeof parsed !== 'object') return; + if (parsed.type === 'run') { + if (child) { + wsSendJson(socket, { type: 'error', message: 'busy' }); + return; + } + const cmd = String(parsed.cmd || '').trim(); + const argv = Array.isArray(parsed.args) ? parsed.args.map(String) : []; + if (!isTerminalWsCommandAllowed(cmd)) { + wsSendJson(socket, { type: 'error', message: 'command not allowed' }); + wsSendClose(socket, 1008); + return; + } + try { + child = spawn(cmd, argv, { + cwd: process.cwd(), + env: process.env, + windowsHide: true, + shell: false + }); + } catch (e) { + wsSendJson(socket, { type: 'error', message: e && e.message ? e.message : String(e) }); + wsSendClose(socket, 1011); + return; + } + wsSendJson(socket, { type: 'started', pid: child.pid }); + child.stdout.on('data', (chunk) => { + wsSendJson(socket, { type: 'data', stream: 'stdout', text: chunk.toString('utf-8') }); + }); + child.stderr.on('data', (chunk) => { + wsSendJson(socket, { type: 'data', stream: 'stderr', text: chunk.toString('utf-8') }); + }); + child.on('error', (err) => { + wsSendJson(socket, { type: 'error', message: err && err.message ? err.message : String(err) }); + }); + child.on('exit', (code, signal) => { + wsSendJson(socket, { type: 'exit', code: code, signal: signal || '' }); + wsSendClose(socket, 1000); + }); + } else if (parsed.type === 'kill') { + if (child && !child.killed) { + try { child.kill(); } catch (_) {} + } + } else if (parsed.type === 'ping') { + wsSendJson(socket, { type: 'pong' }); + } + }, close); + socket.on('data', reader); +} + function createWebServer({ htmlPath, assetsDir, webDir, host, port, openBrowser }) { const connections = new Set(); const probeWebUiReadiness = (callback) => { @@ -10133,6 +10230,10 @@ function createWebServer({ htmlPath, assetsDir, webDir, host, port, openBrowser break; case 'apply-claude-md-file': result = applyClaudeMdFile(params || {}); + if (result && !result.error) { + const mdTarget = (params && params.targetPath) ? String(params.targetPath) : 'CLAUDE.md'; + notifyWebhook('claude-md-edit', 'CLAUDE.md modified: ' + mdTarget, { targetPath: mdTarget }).catch(function () {}); + } break; case 'preview-agents-diff': result = buildAgentsDiff(params || {}); @@ -10208,7 +10309,32 @@ function createWebServer({ htmlPath, assetsDir, webDir, host, port, openBrowser break; case 'apply-claude-config': result = applyToClaudeSettings(params.config); + if (result && !result.error) { + const cfgName = (params && params.config && typeof params.config.name === 'string') ? params.config.name : ''; + const cfgFrom = (params && typeof params.previousName === 'string') ? params.previousName : ''; + const summary = cfgFrom + ? ('Provider switched: ' + cfgFrom + ' -> ' + cfgName) + : ('Provider applied: ' + cfgName); + notifyWebhook('provider-switch', summary, { name: cfgName, previousName: cfgFrom }).catch(function () {}); + } + break; + case 'get-webhook-config': + result = loadWebhookConfig(); + break; + case 'set-webhook-config': + result = saveWebhookConfig(params && params.config ? params.config : {}); + break; + case 'test-webhook': { + const overrideCfg = params && params.config ? params.config : null; + const probe = await notifyWebhook( + 'provider-switch', + 'codexmate webhook test ping', + { test: true }, + overrideCfg ? { config: overrideCfg } : {} + ); + result = probe; break; + } case 'export-claude-share': result = buildClaudeSharePayload(params && params.config ? params.config : {}); break; @@ -10723,6 +10849,36 @@ function createWebServer({ htmlPath, assetsDir, webDir, host, port, openBrowser socket.on('close', () => connections.delete(socket)); }); + server.on('upgrade', (req, socket, head) => { + const requestPath = (req.url || '/').split('?')[0]; + if (requestPath !== '/ws/terminal') { + try { socket.destroy(); } catch (_) {} + return; + } + const remoteAddr = socket && socket.remoteAddress ? socket.remoteAddress : ''; + const isLoopback = !remoteAddr + || remoteAddr === '127.0.0.1' + || remoteAddr === '::1' + || remoteAddr === '::ffff:127.0.0.1'; + if (!isLoopback) { + const expected = typeof process.env.CODEXMATE_HTTP_TOKEN === 'string' + ? process.env.CODEXMATE_HTTP_TOKEN.trim() + : ''; + const headers = req && req.headers ? req.headers : {}; + const auth = typeof headers.authorization === 'string' ? headers.authorization.trim() : ''; + const match = auth ? auth.match(/^bearer\s+(.+)$/i) : null; + const token = match && match[1] + ? match[1].trim() + : (typeof headers['x-codexmate-token'] === 'string' ? String(headers['x-codexmate-token']).trim() : ''); + if (!expected || token !== expected) { + try { socket.destroy(); } catch (_) {} + return; + } + } + if (!performWsHandshake(req, socket)) return; + attachTerminalSession(socket); + }); + server.once('error', (err) => { if (err && err.code === 'EADDRINUSE') { console.error(`! 启动失败: 端口 ${port} 已被占用,可能有残留的 codexmate run 实例。`); diff --git a/lib/cli-webhook.js b/lib/cli-webhook.js new file mode 100644 index 00000000..3a67c4ce --- /dev/null +++ b/lib/cli-webhook.js @@ -0,0 +1,126 @@ +const path = require('path'); +const fs = require('fs'); +const http = require('http'); +const https = require('https'); +const os = require('os'); + +const ALLOWED_EVENTS = ['provider-switch', 'claude-md-edit']; +const DEFAULT_TIMEOUT_MS = 5000; + +function defaultConfigPath() { + return path.join(os.homedir(), '.codex', 'codexmate-webhook.json'); +} + +function normalizeConfig(cfg) { + const out = { enabled: false, url: '', events: ALLOWED_EVENTS.slice() }; + if (!cfg || typeof cfg !== 'object') return out; + out.enabled = !!cfg.enabled; + out.url = typeof cfg.url === 'string' ? cfg.url.trim() : ''; + if (Array.isArray(cfg.events)) { + const filtered = cfg.events.filter(function (e) { return ALLOWED_EVENTS.indexOf(e) !== -1; }); + out.events = filtered.length ? filtered : ALLOWED_EVENTS.slice(); + } + return out; +} + +function loadWebhookConfig(filePath) { + const target = filePath || defaultConfigPath(); + try { + if (!fs.existsSync(target)) { + return normalizeConfig({}); + } + const raw = fs.readFileSync(target, 'utf-8'); + return normalizeConfig(JSON.parse(raw)); + } catch (_) { + return normalizeConfig({}); + } +} + +function saveWebhookConfig(cfg, filePath) { + const target = filePath || defaultConfigPath(); + const normalized = normalizeConfig(cfg); + try { + fs.mkdirSync(path.dirname(target), { recursive: true }); + } catch (_) {} + fs.writeFileSync(target, JSON.stringify(normalized, null, 2), 'utf-8'); + return normalized; +} + +function postJson(targetUrl, payload, timeoutMs) { + return new Promise(function (resolve) { + let parsed; + try { + parsed = new URL(targetUrl); + } catch (_) { + resolve({ ok: false, error: 'invalid-url' }); + return; + } + const transport = parsed.protocol === 'https:' ? https : http; + const body = JSON.stringify(payload || {}); + let req; + try { + req = transport.request({ + method: 'POST', + protocol: parsed.protocol, + hostname: parsed.hostname, + port: parsed.port || (parsed.protocol === 'https:' ? 443 : 80), + path: (parsed.pathname || '/') + (parsed.search || ''), + headers: { + 'Content-Type': 'application/json; charset=utf-8', + 'Content-Length': Buffer.byteLength(body, 'utf-8'), + 'User-Agent': 'codexmate-webhook/1' + } + }, function (res) { + let raw = ''; + res.on('data', function (chunk) { + if (raw.length < 1024) raw += chunk.toString('utf-8'); + }); + res.on('end', function () { + const status = res.statusCode || 0; + resolve({ ok: status >= 200 && status < 300, status: status, body: raw.slice(0, 200) }); + }); + }); + } catch (e) { + resolve({ ok: false, error: e && e.message ? e.message : String(e) }); + return; + } + const wait = Number.isFinite(timeoutMs) && timeoutMs > 0 ? timeoutMs : DEFAULT_TIMEOUT_MS; + req.setTimeout(wait, function () { req.destroy(new Error('timeout')); }); + req.on('error', function (err) { resolve({ ok: false, error: err && err.message ? err.message : String(err) }); }); + req.write(body); + req.end(); + }); +} + +function buildPayload(event, summary, details) { + return { + event: String(event || ''), + summary: String(summary || ''), + operator: process.env.USER || process.env.USERNAME || (os.userInfo && os.userInfo().username) || '', + timestamp: new Date().toISOString(), + details: details && typeof details === 'object' ? details : {} + }; +} + +function notifyWebhook(event, summary, details, options) { + const opts = options || {}; + const cfg = opts.config ? normalizeConfig(opts.config) : loadWebhookConfig(opts.filePath); + if (!cfg.enabled || !cfg.url) { + return Promise.resolve({ ok: false, skipped: true, reason: 'disabled' }); + } + if (cfg.events.indexOf(event) === -1) { + return Promise.resolve({ ok: false, skipped: true, reason: 'event-filtered' }); + } + return postJson(cfg.url, buildPayload(event, summary, details), opts.timeoutMs); +} + +module.exports = { + ALLOWED_EVENTS, + defaultConfigPath, + normalizeConfig, + loadWebhookConfig, + saveWebhookConfig, + notifyWebhook, + buildPayload, + postJson +}; diff --git a/lib/cli-ws-server.js b/lib/cli-ws-server.js new file mode 100644 index 00000000..595e5502 --- /dev/null +++ b/lib/cli-ws-server.js @@ -0,0 +1,184 @@ +const crypto = require('crypto'); + +const WS_GUID = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11'; + +function computeAcceptKey(clientKey) { + return crypto.createHash('sha1').update(String(clientKey || '') + WS_GUID).digest('base64'); +} + +function performHandshake(req, socket) { + const headers = req && req.headers ? req.headers : {}; + const key = headers['sec-websocket-key']; + if (!key || (headers.upgrade || '').toLowerCase() !== 'websocket') { + try { socket.destroy(); } catch (_) {} + return false; + } + const accept = computeAcceptKey(key); + const lines = [ + 'HTTP/1.1 101 Switching Protocols', + 'Upgrade: websocket', + 'Connection: Upgrade', + 'Sec-WebSocket-Accept: ' + accept + ]; + try { + socket.write(lines.join('\r\n') + '\r\n\r\n'); + } catch (_) { + return false; + } + return true; +} + +function encodeFrame(payload, opcode) { + const data = Buffer.isBuffer(payload) ? payload : Buffer.from(String(payload || ''), 'utf-8'); + const op = typeof opcode === 'number' ? opcode : 0x1; + const len = data.length; + let header; + if (len < 126) { + header = Buffer.alloc(2); + header[0] = 0x80 | op; + header[1] = len; + } else if (len < 65536) { + header = Buffer.alloc(4); + header[0] = 0x80 | op; + header[1] = 126; + header.writeUInt16BE(len, 2); + } else { + header = Buffer.alloc(10); + header[0] = 0x80 | op; + header[1] = 127; + header.writeBigUInt64BE(BigInt(len), 2); + } + return Buffer.concat([header, data]); +} + +function sendText(socket, text) { + if (!socket || socket.destroyed) return false; + try { + socket.write(encodeFrame(String(text == null ? '' : text), 0x1)); + return true; + } catch (_) { + return false; + } +} + +function sendJson(socket, obj) { + return sendText(socket, JSON.stringify(obj || {})); +} + +function sendClose(socket, code) { + if (!socket || socket.destroyed) return; + try { + const c = Number.isFinite(code) ? code : 1000; + const payload = Buffer.alloc(2); + payload.writeUInt16BE(c, 0); + const header = Buffer.from([0x88, payload.length]); + socket.write(Buffer.concat([header, payload])); + socket.end(); + } catch (_) {} +} + +function makeFrameReader(onMessage, onClose) { + let buffer = Buffer.alloc(0); + return function feed(chunk) { + buffer = Buffer.concat([buffer, chunk]); + while (true) { + if (buffer.length < 2) return; + const b0 = buffer[0]; + const b1 = buffer[1]; + const opcode = b0 & 0x0f; + const masked = (b1 & 0x80) === 0x80; + let len = b1 & 0x7f; + let p = 2; + if (len === 126) { + if (buffer.length < 4) return; + len = buffer.readUInt16BE(2); + p = 4; + } else if (len === 127) { + if (buffer.length < 10) return; + len = Number(buffer.readBigUInt64BE(2)); + p = 10; + } + let mask = null; + if (masked) { + if (buffer.length < p + 4) return; + mask = buffer.slice(p, p + 4); + p += 4; + } + if (buffer.length < p + len) return; + let payload = buffer.slice(p, p + len); + if (masked && mask) { + const out = Buffer.alloc(len); + for (let i = 0; i < len; i++) out[i] = payload[i] ^ mask[i % 4]; + payload = out; + } + buffer = buffer.slice(p + len); + if (opcode === 0x1 || opcode === 0x2) { + if (typeof onMessage === 'function') onMessage(payload.toString('utf-8')); + } else if (opcode === 0x8) { + if (typeof onClose === 'function') onClose(); + return; + } + } + }; +} + +function buildClientHandshake(targetUrl, extraHeaders) { + const parsed = new URL(targetUrl); + const key = crypto.randomBytes(16).toString('base64'); + const headers = { + Host: parsed.host, + Upgrade: 'websocket', + Connection: 'Upgrade', + 'Sec-WebSocket-Key': key, + 'Sec-WebSocket-Version': '13' + }; + if (extraHeaders && typeof extraHeaders === 'object') { + Object.keys(extraHeaders).forEach(function (k) { headers[k] = extraHeaders[k]; }); + } + const lines = ['GET ' + (parsed.pathname || '/') + (parsed.search || '') + ' HTTP/1.1']; + Object.keys(headers).forEach(function (k) { lines.push(k + ': ' + headers[k]); }); + return { + host: parsed.hostname, + port: parsed.port || (parsed.protocol === 'wss:' || parsed.protocol === 'https:' ? 443 : 80), + request: lines.join('\r\n') + '\r\n\r\n', + expectedAccept: computeAcceptKey(key) + }; +} + +function encodeMaskedFrame(text, opcode) { + const data = Buffer.from(String(text == null ? '' : text), 'utf-8'); + const op = typeof opcode === 'number' ? opcode : 0x1; + const len = data.length; + let header; + if (len < 126) { + header = Buffer.alloc(2); + header[0] = 0x80 | op; + header[1] = 0x80 | len; + } else if (len < 65536) { + header = Buffer.alloc(4); + header[0] = 0x80 | op; + header[1] = 0x80 | 126; + header.writeUInt16BE(len, 2); + } else { + header = Buffer.alloc(10); + header[0] = 0x80 | op; + header[1] = 0x80 | 127; + header.writeBigUInt64BE(BigInt(len), 2); + } + const mask = crypto.randomBytes(4); + const masked = Buffer.alloc(len); + for (let i = 0; i < len; i++) masked[i] = data[i] ^ mask[i % 4]; + return Buffer.concat([header, mask, masked]); +} + +module.exports = { + computeAcceptKey, + performHandshake, + encodeFrame, + sendText, + sendJson, + sendClose, + makeFrameReader, + buildClientHandshake, + encodeMaskedFrame +}; diff --git a/tests/e2e/run.js b/tests/e2e/run.js index ec7261d3..224b038a 100644 --- a/tests/e2e/run.js +++ b/tests/e2e/run.js @@ -31,6 +31,8 @@ const testWebUiAssets = require('./test-web-ui-assets'); const testWebUiSessionBrowser = require('./test-web-ui-session-browser'); const testWebUiUsageInteractions = require('./test-web-ui-usage-interactions'); const testInstallStatus = require('./test-install-status'); +const testWebhook = require('./test-webhook'); +const testTerminal = require('./test-terminal'); async function main() { const realHome = os.homedir(); @@ -160,6 +162,8 @@ async function main() { await testWorkflow(ctx); await testTaskOrchestration(ctx); await testInstallStatus(ctx); + await testWebhook(ctx); + await testTerminal(ctx); await testWebUiAssets(ctx); await testWebUiSessionBrowser(ctx); await testWebUiUsageInteractions(ctx); diff --git a/tests/e2e/test-terminal.js b/tests/e2e/test-terminal.js new file mode 100644 index 00000000..3cbf406e --- /dev/null +++ b/tests/e2e/test-terminal.js @@ -0,0 +1,125 @@ +const net = require('net'); +const { assert } = require('./helpers'); +const { + buildClientHandshake, + encodeMaskedFrame, + makeFrameReader +} = require('../../lib/cli-ws-server'); + +function connectWs(port, path) { + return new Promise((resolve, reject) => { + const handshake = buildClientHandshake('ws://127.0.0.1:' + port + path); + const socket = net.connect(port, '127.0.0.1'); + let buffer = Buffer.alloc(0); + let upgraded = false; + socket.on('error', reject); + socket.on('connect', () => { + socket.write(handshake.request); + }); + const onData = (chunk) => { + buffer = Buffer.concat([buffer, chunk]); + if (!upgraded) { + const idx = buffer.indexOf('\r\n\r\n'); + if (idx === -1) return; + const head = buffer.slice(0, idx).toString('utf-8'); + buffer = buffer.slice(idx + 4); + if (!/^HTTP\/1\.1 101/.test(head)) { + socket.destroy(); + reject(new Error('handshake rejected: ' + head.split('\n')[0])); + return; + } + if (!head.toLowerCase().includes('sec-websocket-accept: ' + handshake.expectedAccept.toLowerCase())) { + socket.destroy(); + reject(new Error('handshake accept mismatch')); + return; + } + upgraded = true; + resolve({ socket, leftover: buffer }); + } + }; + socket.on('data', onData); + }); +} + +function collectFrames(socket, leftover) { + const messages = []; + const closeFlags = { closed: false }; + const reader = makeFrameReader( + (text) => { messages.push(text); }, + () => { closeFlags.closed = true; } + ); + if (leftover && leftover.length > 0) reader(leftover); + socket.on('data', reader); + socket.on('close', () => { closeFlags.closed = true; }); + return { messages, closeFlags }; +} + +function waitForFrame(messages, predicate, timeoutMs = 4000) { + return new Promise((resolve, reject) => { + const start = Date.now(); + const tick = () => { + for (let i = 0; i < messages.length; i++) { + let parsed; + try { parsed = JSON.parse(messages[i]); } catch (_) { continue; } + if (predicate(parsed)) return resolve(parsed); + } + if (Date.now() - start >= timeoutMs) return reject(new Error('frame wait timeout')); + setTimeout(tick, 30); + }; + tick(); + }); +} + +module.exports = async function testTerminal(ctx) { + const { port } = ctx; + if (!port) throw new Error('terminal e2e requires ctx.port'); + + { + const conn = await connectWs(port, '/ws/terminal'); + const { messages, closeFlags } = collectFrames(conn.socket, conn.leftover); + const payload = JSON.stringify({ + type: 'run', + cmd: 'node', + args: ['-e', "process.stdout.write('hello-terminal-e2e\\n');"] + }); + conn.socket.write(encodeMaskedFrame(payload, 0x1)); + + const started = await waitForFrame(messages, (m) => m && m.type === 'started'); + assert(typeof started.pid === 'number' && started.pid > 0, 'started frame should include positive pid'); + + const data = await waitForFrame(messages, (m) => m && m.type === 'data' && typeof m.text === 'string' && m.text.indexOf('hello-terminal-e2e') !== -1); + assert(data.stream === 'stdout', 'data frame stream should be stdout, got ' + data.stream); + + const exit = await waitForFrame(messages, (m) => m && m.type === 'exit'); + assert(exit.code === 0, 'exit code should be 0, got ' + exit.code); + + try { conn.socket.end(); } catch (_) {} + await new Promise((resolve) => setTimeout(resolve, 60)); + assert(closeFlags.closed || conn.socket.destroyed, 'socket should be closed after exit'); + } + + { + const conn = await connectWs(port, '/ws/terminal'); + const { messages } = collectFrames(conn.socket, conn.leftover); + const payload = JSON.stringify({ type: 'run', cmd: 'rm', args: ['-rf', '/'] }); + conn.socket.write(encodeMaskedFrame(payload, 0x1)); + const err = await waitForFrame(messages, (m) => m && m.type === 'error'); + assert(/not allowed/i.test(err.message || ''), 'should reject non-whitelisted command, got ' + err.message); + try { conn.socket.end(); } catch (_) {} + } + + { + const conn = await connectWs(port, '/ws/terminal'); + const { messages } = collectFrames(conn.socket, conn.leftover); + const stderrPayload = JSON.stringify({ + type: 'run', + cmd: 'node', + args: ['-e', "process.stderr.write('err-terminal-e2e\\n');"] + }); + conn.socket.write(encodeMaskedFrame(stderrPayload, 0x1)); + const data = await waitForFrame(messages, (m) => m && m.type === 'data' && typeof m.text === 'string' && m.text.indexOf('err-terminal-e2e') !== -1); + assert(data.stream === 'stderr', 'stderr stream should route correctly'); + await waitForFrame(messages, (m) => m && m.type === 'exit'); + try { conn.socket.end(); } catch (_) {} + } +}; diff --git a/tests/e2e/test-webhook.js b/tests/e2e/test-webhook.js new file mode 100644 index 00000000..d02b3bf7 --- /dev/null +++ b/tests/e2e/test-webhook.js @@ -0,0 +1,144 @@ +const http = require('http'); +const { assert } = require('./helpers'); + +function startRecordingServer() { + const requests = []; + return new Promise((resolve, reject) => { + const server = http.createServer((req, res) => { + let body = ''; + req.setEncoding('utf-8'); + req.on('data', (chunk) => { body += chunk; }); + req.on('end', () => { + let parsed = null; + try { parsed = JSON.parse(body || '{}'); } catch (_) { parsed = null; } + requests.push({ + method: req.method, + url: req.url, + headers: req.headers, + body: parsed, + raw: body + }); + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end('{"ok":true}'); + }); + }); + server.on('error', reject); + server.listen(0, '127.0.0.1', () => { + const addr = server.address(); + resolve({ server, port: addr.port, requests }); + }); + }); +} + +function closeRecordingServer(server) { + return new Promise((resolve) => { + if (!server) return resolve(); + server.close(() => resolve()); + }); +} + +function waitFor(predicate, timeoutMs = 2000, intervalMs = 30) { + return new Promise((resolve, reject) => { + const start = Date.now(); + const tick = () => { + try { + if (predicate()) return resolve(); + } catch (_) {} + if (Date.now() - start >= timeoutMs) { + return reject(new Error('waitFor timeout')); + } + setTimeout(tick, intervalMs); + }; + tick(); + }); +} + +module.exports = async function testWebhook(ctx) { + const { api } = ctx; + const recorder = await startRecordingServer(); + const recorderUrl = `http://127.0.0.1:${recorder.port}/hook`; + + try { + const initial = await api('get-webhook-config'); + assert(initial && typeof initial === 'object' && !initial.error, 'get-webhook-config should return object'); + assert(typeof initial.enabled === 'boolean', 'get-webhook-config missing enabled'); + assert(Array.isArray(initial.events), 'get-webhook-config missing events array'); + + const saved = await api('set-webhook-config', { + config: { + enabled: true, + url: recorderUrl, + events: ['provider-switch', 'claude-md-edit'] + } + }); + assert(saved && saved.enabled === true, 'set-webhook-config did not persist enabled'); + assert(saved.url === recorderUrl, 'set-webhook-config did not persist url'); + + const testPing = await api('test-webhook'); + assert(testPing && testPing.ok === true, 'test-webhook should succeed: ' + JSON.stringify(testPing)); + await waitFor(() => recorder.requests.length >= 1, 2000); + const ping = recorder.requests[0]; + assert(ping.method === 'POST', 'test-webhook should POST'); + assert(ping.body && ping.body.event === 'provider-switch', 'test ping event mismatch'); + assert(ping.body.details && ping.body.details.test === true, 'test ping marker missing'); + + const requestsBefore = recorder.requests.length; + const claudeApply = await api('apply-claude-config', { + config: { + name: 'webhook-e2e', + apiKey: 'sk-webhook-e2e', + baseUrl: ctx.mockProviderUrl, + model: 'glm-4.7' + } + }); + assert(claudeApply && !claudeApply.error, 'apply-claude-config failed: ' + JSON.stringify(claudeApply)); + await waitFor(() => recorder.requests.length > requestsBefore, 2000); + const switchEvent = recorder.requests[recorder.requests.length - 1]; + assert(switchEvent.body && switchEvent.body.event === 'provider-switch', 'apply-claude-config should trigger provider-switch event'); + assert(typeof switchEvent.body.summary === 'string' && switchEvent.body.summary.indexOf('webhook-e2e') !== -1, + 'webhook summary should mention provider name: ' + switchEvent.body.summary); + assert(typeof switchEvent.body.timestamp === 'string' && switchEvent.body.timestamp.length > 0, + 'webhook payload should include timestamp'); + + await api('set-webhook-config', { + config: { enabled: false, url: recorderUrl, events: ['provider-switch', 'claude-md-edit'] } + }); + const requestsBeforeDisabled = recorder.requests.length; + const claudeApplyDisabled = await api('apply-claude-config', { + config: { + name: 'webhook-e2e-disabled', + apiKey: 'sk-webhook-e2e', + baseUrl: ctx.mockProviderUrl, + model: 'glm-4.7' + } + }); + assert(claudeApplyDisabled && !claudeApplyDisabled.error, 'apply-claude-config (disabled webhook) should still succeed'); + await new Promise((resolve) => setTimeout(resolve, 300)); + assert(recorder.requests.length === requestsBeforeDisabled, + 'disabled webhook should not deliver any new requests, got ' + (recorder.requests.length - requestsBeforeDisabled)); + + const filtered = await api('set-webhook-config', { + config: { enabled: true, url: recorderUrl, events: ['claude-md-edit'] } + }); + assert(filtered && filtered.events && filtered.events.indexOf('provider-switch') === -1, + 'event filter should drop provider-switch'); + const requestsBeforeFiltered = recorder.requests.length; + const claudeApplyFiltered = await api('apply-claude-config', { + config: { + name: 'webhook-e2e-filtered', + apiKey: 'sk-webhook-e2e', + baseUrl: ctx.mockProviderUrl, + model: 'glm-4.7' + } + }); + assert(claudeApplyFiltered && !claudeApplyFiltered.error, 'apply-claude-config (filtered) should still succeed'); + await new Promise((resolve) => setTimeout(resolve, 300)); + assert(recorder.requests.length === requestsBeforeFiltered, + 'filtered event should not be delivered, got ' + (recorder.requests.length - requestsBeforeFiltered)); + } finally { + try { + await api('set-webhook-config', { config: { enabled: false, url: '', events: ['provider-switch', 'claude-md-edit'] } }); + } catch (_) {} + await closeRecordingServer(recorder.server); + } +}; diff --git a/tests/unit/web-ui-behavior-parity.test.mjs b/tests/unit/web-ui-behavior-parity.test.mjs index 198cd271..8ca2c9c1 100644 --- a/tests/unit/web-ui-behavior-parity.test.mjs +++ b/tests/unit/web-ui-behavior-parity.test.mjs @@ -331,6 +331,20 @@ test('captured bundled app skeleton only exposes expected data key drift versus 'openclawPendingAuthProfileUpdates', 'sessionTrashEnabled', 'sessionTrashRetentionDays', + 'webhookConfig', + 'webhookEventOptions', + 'webhookSaving', + 'webhookTestResult', + 'webhookTesting', + 'terminalPanelOpen', + 'terminalLines', + 'terminalPaused', + 'terminalSearchQuery', + 'terminalCommandInput', + 'terminalRunning', + 'terminalSocket', + 'terminalPendingBuffer', + 'terminalMaxLines', 'shareCommandPrefix', 'sessionsUsageLoadedLimit', 'taskOrchestrationTabEnabled', @@ -354,7 +368,21 @@ test('captured bundled app skeleton only exposes expected data key drift versus 'sessionsViewMode', 'taskOrchestrationTabEnabled', 'taskOrchestration', - '_taskOrchestrationPollTimer' + '_taskOrchestrationPollTimer', + 'webhookConfig', + 'webhookEventOptions', + 'webhookSaving', + 'webhookTestResult', + 'webhookTesting', + 'terminalPanelOpen', + 'terminalLines', + 'terminalPaused', + 'terminalSearchQuery', + 'terminalCommandInput', + 'terminalRunning', + 'terminalSocket', + 'terminalPendingBuffer', + 'terminalMaxLines' ]; const allowedMissingCurrentKeys = [ 'localProxyRunning', @@ -438,6 +466,18 @@ test('captured bundled app skeleton only exposes expected data key drift versus 'normalizeLang', 'setLang', 't', + 'loadWebhookSettings', + 'saveWebhookSettings', + 'testWebhook', + 'toggleWebhookEvent', + 'toggleTerminalPanel', + 'clearTerminalOutput', + 'pauseTerminal', + 'resumeTerminal', + 'runTerminalCommand', + 'killTerminalCommand', + '_appendTerminalLine', + '_flushTerminalBuffer', 'restoreNavStateFromStorage', 'cancelScheduledSessionListViewportFill', 'canSubmitProvider', diff --git a/web-ui/app.js b/web-ui/app.js index 1dafa857..b421f5ba 100644 --- a/web-ui/app.js +++ b/web-ui/app.js @@ -401,7 +401,21 @@ document.addEventListener('DOMContentLoaded', () => { lastLoadedAt: '', lastError: '' }, - _taskOrchestrationPollTimer: 0 + _taskOrchestrationPollTimer: 0, + webhookConfig: { enabled: false, url: '', events: ['provider-switch', 'claude-md-edit'] }, + webhookEventOptions: ['provider-switch', 'claude-md-edit'], + webhookSaving: false, + webhookTestResult: null, + webhookTesting: false, + terminalPanelOpen: false, + terminalLines: [], + terminalPaused: false, + terminalSearchQuery: '', + terminalCommandInput: 'codexmate codex hello', + terminalRunning: false, + terminalSocket: null, + terminalPendingBuffer: [], + terminalMaxLines: 2000 }; }, @@ -409,6 +423,9 @@ document.addEventListener('DOMContentLoaded', () => { if (typeof this.initI18n === 'function') { this.initI18n(); } + if (typeof this.loadWebhookSettings === 'function') { + this.loadWebhookSettings(); + } if (typeof this.t === 'function') { this.confirmDialogConfirmText = this.t('confirm.ok'); this.confirmDialogCancelText = this.t('confirm.cancel'); diff --git a/web-ui/index.html b/web-ui/index.html index 69008984..d33018f2 100644 --- a/web-ui/index.html +++ b/web-ui/index.html @@ -23,6 +23,7 @@ + diff --git a/web-ui/modules/app.methods.index.mjs b/web-ui/modules/app.methods.index.mjs index 425491cc..ce3151e0 100644 --- a/web-ui/modules/app.methods.index.mjs +++ b/web-ui/modules/app.methods.index.mjs @@ -29,6 +29,7 @@ import { createStartupClaudeMethods } from './app.methods.startup-claude.mjs'; import { createSkillsMethods } from './skills.methods.mjs'; import { createPluginsMethods } from './plugins.methods.mjs'; import { createI18nMethods } from './i18n.mjs'; +import { createWebhookTerminalMethods } from './app.methods.webhook-terminal.mjs'; import { CONFIG_MODE_SET, getProviderConfigModeMeta @@ -43,6 +44,7 @@ import { export function createAppMethods() { return { ...createI18nMethods(), + ...createWebhookTerminalMethods(), ...createStartupClaudeMethods({ api, defaultModelContextWindow: DEFAULT_MODEL_CONTEXT_WINDOW, diff --git a/web-ui/modules/app.methods.webhook-terminal.mjs b/web-ui/modules/app.methods.webhook-terminal.mjs new file mode 100644 index 00000000..af0d170d --- /dev/null +++ b/web-ui/modules/app.methods.webhook-terminal.mjs @@ -0,0 +1,182 @@ +import { api, API_BASE } from './api.mjs'; + +export function createWebhookTerminalMethods() { + return { + async loadWebhookSettings() { + try { + const data = await api('get-webhook-config'); + if (data && typeof data === 'object' && !data.error) { + this.webhookConfig = { + enabled: !!data.enabled, + url: typeof data.url === 'string' ? data.url : '', + events: Array.isArray(data.events) && data.events.length + ? data.events.slice() + : this.webhookEventOptions.slice() + }; + } + } catch (e) { + this.webhookTestResult = { ok: false, error: e && e.message ? e.message : String(e) }; + } + }, + + async saveWebhookSettings() { + this.webhookSaving = true; + try { + const cfg = { + enabled: !!this.webhookConfig.enabled, + url: typeof this.webhookConfig.url === 'string' ? this.webhookConfig.url.trim() : '', + events: Array.isArray(this.webhookConfig.events) ? this.webhookConfig.events.slice() : [] + }; + const saved = await api('set-webhook-config', { config: cfg }); + if (saved && typeof saved === 'object' && !saved.error) { + this.webhookConfig = { + enabled: !!saved.enabled, + url: typeof saved.url === 'string' ? saved.url : '', + events: Array.isArray(saved.events) ? saved.events.slice() : [] + }; + this.webhookTestResult = { ok: true, status: 'saved' }; + } else { + this.webhookTestResult = { ok: false, error: (saved && saved.error) || 'save failed' }; + } + } catch (e) { + this.webhookTestResult = { ok: false, error: e && e.message ? e.message : String(e) }; + } finally { + this.webhookSaving = false; + } + }, + + async testWebhook() { + this.webhookTesting = true; + try { + const cfg = { + enabled: true, + url: typeof this.webhookConfig.url === 'string' ? this.webhookConfig.url.trim() : '', + events: Array.isArray(this.webhookConfig.events) && this.webhookConfig.events.length + ? this.webhookConfig.events.slice() + : this.webhookEventOptions.slice() + }; + const r = await api('test-webhook', { config: cfg }); + this.webhookTestResult = r || { ok: false, error: 'no result' }; + } catch (e) { + this.webhookTestResult = { ok: false, error: e && e.message ? e.message : String(e) }; + } finally { + this.webhookTesting = false; + } + }, + + toggleWebhookEvent(eventName) { + if (!Array.isArray(this.webhookConfig.events)) { + this.webhookConfig.events = []; + } + const idx = this.webhookConfig.events.indexOf(eventName); + if (idx === -1) { + this.webhookConfig.events.push(eventName); + } else { + this.webhookConfig.events.splice(idx, 1); + } + }, + + toggleTerminalPanel() { + this.terminalPanelOpen = !this.terminalPanelOpen; + }, + + clearTerminalOutput() { + this.terminalLines = []; + this.terminalPendingBuffer = []; + }, + + pauseTerminal() { + this.terminalPaused = true; + }, + + resumeTerminal() { + this.terminalPaused = false; + this._flushTerminalBuffer(); + }, + + _flushTerminalBuffer() { + if (!Array.isArray(this.terminalPendingBuffer) || this.terminalPendingBuffer.length === 0) return; + const drained = this.terminalPendingBuffer; + this.terminalPendingBuffer = []; + for (const line of drained) { + this._appendTerminalLine(line.stream, line.text); + } + }, + + _appendTerminalLine(stream, text) { + const lines = String(text || '').split(/\r?\n/); + for (let i = 0; i < lines.length; i++) { + const t = lines[i]; + if (i === lines.length - 1 && t === '') continue; + this.terminalLines.push({ stream: stream || 'stdout', text: t }); + } + const max = Number.isFinite(this.terminalMaxLines) ? this.terminalMaxLines : 2000; + if (this.terminalLines.length > max) { + this.terminalLines.splice(0, this.terminalLines.length - max); + } + }, + + runTerminalCommand() { + if (this.terminalRunning) return; + const raw = String(this.terminalCommandInput || '').trim(); + if (!raw) return; + const parts = raw.split(/\s+/); + const cmd = parts.shift(); + const args = parts; + const proto = (typeof window !== 'undefined' && window.location && window.location.protocol === 'https:') ? 'wss:' : 'ws:'; + const host = (typeof window !== 'undefined' && window.location && window.location.host) ? window.location.host : 'localhost'; + const url = proto + '//' + host + '/ws/terminal'; + let socket; + try { + socket = new WebSocket(url); + } catch (e) { + this._appendTerminalLine('stderr', '[connect error] ' + (e && e.message ? e.message : String(e))); + return; + } + this.terminalSocket = socket; + this.terminalRunning = true; + this._appendTerminalLine('stdout', '$ ' + raw); + socket.onopen = () => { + try { + socket.send(JSON.stringify({ type: 'run', cmd, args })); + } catch (e) { + this._appendTerminalLine('stderr', '[send error] ' + (e && e.message ? e.message : String(e))); + } + }; + socket.onmessage = (ev) => { + let parsed; + try { parsed = JSON.parse(typeof ev.data === 'string' ? ev.data : ''); } catch (_) { return; } + if (!parsed || typeof parsed !== 'object') return; + if (parsed.type === 'data') { + const stream = parsed.stream === 'stderr' ? 'stderr' : 'stdout'; + if (this.terminalPaused) { + this.terminalPendingBuffer.push({ stream, text: String(parsed.text || '') }); + } else { + this._appendTerminalLine(stream, String(parsed.text || '')); + } + } else if (parsed.type === 'started') { + this._appendTerminalLine('stdout', '[started pid=' + (parsed.pid || '') + ']'); + } else if (parsed.type === 'exit') { + this._appendTerminalLine('stdout', '[exit code=' + (parsed.code == null ? '?' : parsed.code) + (parsed.signal ? ' signal=' + parsed.signal : '') + ']'); + } else if (parsed.type === 'error') { + this._appendTerminalLine('stderr', '[error] ' + (parsed.message || '')); + } + }; + socket.onerror = () => { + this._appendTerminalLine('stderr', '[socket error]'); + }; + socket.onclose = () => { + this.terminalRunning = false; + this.terminalSocket = null; + }; + }, + + killTerminalCommand() { + if (this.terminalSocket && this.terminalSocket.readyState === 1) { + try { + this.terminalSocket.send(JSON.stringify({ type: 'kill' })); + } catch (_) {} + } + } + }; +} diff --git a/web-ui/partials/index/panel-config-claude.html b/web-ui/partials/index/panel-config-claude.html index 027bbf06..42bf0897 100644 --- a/web-ui/partials/index/panel-config-claude.html +++ b/web-ui/partials/index/panel-config-claude.html @@ -136,6 +136,40 @@ +
+{{ line.text }}
+
+