You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
283 lines
10 KiB
283 lines
10 KiB
'use strict'; |
|
// DSH PRO - server.js |
|
// 本地桥接服务:HTTP 路由 + Bearer token 鉴权 + 限流 + 审计。 |
|
// 仅绑定 127.0.0.1。零 npm 依赖。启动:node server.js(需先在 config.json 填写 token)。 |
|
// 预留 /mcp 路由分支:将来可在同一 handler 层挂 streamable-HTTP MCP 端点, |
|
// tools/list -> GET /tools、tools/call -> POST /tool,供 ChatGPT Connector / Claude 直连复用工具层。 |
|
|
|
const http = require('http'); |
|
const crypto = require('crypto'); |
|
const fs = require('fs'); |
|
const path = require('path'); |
|
const { loadConfig, ToolError, redact, resolveWorkspace } = require('./guard'); |
|
const { TOOL_DEFS, runTool } = require('./tools'); |
|
const { HandoffManager, dshProbe } = require('./handoff'); |
|
|
|
const BRIDGE_DIR = __dirname; |
|
const AUDIT_FILE = path.join(BRIDGE_DIR, 'audit.jsonl'); |
|
|
|
let config; |
|
try { |
|
config = loadConfig(BRIDGE_DIR); |
|
} catch (err) { |
|
console.error('[DSH PRO] config load failed:', err.message); |
|
process.exit(1); |
|
} |
|
if (!config.token || String(config.token).length < 32) { |
|
console.error('[DSH bridge] STARTUP: token missing or short, exiting'); |
|
process.exit(1); |
|
} |
|
|
|
const handoff = new HandoffManager(config); |
|
const pendingConfirms = new Map(); // confirmToken -> {exp, tool, args, workspace} |
|
|
|
// --------------------------------------------------------------------------- |
|
// 工具函数 |
|
// --------------------------------------------------------------------------- |
|
function timingSafeEqual(a, b) { |
|
const ba = Buffer.from(String(a)); |
|
const bb = Buffer.from(String(b)); |
|
if (ba.length !== bb.length) return false; |
|
return crypto.timingSafeEqual(ba, bb); |
|
} |
|
|
|
function audit(entry) { |
|
try { |
|
const clean = {}; |
|
for (const [k, v] of Object.entries(entry)) { |
|
clean[k] = typeof v === 'string' ? redact(v).slice(0, 300) : v; |
|
} |
|
fs.appendFileSync(AUDIT_FILE, JSON.stringify({ ts: new Date().toISOString(), ...clean }) + '\n', 'utf8'); |
|
} catch {} |
|
} |
|
|
|
// 限流:60 req/min(仅 127.0.0.1 可达,防御性设计) |
|
const rateBuckets = new Map(); |
|
function rateLimited(ip) { |
|
const now = Date.now(); |
|
const win = now - 60000; |
|
let arr = rateBuckets.get(ip); |
|
if (!arr) { |
|
arr = []; |
|
rateBuckets.set(ip, arr); |
|
} |
|
while (arr.length && arr[0] < win) arr.shift(); |
|
if (arr.length >= 60) return true; |
|
arr.push(now); |
|
return false; |
|
} |
|
|
|
function sendJson(res, status, obj) { |
|
const body = JSON.stringify(obj); |
|
res.writeHead(status, { |
|
'Content-Type': 'application/json; charset=utf-8', |
|
'Access-Control-Allow-Origin': 'http://127.0.0.1:8766', |
|
'Access-Control-Allow-Headers': 'Content-Type, Authorization', |
|
'Access-Control-Allow-Methods': 'GET, POST, OPTIONS', |
|
}); |
|
res.end(body); |
|
} |
|
|
|
const HTTP_STATUS = { |
|
E_UNAUTHORIZED: 401, |
|
E_TOOL_NOT_FOUND: 404, |
|
E_ARGS_INVALID: 400, |
|
E_ROOT_NOT_ALLOWED: 403, |
|
E_PATH_ESCAPE: 403, |
|
E_SYMLINK_ESCAPE: 403, |
|
E_FORBIDDEN_PATH: 403, |
|
E_BINARY_FILE: 415, |
|
E_BASH_BLOCKED: 403, |
|
E_NEEDS_CONFIRM: 403, |
|
E_BUSY: 409, |
|
E_TIMEOUT: 408, |
|
E_INTERNAL: 500, |
|
}; |
|
|
|
function readBody(req, maxBytes) { |
|
return new Promise((resolve, reject) => { |
|
const chunks = []; |
|
let size = 0; |
|
req.on('data', (c) => { |
|
size += c.length; |
|
if (size > maxBytes) { |
|
reject(new ToolError('E_ARGS_INVALID', '请求体超过 10MB 上限')); |
|
req.destroy(); |
|
return; |
|
} |
|
chunks.push(c); |
|
}); |
|
req.on('end', () => resolve(Buffer.concat(chunks).toString('utf8'))); |
|
req.on('error', reject); |
|
}); |
|
} |
|
|
|
function authOk(req) { |
|
const h = req.headers['authorization'] || ''; |
|
const m = h.match(/^Bearer\s+(.+)$/i); |
|
if (!m) return false; |
|
return timingSafeEqual(m[1], config.token); |
|
} |
|
|
|
// --------------------------------------------------------------------------- |
|
// 路由 |
|
// --------------------------------------------------------------------------- |
|
async function handle(req, res, pathname, body) { |
|
switch (pathname) { |
|
case '/health': { |
|
const probe = dshProbe(config); |
|
return { |
|
ok: true, |
|
name: 'dsh-memory', |
|
version: '0.3.0', |
|
allowedRoots: config.allowedRoots, |
|
dsh: { binFound: probe.binFound, bin: probe.bin, nodeSource: probe.node ? probe.node.source : null }, |
|
}; |
|
} |
|
case '/tools': { |
|
return { ok: true, tools: TOOL_DEFS }; |
|
} |
|
case '/tool': { |
|
const { tool, args, workspace, confirmToken } = body || {}; |
|
if (!tool || typeof tool !== 'string') throw new ToolError('E_ARGS_INVALID', '缺少 tool 字段'); |
|
const started = Date.now(); |
|
const call = async (confirmed) => { |
|
const ctx = { config, workspace, confirmed }; |
|
return runTool(tool, args, ctx, handoff); |
|
}; |
|
let data; |
|
try { |
|
data = await call(false); |
|
} catch (err) { |
|
if (err instanceof ToolError && err.code === 'E_NEEDS_CONFIRM') { |
|
if (confirmToken && pendingConfirms.has(confirmToken)) { |
|
const pending = pendingConfirms.get(confirmToken); |
|
pendingConfirms.delete(confirmToken); |
|
if (pending.exp < Date.now()) throw new ToolError('E_NEEDS_CONFIRM', '确认令牌已过期,请重新发起'); |
|
data = await call(true); |
|
} else { |
|
const token = crypto.randomBytes(16).toString('hex'); |
|
pendingConfirms.set(token, { exp: Date.now() + 10 * 60 * 1000, tool, args, workspace }); |
|
// 清理过期项 |
|
for (const [k, v] of pendingConfirms) if (v.exp < Date.now()) pendingConfirms.delete(k); |
|
err.extra = { ...(err.extra || {}), confirmToken: token }; |
|
throw err; |
|
} |
|
} else { |
|
throw err; |
|
} |
|
} |
|
audit({ op: 'tool', tool, workspace: workspace || config.allowedRoots[0], ms: Date.now() - started, ok: true }); |
|
// 超 24KB 的结果落盘 .dsh-pro/outputs/,模型可用 dsh_read 分段读取 |
|
let payload = data; |
|
const dataStr = JSON.stringify(data); |
|
if (dataStr.length > 24576) { |
|
const root = resolveWorkspace(config, workspace); |
|
const outsDir = path.join(root, '.dsh-pro', 'outputs'); |
|
fs.mkdirSync(outsDir, { recursive: true }); |
|
const name = `${Date.now()}-${String(tool).replace(/[^a-z0-9_-]/gi, '_')}.json`; |
|
fs.writeFileSync(path.join(outsDir, name), dataStr, 'utf8'); |
|
payload = { |
|
preview: dataStr.slice(0, 24576), |
|
fullOutputFile: `.dsh-pro/outputs/${name}`, |
|
hint: '数据超过 24KB 已截断落盘,可用 dsh_read 分段读取该文件(或缩小 limit/maxResults)', |
|
}; |
|
} |
|
return { ok: true, data: payload, meta: { durationMs: Date.now() - started } }; |
|
} |
|
case '/handoff': { |
|
if (req.method !== 'POST') throw new ToolError('E_ARGS_INVALID', '仅支持 POST'); |
|
const { planText, workspace, taskNote, timeoutMs } = body || {}; |
|
const started = Date.now(); |
|
const r = handoff.start({ workspace, planText, taskNote, timeoutMs }); |
|
audit({ op: 'handoff-start', workspace: workspace || config.allowedRoots[0], runId: r.runId, planMs: Date.now() - started }); |
|
return { ok: true, ...r }; |
|
} |
|
default: |
|
// /handoff/:runId 与 /handoff/:runId/cancel |
|
const m = pathname.match(/^\/handoff\/([A-Za-z0-9-]+)(\/cancel)?$/); |
|
if (m) { |
|
const runId = m[1]; |
|
if (req.method === 'POST' && m[2]) { |
|
return { ok: true, ...handoff.cancel(runId) }; |
|
} |
|
if (req.method === 'GET') { |
|
const run = handoff.get(runId); |
|
if (!run) throw new ToolError('E_TOOL_NOT_FOUND', `未找到 handoff:${runId}`); |
|
return { ok: true, run }; |
|
} |
|
} |
|
if (pathname === '/mcp') { |
|
throw new ToolError('E_TOOL_NOT_FOUND', 'MCP 端点为预留分支,本期未实现(见 server.js 头注释)'); |
|
} |
|
if (pathname === '/shutdown') { |
|
if (req.method !== 'POST') throw new ToolError('E_ARGS_INVALID', '仅支持 POST'); |
|
setTimeout(() => process.exit(0), 300); |
|
return { ok: true, bye: true }; |
|
} |
|
throw new ToolError('E_TOOL_NOT_FOUND', `未知路径:${pathname}`); |
|
} |
|
} |
|
|
|
const server = http.createServer(async (req, res) => { |
|
const ip = req.socket.remoteAddress || ''; |
|
const u = new URL(req.url, 'http://127.0.0.1'); |
|
const pathname = u.pathname; |
|
const started = Date.now(); |
|
|
|
if (req.method === 'OPTIONS') { |
|
res.writeHead(204, { |
|
'Access-Control-Allow-Origin': 'http://127.0.0.1:8766', |
|
'Access-Control-Allow-Headers': 'Content-Type, Authorization', |
|
'Access-Control-Allow-Methods': 'GET, POST, OPTIONS', |
|
}); |
|
res.end(); |
|
return; |
|
} |
|
|
|
try { |
|
if (ip !== '127.0.0.1' && ip !== '::1' && ip !== '::ffff:127.0.0.1') { |
|
throw new ToolError('E_UNAUTHORIZED', '仅允许本机访问'); |
|
} |
|
if (rateLimited(ip)) { |
|
throw new ToolError('E_BUSY', '请求过于频繁(60/min)'); |
|
} |
|
if (!authOk(req)) { |
|
throw new ToolError('E_UNAUTHORIZED', 'invalid or missing bearer token'); |
|
} |
|
let body = null; |
|
if (req.method === 'POST') { |
|
const raw = await readBody(req, 10 * 1024 * 1024); |
|
if (raw.trim()) { |
|
try { |
|
body = JSON.parse(raw); |
|
} catch { |
|
throw new ToolError('E_ARGS_INVALID', '请求体不是合法 JSON'); |
|
} |
|
} |
|
} |
|
const result = await handle(req, res, pathname, body); |
|
audit({ op: 'http', method: req.method, path: pathname, ms: Date.now() - started, ok: true }); |
|
sendJson(res, 200, result); |
|
} catch (err) { |
|
const isTool = err instanceof ToolError; |
|
const code = isTool ? err.code : 'E_INTERNAL'; |
|
const status = HTTP_STATUS[code] || 500; |
|
audit({ op: 'http', method: req.method, path: pathname, ms: Date.now() - started, ok: false, code, msg: String(err && err.message) }); |
|
sendJson(res, status, { ok: false, error: { code, message: String(err && err.message), ...(isTool && err.extra ? { extra: err.extra } : {}) } }); |
|
} |
|
}); |
|
|
|
server.listen(config.port, '127.0.0.1', () => { |
|
console.log(`[DSH PRO] bridge listening on http://127.0.0.1:${config.port}`); |
|
console.log(`[DSH PRO] allowedRoots: ${config.allowedRoots.join(' ; ')}`); |
|
const probe = dshProbe(config); |
|
console.log(`[DSH PRO] dsh runtime: ${probe.binFound ? 'found' : 'NOT FOUND'} (${probe.bin || '-'})`); |
|
}); |
|
|
|
process.on('uncaughtException', (err) => { |
|
console.error('[DSH PRO] uncaught:', err && err.stack); |
|
}); |
|
process.on('unhandledRejection', (err) => { |
|
console.error('[DSH PRO] unhandledRejection:', String(err)); |
|
});
|
|
|