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

'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));
});