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.
436 lines
19 KiB
436 lines
19 KiB
'use strict'; |
|
// DSH memory - 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, resolveMemoryDir } = require('./guard'); |
|
const { TOOL_DEFS, runTool } = require('./tools'); |
|
const memoryModule = require('./memory'); |
|
const { McpManager } = require('./mcp-tools'); |
|
const stateDir = require('./state-dir'); |
|
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 memory] config load failed:', err.message); |
|
process.exit(1); |
|
} |
|
if (!config.token || String(config.token).length < 32) { |
|
console.error('[DSH memory] STARTUP: token missing or short, exiting'); |
|
process.exit(1); |
|
} |
|
|
|
const handoff = new HandoffManager(config); |
|
const mcp = new McpManager(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 {} |
|
} |
|
|
|
// Rate limit removed: local 127.0.0.1, single trusted client, token already checked. |
|
|
|
function sendJson(res, status, obj) { |
|
const body = JSON.stringify(obj); |
|
res.writeHead(status, { |
|
'Content-Type': 'application/json; charset=utf-8', |
|
'Access-Control-Allow-Origin': '*', |
|
'Access-Control-Allow-Headers': 'Content-Type, Authorization', |
|
'Access-Control-Allow-Methods': 'GET, POST, PUT, 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: '1.0.0', |
|
workspace: config.allowedRoots[0], |
|
memoryDir: resolveMemoryDir(config, BRIDGE_DIR), |
|
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.concat(mcp.toolDefs()) }; |
|
} |
|
case '/mcp/apps': { |
|
return { ok: true, root: mcp.root, apps: await mcp.list() }; |
|
} |
|
case '/mcp/rescan': { |
|
if (req.method !== 'POST') throw new ToolError('E_ARGS_INVALID', 'only POST'); |
|
return { ok: true, root: mcp.root, apps: await mcp.rescan() }; |
|
} |
|
case '/mcp/root': { |
|
if (req.method !== 'POST') throw new ToolError('E_ARGS_INVALID', 'only POST'); |
|
const p = body && body.path; |
|
if (!p || typeof p !== 'string') throw new ToolError('E_ARGS_INVALID', 'missing path'); |
|
const r = await mcp.setRoot(p); |
|
return { ok: true, root: r.root, apps: r.apps }; |
|
} |
|
case '/mcp/launch': { |
|
if (req.method !== 'POST') throw new ToolError('E_ARGS_INVALID', 'only POST'); |
|
const app = body && body.app; |
|
if (!app || typeof app !== 'string') throw new ToolError('E_ARGS_INVALID', 'missing app'); |
|
return { ok: true, ...(await mcp.launchGui(app)) }; |
|
} |
|
case '/mcp/stop': { |
|
if (req.method !== 'POST') throw new ToolError('E_ARGS_INVALID', 'only POST'); |
|
const app = body && body.app; |
|
if (!app || typeof app !== 'string') throw new ToolError('E_ARGS_INVALID', 'missing app'); |
|
return { ok: true, ...(await mcp.stop(app)) }; |
|
} |
|
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 await runTool(tool, args, ctx, handoff, mcp); |
|
}; |
|
let data; |
|
// Если пришёл confirmToken — сразу выполняем с confirmed:true, без лишнего прохода call(false). |
|
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 { |
|
try { |
|
data = await call(false); |
|
} catch (err) { |
|
if (err instanceof ToolError && err.code === 'E_NEEDS_CONFIRM') { |
|
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; |
|
} |
|
throw err; |
|
} |
|
} |
|
audit({ op: 'tool', tool, workspace: workspace || config.allowedRoots[0], ms: Date.now() - started, ok: true }); |
|
// Result above resultMaxBytes is dumped to .dsh-memory/outputs/ so the model can read it in chunks |
|
let payload = data; |
|
const dataStr = JSON.stringify(data); |
|
const resultMaxBytes = Number(config.resultMaxBytes) || 24576; |
|
if (dataStr.length > resultMaxBytes) { |
|
const root = resolveWorkspace(config, workspace); |
|
stateDir.ensureStateDir(root); |
|
const outsDir = stateDir.outputsDir(root); |
|
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, resultMaxBytes), |
|
fullOutputFile: `.dsh-memory/outputs/${name}`, |
|
hint: `Result exceeds ${resultMaxBytes} bytes, dumped to file. Use dsh_read to read it in chunks (or reduce 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 }; |
|
} |
|
case '/memory/state': { |
|
const memDir = resolveMemoryDir(config, BRIDGE_DIR); |
|
if (req.method === 'GET') { |
|
const st = memoryModule.loadState(memDir); |
|
return { ok: true, data: st }; |
|
} |
|
if (req.method === 'PUT') { |
|
const b = body || {}; |
|
const st = memoryModule.loadState(memDir); |
|
if (Array.isArray(b.entries)) st.entries = b.entries; |
|
if (b.chats && typeof b.chats === 'object') st.chats = b.chats; |
|
const saved = memoryModule.saveState(memDir, st); |
|
return { ok: true, data: saved }; |
|
} |
|
throw new ToolError('E_ARGS_INVALID', 'GET or PUT only'); |
|
} |
|
case '/memory/query': { |
|
if (req.method !== 'POST') throw new ToolError('E_ARGS_INVALID', 'POST only'); |
|
const memDir = resolveMemoryDir(config, BRIDGE_DIR); |
|
const st = memoryModule.loadState(memDir); |
|
const op = body && body.op ? String(body.op) : 'list'; |
|
let result; |
|
try { result = memoryModule.query(st, body || {}); } |
|
catch (err) { throw new ToolError('E_ARGS_INVALID', String(err && err.message)); } |
|
if (op === 'write' || op === 'add' || op === 'replace' || op === 'remove') { memoryModule.saveState(memDir, st); } |
|
return { ok: true, data: result }; |
|
} |
|
case '/memory/inject': { |
|
if (req.method !== 'POST') throw new ToolError('E_ARGS_INVALID', 'POST only'); |
|
const memDir = resolveMemoryDir(config, BRIDGE_DIR); |
|
const st = memoryModule.loadState(memDir); |
|
const chatId = body && body.chatId ? String(body.chatId) : ''; |
|
const wsKey = body && body.workspaceKey ? String(body.workspaceKey) : String(config.allowedRoots[0] || ''); |
|
const inj = memoryModule.computeInjection(st, chatId, wsKey); |
|
const chat = (chatId && st.chats) ? st.chats[chatId] : null; |
|
const lastFp = chat ? String(chat.lastInjFp || '') : ''; |
|
if (lastFp && lastFp === inj.fingerprint) { |
|
return { ok: true, data: { block: '', fingerprint: inj.fingerprint, count: 0, bytes: 0, deduped: true } }; |
|
} |
|
memoryModule.applyInjection(st, inj, chatId); |
|
memoryModule.saveState(memDir, st); |
|
return { ok: true, data: inj }; |
|
} |
|
case '/memory/chats': { |
|
const memDir = resolveMemoryDir(config, BRIDGE_DIR); |
|
const stateFile = path.join(memDir, 'state.json'); |
|
let st = {}; |
|
try { st = JSON.parse(fs.readFileSync(stateFile, 'utf8')); } catch (e) { st = {}; } |
|
if (!st.chats || typeof st.chats !== 'object') st.chats = {}; |
|
if (req.method === 'GET') { |
|
var qid = ''; var qs = String(req.url || ''); var qi = qs.indexOf('chatId='); if (qi !== -1) { qid = qs.slice(qi + 7); var qa = qid.indexOf('&'); if (qa !== -1) qid = qid.slice(0, qa); } const id = qid ? String(qid) : ((body && body.chatId) ? String(body.chatId) : ''); |
|
if (id) return { ok: true, data: st.chats[id] || null }; |
|
return { ok: true, data: st.chats }; |
|
} |
|
if (req.method === 'POST') { |
|
const b = body || {}; |
|
const id = b.chatId ? String(b.chatId) : ''; |
|
if (!id || id.length > 64 || id.indexOf('/') !== -1) throw new ToolError('E_ARGS_INVALID', 'bad chatId'); |
|
const entry = (b.entry && typeof b.entry === 'object') ? b.entry : {}; |
|
entry.updatedAt = Date.now(); |
|
st.chats[id] = entry; |
|
const ids = Object.keys(st.chats); |
|
if (ids.length > 20) { |
|
ids.sort(function (x, y) { return (st.chats[x].updatedAt || 0) - (st.chats[y].updatedAt || 0); }); |
|
const drop = ids.length - 20; |
|
for (let i = 0; i < drop; i++) delete st.chats[ids[i]]; |
|
} |
|
st.version = st.version || 1; |
|
st.updatedAt = new Date().toISOString(); |
|
fs.mkdirSync(memDir, { recursive: true }); |
|
const tmp = stateFile + '.tmp'; |
|
fs.writeFileSync(tmp, JSON.stringify(st, null, 2), 'utf8'); |
|
fs.renameSync(tmp, stateFile); |
|
return { ok: true, data: st.chats[id] }; |
|
} |
|
throw new ToolError('E_ARGS_INVALID', 'GET or POST only'); |
|
} |
|
case '/log/append': { |
|
if (req.method !== 'POST') throw new ToolError('E_ARGS_INVALID', 'only POST'); |
|
const file = body && body.file; |
|
const lines = body && body.lines; |
|
if (typeof file !== 'string') throw new ToolError('E_ARGS_INVALID', 'file required'); |
|
if (file.length < 7 || file.length > 44) throw new ToolError('E_ARGS_INVALID', 'file length'); |
|
if (file.slice(-6) !== '.jsonl') throw new ToolError('E_ARGS_INVALID', 'file ext'); |
|
if (file.indexOf('/') !== -1) throw new ToolError('E_ARGS_INVALID', 'file slash'); |
|
if (file.indexOf(String.fromCharCode(92)) !== -1) throw new ToolError('E_ARGS_INVALID', 'file backslash'); |
|
if (!Array.isArray(lines) || lines.length === 0) throw new ToolError('E_ARGS_INVALID', 'lines required'); |
|
if (lines.length > 200) throw new ToolError('E_ARGS_INVALID', 'too many lines'); |
|
const logDir = path.join(resolveMemoryDir(config, BRIDGE_DIR), 'chat-log'); |
|
fs.mkdirSync(logDir, { recursive: true }); |
|
const target = path.join(logDir, file); |
|
const resolved = path.resolve(target); |
|
const rootResolved = path.resolve(logDir) + path.sep; |
|
if (!resolved.startsWith(rootResolved)) throw new ToolError('E_PATH_ESCAPE', 'escape'); |
|
const buf = lines.map(function (l) { |
|
const s = typeof l === 'string' ? l : JSON.stringify(l); |
|
return s + String.fromCharCode(10); |
|
}).join(''); |
|
const bytes = Buffer.byteLength(buf, 'utf8'); |
|
if (bytes > 524288) throw new ToolError('E_ARGS_INVALID', 'payload too big'); |
|
fs.appendFileSync(resolved, buf, 'utf8'); |
|
try { |
|
var __trimMax = 50 * 1024; |
|
var __trimKeep = 25 * 1024; |
|
var __st = fs.statSync(resolved); |
|
if (__st.size > __trimMax) { |
|
var __cur = fs.readFileSync(resolved, 'utf8'); |
|
var __start = __cur.length - __trimKeep; |
|
if (__start < 0) __start = 0; |
|
var __nl = __cur.indexOf(String.fromCharCode(10), __start); |
|
if (__nl !== -1) { |
|
fs.writeFileSync(resolved, __cur.slice(__nl + 1), 'utf8'); |
|
} |
|
} |
|
} catch (e) { } |
|
return { ok: true, data: { file: file, lines: lines.length, bytes: bytes } }; |
|
} |
|
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'); |
|
const restart = !!(body && body.restart); |
|
if (restart && process.env.DSH_SUPERVISOR !== '1') { |
|
// No supervisor around — spawn a detached one so restart actually works. |
|
try { |
|
const supPath = path.join(__dirname, '..', 'scripts', 'supervisor.js'); |
|
const child = require('child_process').spawn(process.execPath, [supPath], { cwd: path.join(__dirname, '..'), detached: true, stdio: 'ignore', windowsHide: true }); |
|
child.unref(); |
|
console.log('[DSH memory] no supervisor detected, spawned detached supervisor'); |
|
} catch (e) { |
|
console.warn('[DSH memory] failed to spawn supervisor: ' + (e && e.message)); |
|
} |
|
} |
|
setTimeout(() => process.exit(restart ? 42 : 0), restart ? 1500 : 300); |
|
return { ok: true, bye: true, restart: restart }; |
|
} |
|
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': '*', |
|
'Access-Control-Allow-Headers': 'Content-Type, Authorization', |
|
'Access-Control-Allow-Methods': 'GET, POST, PUT, 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 (!authOk(req)) { |
|
throw new ToolError('E_UNAUTHORIZED', 'invalid or missing bearer token'); |
|
} |
|
let body = null; |
|
if (req.method === 'POST' || req.method === 'PUT') { |
|
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 memory] bridge listening on http://127.0.0.1:${config.port}`); |
|
// ensureStateDir on startup: create .dsh-memory/ in every allowedRoot |
|
for (const root of config.allowedRoots) { |
|
const sd = stateDir.ensureStateDir(root); |
|
if (sd) console.log('[DSH memory] state dir ready: ' + sd); |
|
else console.log('[DSH memory] state dir failed for root: ' + root); |
|
} |
|
mcp.init().then(function () { console.log('[mcp] init done'); }).catch(function (e) { console.warn('[mcp] init failed: ' + (e && e.message)); }); |
|
console.log(`[DSH memory] allowedRoots: ${config.allowedRoots.join(' ; ')}`); |
|
const probe = dshProbe(config); |
|
if (probe.binFound) console.log(`[DSH memory] dsh runtime: found (${probe.bin})`); |
|
else console.log('[DSH memory] dsh runtime: NOT FOUND (handoff disabled; set config.dshBin to enable)'); |
|
}); |
|
|
|
process.on('uncaughtException', (err) => { |
|
console.error('[DSH memory] uncaught:', err && err.stack); |
|
}); |
|
process.on('unhandledRejection', (err) => { |
|
console.error('[DSH memory] unhandledRejection:', String(err)); |
|
});
|
|
|