@whyshu/argent 0.1.27 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +16 -65
- package/cordis.patch.yml +6 -0
- package/dist/client.js +691 -0
- package/mcp/billing-upload.mjs +77 -0
- package/mcp/erp-server.mjs +296 -0
- package/mcp/gateway.mjs +336 -0
- package/mcp/meter.mjs +101 -0
- package/mcp/vault.mjs +78 -0
- package/mcp/vendor/account-token.mjs +37 -0
- package/mcp/vendor/redact.mjs +34 -0
- package/package.json +28 -18
- package/src/account-token.mjs +40 -0
- package/src/actions.mjs +114 -0
- package/src/config.mjs +47 -0
- package/src/error-log.mjs +56 -0
- package/src/fulfillment-result.mjs +84 -0
- package/src/health.mjs +231 -0
- package/src/index.js +231 -0
- package/src/kernel.mjs +111 -0
- package/src/lease.mjs +90 -0
- package/src/llm-whyshu.mjs +155 -0
- package/src/office-runtime.js +1102 -0
- package/src/skuflow-runner.mjs +57 -0
- package/src/timer-status.mjs +160 -0
- package/src/whyshu-auth.mjs +277 -0
- package/bin/argent.mjs +0 -385
- package/lib/index.mjs +0 -604
- package/runner/argent-runner.mjs +0 -223
package/mcp/gateway.mjs
ADDED
|
@@ -0,0 +1,336 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* gateway.mjs —— Argent MCP 网关(v0.9.0 第一版,stdio)
|
|
4
|
+
*
|
|
5
|
+
* 为什么要有它(2026-11-03 决策):
|
|
6
|
+
* 客户机 profile 里现在挂着 2 条 MCP(`mcp-argent-engine` + `mcp-argent-ml`),ERP 那套 15 个
|
|
7
|
+
* officeTools 还困在服务端 —— 工具面散在 3 处。网关把它们收成**一个 endpoint**,
|
|
8
|
+
* 并在同一个进程里做:路由 / 审计 / 计量 / 脱敏钩子 / 上游降级。
|
|
9
|
+
*
|
|
10
|
+
* 设计要点:
|
|
11
|
+
* · **不重写工具**:每个上游仍是独立进程(engine 用 core/mcp/server.mjs,ERP 用 erp-server.mjs),
|
|
12
|
+
* 网关只做 JSON-RPC 转发 + 命名空间前缀(`engine.*` / `erp.*` / `ml.*`)。
|
|
13
|
+
* · **上游挂了不拖垮网关**:某个上游起不来 ⇒ 它的工具不出现在 tools/list,调用返回结构化错误。
|
|
14
|
+
* · **审计/计量只记事件**:`<dataDir>/gateway/audit.jsonl`(工具/耗时/ok)与
|
|
15
|
+
* `events.jsonl`(可计费动作)。⛔ 不记参数与结果正文。
|
|
16
|
+
* · stdio 传输(dsh-mcp-client 已支持);HTTP 常驻版本下一步在 core/mcp/http.mjs 的基础上加。
|
|
17
|
+
*
|
|
18
|
+
* 用法(dsh profile insert 时指向本文件即可):
|
|
19
|
+
* command: <node> args: [<repo>/core/mcp/gateway.mjs]
|
|
20
|
+
* 环境变量:
|
|
21
|
+
* ARGENT_DATA_DIR 审计/计量落盘根(默认 ~/.dsh/argent)
|
|
22
|
+
* ARGENT_GATEWAY_ML=1 额外挂 ml 上游(需要 python + tools/agentic/mcp_server.py)
|
|
23
|
+
* ARGENT_GATEWAY_NOPREFIX=1 不加上游前缀(调试用)
|
|
24
|
+
*/
|
|
25
|
+
import { spawn } from 'node:child_process'
|
|
26
|
+
import { appendEvent, loadIdemSet } from './meter.mjs'
|
|
27
|
+
import { loadVault, saveVault, tokenizeEntities } from './vault.mjs'
|
|
28
|
+
import { appendFileSync, existsSync, mkdirSync } from 'node:fs'
|
|
29
|
+
import { homedir } from 'node:os'
|
|
30
|
+
import { dirname, join, resolve } from 'node:path'
|
|
31
|
+
import { fileURLToPath } from 'node:url'
|
|
32
|
+
|
|
33
|
+
const HERE = dirname(fileURLToPath(import.meta.url))
|
|
34
|
+
const PKG_ROOT = resolve(HERE, '..')
|
|
35
|
+
const DATA_DIR = process.env.ARGENT_DATA_DIR || join(homedir(), '.dsh', 'argent')
|
|
36
|
+
const GATEWAY_DIR = join(DATA_DIR, 'gateway')
|
|
37
|
+
const NO_PREFIX = process.env.ARGENT_GATEWAY_NOPREFIX === '1'
|
|
38
|
+
const SERVER_INFO = { name: 'argent-gateway', version: '0.9.0' }
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* 结果脱敏(代号化):把实体名换成 `[客户A]` 之类再交给模型。
|
|
42
|
+
* 只在 `ARGENT_GATEWAY_VAULT=1` 时启用;映射表落本机 vault.json(0600),⛔ 不上报。
|
|
43
|
+
* @returns {{text:string, tokens:number}}
|
|
44
|
+
*/
|
|
45
|
+
export function applyVaultToText(vault, text) {
|
|
46
|
+
try {
|
|
47
|
+
const j = JSON.parse(text)
|
|
48
|
+
const before = Object.keys(vault.byToken).length
|
|
49
|
+
const out = tokenizeEntities(vault, j)
|
|
50
|
+
const tokens = Object.keys(vault.byToken).length - before
|
|
51
|
+
if (tokens > 0) saveVault(vault)
|
|
52
|
+
return { text: JSON.stringify(out), tokens }
|
|
53
|
+
} catch {
|
|
54
|
+
return { text, tokens: 0 }
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** 结果是否算"成功":MCP isError 之外,还要看工具自己返回的 `{"ok":false,...}`(如写开关未开) */
|
|
59
|
+
export function resultOk(res) {
|
|
60
|
+
if (!res || res.isError) return false
|
|
61
|
+
try {
|
|
62
|
+
const text = res.content && res.content[0] && res.content[0].text
|
|
63
|
+
if (typeof text === 'string') {
|
|
64
|
+
const j = JSON.parse(text)
|
|
65
|
+
if (j && j.ok === false) return false
|
|
66
|
+
}
|
|
67
|
+
} catch { /* 非 JSON 结果照常算成功 */ }
|
|
68
|
+
return true
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** 只读工具名(不计费)——按语义后缀判,不维护第二份清单 */
|
|
72
|
+
export function isReadOnlyTool(name) {
|
|
73
|
+
return /(^|[._])(search|list|detail|status|info|snapshot|dashboard|version|license|ledger|sop|doctor|report|failures|preflight|audit|tenant_info|prepare_action)(_|$)/.test(String(name || ''))
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export class Upstream {
|
|
77
|
+
constructor(id, cmd, args, env = {}) {
|
|
78
|
+
this.id = id
|
|
79
|
+
this.pending = new Map()
|
|
80
|
+
this.nextId = 1
|
|
81
|
+
this.buf = ''
|
|
82
|
+
this.tools = []
|
|
83
|
+
this.alive = false
|
|
84
|
+
this.child = spawn(cmd, args, { stdio: ['pipe', 'pipe', 'pipe'], env: { ...process.env, ...env } })
|
|
85
|
+
this.child.stdout.setEncoding('utf8')
|
|
86
|
+
this.child.stdout.on('data', (c) => this._onData(c))
|
|
87
|
+
this.child.stderr.on('data', (c) => process.stderr.write(`[${id}] ${c}`))
|
|
88
|
+
this.child.on('exit', (code) => { this.alive = false; for (const p of this.pending.values()) p.reject(new Error(`${id} upstream exited (${code})`)); this.pending.clear() })
|
|
89
|
+
this.alive = true
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
_onData(chunk) {
|
|
93
|
+
this.buf += chunk
|
|
94
|
+
let i
|
|
95
|
+
while ((i = this.buf.indexOf('\n')) >= 0) {
|
|
96
|
+
const line = this.buf.slice(0, i).trim(); this.buf = this.buf.slice(i + 1)
|
|
97
|
+
if (!line) continue
|
|
98
|
+
let msg; try { msg = JSON.parse(line) } catch { continue }
|
|
99
|
+
const p = this.pending.get(msg.id)
|
|
100
|
+
if (!p) continue
|
|
101
|
+
this.pending.delete(msg.id)
|
|
102
|
+
if (msg.error) p.reject(new Error(msg.error.message || 'upstream error'))
|
|
103
|
+
else p.resolve(msg.result)
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
rpc(method, params, timeoutMs = 60000) {
|
|
108
|
+
const id = this.nextId++
|
|
109
|
+
return new Promise((res, rej) => {
|
|
110
|
+
const t = setTimeout(() => { this.pending.delete(id); rej(new Error(`${this.id} timeout after ${timeoutMs}ms`)) }, timeoutMs)
|
|
111
|
+
t.unref?.()
|
|
112
|
+
this.pending.set(id, {
|
|
113
|
+
resolve: (v) => { clearTimeout(t); res(v) },
|
|
114
|
+
reject: (e) => { clearTimeout(t); rej(e) },
|
|
115
|
+
})
|
|
116
|
+
this.child.stdin.write(`${JSON.stringify({ jsonrpc: '2.0', id, method, params })}\n`)
|
|
117
|
+
})
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
async init() {
|
|
121
|
+
await this.rpc('initialize', { protocolVersion: '2024-11-05', capabilities: {}, clientInfo: SERVER_INFO })
|
|
122
|
+
this.child.stdin.write(`${JSON.stringify({ jsonrpc: '2.0', method: 'notifications/initialized' })}\n`)
|
|
123
|
+
const t = await this.rpc('tools/list', {})
|
|
124
|
+
this.tools = (t && t.tools) || []
|
|
125
|
+
return this.tools
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
call(name, args) { return this.rpc('tools/call', { name, arguments: args }, 180000) }
|
|
129
|
+
close() { try { this.child.kill('SIGTERM') } catch { /* noop */ } }
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function audit(row) {
|
|
133
|
+
try {
|
|
134
|
+
mkdirSync(GATEWAY_DIR, { recursive: true })
|
|
135
|
+
appendFileSync(join(GATEWAY_DIR, 'audit.jsonl'), JSON.stringify({ at: new Date().toISOString(), ...row }) + '\n')
|
|
136
|
+
} catch { /* 审计写失败不影响调用 */ }
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export class Gateway {
|
|
140
|
+
constructor({ upstreams } = {}) {
|
|
141
|
+
this.upstreams = upstreams || Gateway.defaultUpstreams()
|
|
142
|
+
this.routes = new Map() // 对外工具名 → {up, name}
|
|
143
|
+
this.ready = false
|
|
144
|
+
this.idemSet = loadIdemSet(DATA_DIR)
|
|
145
|
+
this.vault = process.env.ARGENT_GATEWAY_VAULT === '1' ? loadVault(DATA_DIR) : null
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
static defaultUpstreams() {
|
|
149
|
+
// 引擎上游可选:插件独立安装(没有 engine 包)时只挂 erp.*,不因缺引擎而整个网关启动失败
|
|
150
|
+
const engineDir = process.env.ARGENT_ENGINE_DIR || ''
|
|
151
|
+
const engineServer = engineDir ? join(engineDir, 'core', 'mcp', 'server.mjs') : ''
|
|
152
|
+
const ups = []
|
|
153
|
+
if (engineServer && existsSync(engineServer)) ups.push(new Upstream('engine', process.execPath, [engineServer]))
|
|
154
|
+
ups.push(new Upstream('erp', process.execPath, [join(HERE, 'erp-server.mjs')]))
|
|
155
|
+
if (process.env.ARGENT_GATEWAY_ML === '1') {
|
|
156
|
+
const py = process.env.ARGENT_ENGINE_PY || join(DATA_DIR, 'venv', 'bin', 'python3')
|
|
157
|
+
const srv = join(REPO, 'tools', 'agentic', 'mcp_server.py')
|
|
158
|
+
if (existsSync(py) && existsSync(srv)) ups.push(new Upstream('ml', py, [srv], { PYTHONPATH: join(REPO, 'scripts') }))
|
|
159
|
+
}
|
|
160
|
+
return ups
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
async start() {
|
|
164
|
+
for (const up of this.upstreams) {
|
|
165
|
+
try {
|
|
166
|
+
const tools = await up.init()
|
|
167
|
+
for (const t of tools) {
|
|
168
|
+
const exposed = NO_PREFIX ? t.name : `${up.id}.${t.name}`
|
|
169
|
+
this.routes.set(exposed, { up, name: t.name, tool: t })
|
|
170
|
+
}
|
|
171
|
+
} catch (e) {
|
|
172
|
+
process.stderr.write(`[gateway] 上游 ${up.id} 不可用(跳过):${String(e.message).slice(0, 160)}\n`)
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
this.ready = true
|
|
176
|
+
return this.listTools()
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
listTools() {
|
|
180
|
+
const out = []
|
|
181
|
+
for (const [exposed, r] of this.routes) {
|
|
182
|
+
out.push({
|
|
183
|
+
name: exposed,
|
|
184
|
+
description: r.tool.description || '',
|
|
185
|
+
inputSchema: r.tool.inputSchema || { type: 'object', properties: {} },
|
|
186
|
+
annotations: r.tool.annotations || (isReadOnlyTool(exposed) ? { readOnlyHint: true } : { readOnlyHint: false, destructiveHint: true }),
|
|
187
|
+
})
|
|
188
|
+
}
|
|
189
|
+
return out
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
async call(exposed, args = {}) {
|
|
193
|
+
const r = this.routes.get(exposed)
|
|
194
|
+
const started = Date.now()
|
|
195
|
+
if (!r) {
|
|
196
|
+
audit({ event: 'unknown_tool', tool: exposed })
|
|
197
|
+
return { isError: true, content: [{ type: 'text', text: JSON.stringify({ ok: false, code: 'unknown_tool', tool: exposed }) }] }
|
|
198
|
+
}
|
|
199
|
+
if (!isReadOnlyTool(exposed)) {
|
|
200
|
+
const lic = await this.licenseState()
|
|
201
|
+
if (lic.ok === false) {
|
|
202
|
+
audit({ event: 'license_blocked', tool: exposed, reason: lic.reason })
|
|
203
|
+
return { isError: true, content: [{ type: 'text', text: JSON.stringify({ ok: false, code: 'license_required', reason: lic.reason, hint: '本机许可无效/过期 ⇒ 写操作被网关拦下(只读不受影响)' }) }] }
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
if (!r.up.alive) {
|
|
207
|
+
audit({ event: 'upstream_down', tool: exposed, upstream: r.up.id })
|
|
208
|
+
return { isError: true, content: [{ type: 'text', text: JSON.stringify({ ok: false, code: 'upstream_unavailable', upstream: r.up.id, hint: '该上游进程已退出(参数错误/依赖缺失);重开网关或修上游' }) }] }
|
|
209
|
+
}
|
|
210
|
+
try {
|
|
211
|
+
const res = await r.up.call(r.name, args)
|
|
212
|
+
const ms = Date.now() - started
|
|
213
|
+
const ok = resultOk(res)
|
|
214
|
+
let out = res
|
|
215
|
+
if (this.vault && ok && exposed.startsWith('erp.') && res && res.content && res.content[0]) {
|
|
216
|
+
const r = applyVaultToText(this.vault, res.content[0].text)
|
|
217
|
+
if (r.tokens > 0) {
|
|
218
|
+
out = { ...res, content: [{ ...res.content[0], text: r.text }, ...res.content.slice(1)] }
|
|
219
|
+
audit({ event: 'vault', tool: exposed, tokens: r.tokens })
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
audit({ event: 'call', tool: exposed, upstream: r.up.id, ms, ok })
|
|
223
|
+
if (!isReadOnlyTool(exposed)) {
|
|
224
|
+
const idem = String(args.idempotencyKey || args.idem || '')
|
|
225
|
+
// 2026-11-06(场景一 P4):MCP 工具调用**不单独收费**,只做调用统计/审计;points 恒 0 仅为台账字段兼容
|
|
226
|
+
const m = appendEvent(DATA_DIR, { action: exposed, idem, points: 0, tenant: process.env.ARGENT_TENANT || '', ok, idemSet: this.idemSet })
|
|
227
|
+
if (m.duplicate) audit({ event: 'meter_duplicate', tool: exposed, idem })
|
|
228
|
+
}
|
|
229
|
+
return out
|
|
230
|
+
} catch (e) {
|
|
231
|
+
const ms = Date.now() - started
|
|
232
|
+
audit({ event: 'error', tool: exposed, upstream: r.up.id, ms, error: String(e.message).slice(0, 160) })
|
|
233
|
+
return { isError: true, content: [{ type: 'text', text: JSON.stringify({ ok: false, code: 'upstream_error', tool: exposed, message: String(e.message).slice(0, 300) }) }] }
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/** 许可状态(5 分钟缓存;引擎不可达 ⇒ 放行但 audit 记 unknown,避免"网关把系统锁死") */
|
|
238
|
+
async licenseState() {
|
|
239
|
+
// 默认**关**(开发机通常没有许可文件;客户机应设 PARENT=1 打开)
|
|
240
|
+
if (process.env.ARGENT_GATEWAY_LICENSE_GATE !== '1') return { ok: true, reason: 'gate_disabled' }
|
|
241
|
+
const now = Date.now()
|
|
242
|
+
if (this._lic && now - this._lic.at < 5 * 60 * 1000) return this._lic.value
|
|
243
|
+
const route = this.routes.get('engine.argent_license')
|
|
244
|
+
let value = { ok: true, reason: 'unknown_allowed' }
|
|
245
|
+
if (route && route.up.alive) {
|
|
246
|
+
try {
|
|
247
|
+
const res = await route.up.call(route.name, {})
|
|
248
|
+
const j = JSON.parse((res.content && res.content[0] && res.content[0].text) || '{}')
|
|
249
|
+
const state = j.state || j.status || ''
|
|
250
|
+
if (state === 'denied' || j.ok === false) value = { ok: false, reason: String(state || 'denied') }
|
|
251
|
+
else value = { ok: true, reason: String(state || 'valid') }
|
|
252
|
+
} catch { /* 引擎异常 ⇒ 放行 */ }
|
|
253
|
+
}
|
|
254
|
+
this._lic = { at: now, value }
|
|
255
|
+
return value
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
close() { for (const u of this.upstreams) u.close() }
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/* ── 前端:stdio(默认) / HTTP(常驻,多窗口共享一份 vault 与计量) ─────── */
|
|
262
|
+
const send = (msg) => process.stdout.write(`${JSON.stringify(msg)}\n`)
|
|
263
|
+
|
|
264
|
+
/** 与传输无关的 JSON-RPC 处理(stdio 与 HTTP 共用,⛔ 不写两份) */
|
|
265
|
+
export function rpcHandler(gw) {
|
|
266
|
+
return async (msg) => {
|
|
267
|
+
const { id, method, params } = msg || {}
|
|
268
|
+
if (method === 'initialize') return { jsonrpc: '2.0', id, result: { protocolVersion: '2024-11-05', capabilities: { tools: {} }, serverInfo: SERVER_INFO } }
|
|
269
|
+
if (method === 'notifications/initialized') return null
|
|
270
|
+
if (method === 'ping') return { jsonrpc: '2.0', id, result: {} }
|
|
271
|
+
if (method === 'tools/list') return { jsonrpc: '2.0', id, result: { tools: gw.listTools() } }
|
|
272
|
+
if (method === 'tools/call') return { jsonrpc: '2.0', id, result: await gw.call(params && params.name, (params && params.arguments) || {}) }
|
|
273
|
+
return { jsonrpc: '2.0', id, error: { code: -32601, message: `method not found: ${method}` } }
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
function startStdio(gw) {
|
|
278
|
+
const handle = rpcHandler(gw)
|
|
279
|
+
process.stdin.setEncoding('utf8')
|
|
280
|
+
let buf = ''
|
|
281
|
+
process.stdin.on('data', async (chunk) => {
|
|
282
|
+
buf += chunk
|
|
283
|
+
let i
|
|
284
|
+
while ((i = buf.indexOf('\n')) >= 0) {
|
|
285
|
+
const line = buf.slice(0, i).trim(); buf = buf.slice(i + 1)
|
|
286
|
+
if (!line) continue
|
|
287
|
+
let msg; try { msg = JSON.parse(line) } catch { continue }
|
|
288
|
+
const out = await handle(msg)
|
|
289
|
+
if (out) send(out)
|
|
290
|
+
}
|
|
291
|
+
})
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
async function startHttp(gw, port, bind) {
|
|
295
|
+
const { createServer } = await import('node:http')
|
|
296
|
+
const handle = rpcHandler(gw)
|
|
297
|
+
const server = createServer(async (req, res) => {
|
|
298
|
+
const j = (code, body) => { res.writeHead(code, { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store' }); res.end(JSON.stringify(body)) }
|
|
299
|
+
if (req.method === 'GET' && (req.url === '/health' || req.url === '/mcp/health')) {
|
|
300
|
+
return j(200, { ok: true, tools: gw.routes.size, upstreams: [...new Set([...gw.routes.values()].map((r) => r.up.id))] })
|
|
301
|
+
}
|
|
302
|
+
if (!(req.method === 'POST' && (req.url === '/mcp' || req.url === '/mcp/'))) return j(404, { error: 'not_found' })
|
|
303
|
+
let raw = ''
|
|
304
|
+
for await (const c of req) raw += c
|
|
305
|
+
let body
|
|
306
|
+
try { body = JSON.parse(raw || '{}') } catch { return j(400, { error: 'bad_json' }) }
|
|
307
|
+
const out = await handle(body)
|
|
308
|
+
if (!out) return j(202, { ok: true })
|
|
309
|
+
return j(200, out)
|
|
310
|
+
})
|
|
311
|
+
await new Promise((resolve) => server.listen(port, bind, resolve))
|
|
312
|
+
process.stderr.write(`[gateway] HTTP 就绪:http://${bind}:${port}/mcp(tools ${gw.routes.size})\n`)
|
|
313
|
+
return server
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
async function main() {
|
|
317
|
+
const gw = new Gateway()
|
|
318
|
+
await gw.start()
|
|
319
|
+
process.stderr.write(`[gateway] 就绪:${gw.routes.size} 个工具(${[...new Set([...gw.routes.values()].map((r) => r.up.id))].join(' / ')})\n`)
|
|
320
|
+
|
|
321
|
+
const httpMode = process.env.ARGENT_GATEWAY_HTTP === '1'
|
|
322
|
+
let server = null
|
|
323
|
+
if (httpMode) {
|
|
324
|
+
const port = Number(process.env.ARGENT_GATEWAY_PORT || 8791)
|
|
325
|
+
const bind = process.env.ARGENT_GATEWAY_BIND || '127.0.0.1'
|
|
326
|
+
server = await startHttp(gw, port, bind)
|
|
327
|
+
} else {
|
|
328
|
+
startStdio(gw)
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
const shutdown = () => { try { server && server.close() } catch { /* noop */ } gw.close(); process.exit(0) }
|
|
332
|
+
process.on('SIGTERM', shutdown)
|
|
333
|
+
process.on('SIGINT', shutdown)
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
if (process.argv[1] && /gateway\.mjs$/.test(process.argv[1])) main()
|
package/mcp/meter.mjs
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* meter.mjs —— 计费事件的**本地台账 + 签名批次**(v0.9.0)
|
|
3
|
+
*
|
|
4
|
+
* 为什么需要签名:客户机是"可被改动"的环境,事件必须能证明"没被事后篡改",
|
|
5
|
+
* 否则服务端对账时无法判断是漏报还是改过。
|
|
6
|
+
*
|
|
7
|
+
* 落盘(全部在 `<dataDir>/gateway/`,0600):
|
|
8
|
+
* events.jsonl 追加式事件(一行一条;含 idem 幂等键)
|
|
9
|
+
* events.signed.jsonl 签名批次(每批 {batchId, fromSeq, toSeq, count, hmac})
|
|
10
|
+
* billing.key HMAC 密钥(首次自动生成,0600;⛔ 不进仓库、不上报)
|
|
11
|
+
* signoff.json 已签到的最大 seq(避免重复签)
|
|
12
|
+
*
|
|
13
|
+
* 口径(与 configs/billing-actions.json 一致):
|
|
14
|
+
* · 只在**动作成功**时记;读/提议/失败不计;
|
|
15
|
+
* · 同一 `idem` 只记一次(重试/重放不重复计费);
|
|
16
|
+
* · ⛔ 不记录参数与结果正文(只 action / points / tenant / user / ts)。
|
|
17
|
+
*/
|
|
18
|
+
import { appendFileSync, chmodSync, existsSync, mkdirSync, openSync, closeSync, readFileSync, writeSync } from 'node:fs'
|
|
19
|
+
import { createHmac, randomUUID } from 'node:crypto'
|
|
20
|
+
import { join } from 'node:path'
|
|
21
|
+
|
|
22
|
+
const KEYS = ['at', 'action', 'idem', 'points', 'tenant', 'user', 'ok']
|
|
23
|
+
|
|
24
|
+
function dirOf(dataDir) {
|
|
25
|
+
const d = join(dataDir, 'gateway')
|
|
26
|
+
mkdirSync(d, { recursive: true })
|
|
27
|
+
return d
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** 读密钥;不存在则生成(0600) */
|
|
31
|
+
export function loadOrCreateKey(dataDir) {
|
|
32
|
+
const f = join(dirOf(dataDir), 'billing.key')
|
|
33
|
+
if (existsSync(f)) return readFileSync(f, 'utf8').trim()
|
|
34
|
+
const key = randomUUID().replace(/-/g, '') + randomUUID().replace(/-/g, '')
|
|
35
|
+
const fd = openSync(f, 'w', 0o600)
|
|
36
|
+
writeSync(fd, key)
|
|
37
|
+
closeSync(fd)
|
|
38
|
+
try { chmodSync(f, 0o600) } catch { /* noop */ }
|
|
39
|
+
return key
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function readLines(file) {
|
|
43
|
+
if (!existsSync(file)) return []
|
|
44
|
+
return readFileSync(file, 'utf8').split('\n').filter(Boolean)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** 已存在的幂等键集合(启动时读一次;之后内存维护) */
|
|
48
|
+
export function loadIdemSet(dataDir) {
|
|
49
|
+
const set = new Set()
|
|
50
|
+
for (const line of readLines(join(dirOf(dataDir), 'events.jsonl'))) {
|
|
51
|
+
try { const e = JSON.parse(line); if (e.idem) set.add(e.idem) } catch { /* 跳过坏行 */ }
|
|
52
|
+
}
|
|
53
|
+
return set
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* 追加一条事件。
|
|
58
|
+
* @returns {{ok:boolean, duplicate?:boolean, seq?:number}}
|
|
59
|
+
*/
|
|
60
|
+
export function appendEvent(dataDir, { action, idem = '', points = 0, tenant = '', user = '', ok = true, idemSet } = {}) {
|
|
61
|
+
if (!ok) return { ok: false, reason: 'not_successful' }
|
|
62
|
+
if (idem && idemSet && idemSet.has(idem)) return { ok: true, duplicate: true }
|
|
63
|
+
const dir = dirOf(dataDir)
|
|
64
|
+
const file = join(dir, 'events.jsonl')
|
|
65
|
+
const seq = readLines(file).length + 1
|
|
66
|
+
const ev = { at: new Date().toISOString(), seq, action, idem, points, tenant, user }
|
|
67
|
+
appendFileSync(file, JSON.stringify(ev) + '\n')
|
|
68
|
+
if (idem && idemSet) idemSet.add(idem)
|
|
69
|
+
return { ok: true, seq }
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** 把 events.jsonl 里尚未签名的事件打包签名(幂等:靠 signoff.json 的 toSeq) */
|
|
73
|
+
export function signPending(dataDir, { key } = {}) {
|
|
74
|
+
const dir = dirOf(dataDir)
|
|
75
|
+
const events = readLines(join(dir, 'events.jsonl')).map((l) => JSON.parse(l))
|
|
76
|
+
const signoffFile = join(dir, 'signoff.json')
|
|
77
|
+
let signedTo = 0
|
|
78
|
+
try { signedTo = JSON.parse(readFileSync(signoffFile, 'utf8')).toSeq || 0 } catch { /* first run */ }
|
|
79
|
+
const pending = events.filter((e) => (e.seq || 0) > signedTo)
|
|
80
|
+
if (!pending.length) return { ok: true, signed: 0, toSeq: signedTo }
|
|
81
|
+
const hm = createHmac('sha256', key || loadOrCreateKey(dataDir))
|
|
82
|
+
// 规范化:字段顺序固定,避免签名依赖 JSON 键序
|
|
83
|
+
const canonical = pending.map((e) => KEYS.map((k) => (k === 'at' || k === 'action' || k === 'idem' ? (e[k] || '') : e[k] === undefined ? '' : String(e[k]))).join('\u0001')).join('\n')
|
|
84
|
+
const hmac = hm.update(canonical).digest('hex')
|
|
85
|
+
const batch = { batchId: randomUUID(), fromSeq: pending[0].seq, toSeq: pending[pending.length - 1].seq, count: pending.length, hmac, at: new Date().toISOString() }
|
|
86
|
+
appendFileSync(join(dir, 'events.signed.jsonl'), JSON.stringify(batch) + '\n')
|
|
87
|
+
const fd = openSync(signoffFile, 'w', 0o600)
|
|
88
|
+
writeSync(fd, JSON.stringify({ toSeq: batch.toSeq, batchId: batch.batchId }))
|
|
89
|
+
closeSync(fd)
|
|
90
|
+
return { ok: true, signed: batch.count, toSeq: batch.toSeq, batchId: batch.batchId }
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** 校验一个批次(测试/服务端对账可用同一实现) */
|
|
94
|
+
export function verifyBatch(events, batch, key) {
|
|
95
|
+
const canonical = events
|
|
96
|
+
.filter((e) => e.seq >= batch.fromSeq && e.seq <= batch.toSeq)
|
|
97
|
+
.map((e) => KEYS.map((k) => (k === 'at' || k === 'action' || k === 'idem' ? (e[k] || '') : e[k] === undefined ? '' : String(e[k]))).join('\u0001'))
|
|
98
|
+
.join('\n')
|
|
99
|
+
const expect = createHmac('sha256', key).update(canonical).digest('hex')
|
|
100
|
+
return expect === batch.hmac
|
|
101
|
+
}
|
package/mcp/vault.mjs
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* vault.mjs —— 代号 ↔ 真名(脱敏用,**只存本机**)
|
|
3
|
+
*
|
|
4
|
+
* 为什么要它:把"六禾园"这类实体名换成 `[客户A]` 再进模型,模型推理正常但看不到真名;
|
|
5
|
+
* 需要展示真名时在**本地**回填(rehydrate)。映射表落 `<dataDir>/gateway/vault.json`(0600)。
|
|
6
|
+
*
|
|
7
|
+
* 红线:
|
|
8
|
+
* · ⛔ 只落本机,绝不进事件/审计/上报(审计只记"脱敏了几个实体");
|
|
9
|
+
* · token 形状固定 `[<kind>字母]`,同值同 token(稳定,便于模型在上下文中指代);
|
|
10
|
+
* · kind 各自独立计数(客户A / 店铺A 不串号)。
|
|
11
|
+
*/
|
|
12
|
+
import { existsSync, mkdirSync, readFileSync, chmodSync, openSync, closeSync, writeSync } from 'node:fs'
|
|
13
|
+
import { join } from 'node:path'
|
|
14
|
+
|
|
15
|
+
const KIND_PREFIX = { customer: '客户', company: '客户', store: '店铺', user: '人员', item: '商品' }
|
|
16
|
+
|
|
17
|
+
export function loadVault(dataDir) {
|
|
18
|
+
const file = join(dataDir, 'gateway', 'vault.json')
|
|
19
|
+
if (existsSync(file)) {
|
|
20
|
+
try {
|
|
21
|
+
const j = JSON.parse(readFileSync(file, 'utf8'))
|
|
22
|
+
return { file, counters: j.counters || {}, byValue: j.byValue || {}, byToken: j.byToken || {} }
|
|
23
|
+
} catch { /* 坏文件 ⇒ 重建(不静默丢:下面 save 时会覆盖) */ }
|
|
24
|
+
}
|
|
25
|
+
return { file, counters: {}, byValue: {}, byToken: {} }
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function saveVault(vault) {
|
|
29
|
+
const { file, counters, byValue, byToken } = vault
|
|
30
|
+
mkdirSync(file.slice(0, file.lastIndexOf('/')), { recursive: true })
|
|
31
|
+
const fd = openSync(file, 'w', 0o600)
|
|
32
|
+
writeSync(fd, JSON.stringify({ counters, byValue, byToken }, null, 0))
|
|
33
|
+
closeSync(fd)
|
|
34
|
+
try { chmodSync(file, 0o600) } catch { /* noop */ }
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** 把实体名换成稳定代号;空值/短值不处理 */
|
|
38
|
+
export function tokenize(vault, kind, value) {
|
|
39
|
+
const raw = String(value || '').trim()
|
|
40
|
+
if (!raw || raw.length < 2) return raw
|
|
41
|
+
const key = `${kind}:${raw}`
|
|
42
|
+
if (vault.byValue[key]) return vault.byValue[key]
|
|
43
|
+
const prefix = KIND_PREFIX[kind] || '实体'
|
|
44
|
+
const n = (vault.counters[kind] || 0) + 1
|
|
45
|
+
vault.counters[kind] = n
|
|
46
|
+
const letter = String.fromCharCode(64 + ((n - 1) % 26) + 1)
|
|
47
|
+
const token = `[${prefix}${letter}${n > 26 ? Math.ceil(n / 26) : ''}]`
|
|
48
|
+
vault.byValue[key] = token
|
|
49
|
+
vault.byToken[token] = raw
|
|
50
|
+
return token
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** 文本里出现代号就换回真名(只在本地展示时用) */
|
|
54
|
+
export function rehydrate(vault, text) {
|
|
55
|
+
let out = String(text || '')
|
|
56
|
+
for (const [token, real] of Object.entries(vault.byToken)) {
|
|
57
|
+
if (out.includes(token)) out = out.split(token).join(real)
|
|
58
|
+
}
|
|
59
|
+
return out
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** 按字段名把对象里的实体名换成代号(浅层递归,命中即换) */
|
|
63
|
+
export function tokenizeEntities(vault, obj, fields = /^(customer|company|buyer|contact|user|owner|store|shop)Name$/i) {
|
|
64
|
+
if (Array.isArray(obj)) return obj.map((v) => tokenizeEntities(vault, v, fields))
|
|
65
|
+
if (obj && typeof obj === 'object') {
|
|
66
|
+
const out = {}
|
|
67
|
+
for (const [k, v] of Object.entries(obj)) {
|
|
68
|
+
if (fields.test(k) && typeof v === 'string') {
|
|
69
|
+
const kind = /^(store|shop)Name$/i.test(k) ? 'store' : /^(contact|user|owner)Name$/i.test(k) ? 'user' : 'customer'
|
|
70
|
+
out[k] = tokenize(vault, kind, v)
|
|
71
|
+
continue
|
|
72
|
+
}
|
|
73
|
+
out[k] = tokenizeEntities(vault, v, fields)
|
|
74
|
+
}
|
|
75
|
+
return out
|
|
76
|
+
}
|
|
77
|
+
return obj
|
|
78
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 账号 token 的唯一解析点(2026-11-03)
|
|
3
|
+
*
|
|
4
|
+
* 为什么要单独成文件:ERP MCP 适配层(core/mcp/erp-server.mjs)与 dsh 宿主插件
|
|
5
|
+
* (plugins/argent-erp-ui)都要用**同一把**问述账号 JWT,⛔ 不许各写一套"先读哪个文件"。
|
|
6
|
+
*
|
|
7
|
+
* 顺序(实测依据见 docs/Argent-0.9-ERP入口与MCP网关-规划.md):
|
|
8
|
+
* ① `ARGENT_ACCOUNT_TOKEN`(显式注入,测试/排障)
|
|
9
|
+
* ② `WHYSHU_API_KEY` 环境变量(宿主 profile 注入)
|
|
10
|
+
* ③ `~/.dsh/.credentials.yaml` 的 `refs.WHYSHU_API_KEY`(现役;只读,⛔ 绝不写)
|
|
11
|
+
*
|
|
12
|
+
* ⚠️ `~/.dsh/argent/whyshu.env` 里的 JWT **已过期**(2026-11-03 实测 401),故意不读它。
|
|
13
|
+
*/
|
|
14
|
+
import { readFileSync } from 'node:fs'
|
|
15
|
+
import { homedir } from 'node:os'
|
|
16
|
+
import { join } from 'node:path'
|
|
17
|
+
|
|
18
|
+
/** 只读:从 credentials 文件里取 `WHYSHU_API_KEY: <token>`(窄正则,避免解析整份 YAML) */
|
|
19
|
+
export function readTokenFromCredentials(dshHome = process.env.DSH_HOME || join(homedir(), '.dsh')) {
|
|
20
|
+
try {
|
|
21
|
+
const raw = readFileSync(join(dshHome, '.credentials.yaml'), 'utf8')
|
|
22
|
+
const m = raw.match(/WHYSHU_API_KEY\s*:\s*"?([A-Za-z0-9._-]{20,})/)
|
|
23
|
+
return m ? m[1] : ''
|
|
24
|
+
} catch {
|
|
25
|
+
return ''
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** @returns {{token:string, source:'env-explicit'|'env-whyshu'|'credentials'|'none'}} */
|
|
30
|
+
export function resolveAccountToken({ env = process.env, dshHome } = {}) {
|
|
31
|
+
const explicit = String(env.ARGENT_ACCOUNT_TOKEN || '').trim()
|
|
32
|
+
if (explicit) return { token: explicit, source: 'env-explicit' }
|
|
33
|
+
const whyshu = String(env.WHYSHU_API_KEY || '').trim()
|
|
34
|
+
if (whyshu) return { token: whyshu, source: 'env-whyshu' }
|
|
35
|
+
const fromFile = readTokenFromCredentials(dshHome)
|
|
36
|
+
return fromFile ? { token: fromFile, source: 'credentials' } : { token: '', source: 'none' }
|
|
37
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 脱敏原语(2026-11-03,v0.9.0)
|
|
3
|
+
*
|
|
4
|
+
* 唯一实现点:ERP MCP 适配层(core/mcp/erp-server.mjs)与 dsh 宿主插件(plugins/argent-erp-ui)
|
|
5
|
+
* 都从这里取,⛔ 不许各写一套正则(两份必然漂移)。
|
|
6
|
+
*
|
|
7
|
+
* 当前规则(最小集,可扩):
|
|
8
|
+
* · 手机号 → 保留前3后4,中间 ****
|
|
9
|
+
* · 键名命中 token / downloadUrl / signedUrl ⇒ **丢弃**(只留文件名类字段)
|
|
10
|
+
* · 超长字符串(>400)截断,避免把整页 HTML 塞进模型上下文
|
|
11
|
+
* · `tenantId`(内部 Mongo id)不返回:对用户无意义,且能关联到别处
|
|
12
|
+
*/
|
|
13
|
+
export function maskPhone(value) {
|
|
14
|
+
return String(value || '').replace(/(\d{3})\d{4}(\d{4})/, '$1****$2')
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const DROP_KEYS = /(token|downloadUrl|download_url|signedUrl|signed_url|tenantId)/i
|
|
18
|
+
const PHONE_KEYS = /(phone|mobile)/i
|
|
19
|
+
|
|
20
|
+
export function redactDeep(value, depth = 0) {
|
|
21
|
+
if (depth > 6) return value
|
|
22
|
+
if (Array.isArray(value)) return value.map((v) => redactDeep(v, depth + 1))
|
|
23
|
+
if (value && typeof value === 'object') {
|
|
24
|
+
const out = {}
|
|
25
|
+
for (const [k, v] of Object.entries(value)) {
|
|
26
|
+
if (DROP_KEYS.test(k)) continue
|
|
27
|
+
if (PHONE_KEYS.test(k) && typeof v === 'string') { out[k] = maskPhone(v); continue }
|
|
28
|
+
out[k] = redactDeep(v, depth + 1)
|
|
29
|
+
}
|
|
30
|
+
return out
|
|
31
|
+
}
|
|
32
|
+
if (typeof value === 'string' && value.length > 400) return `${value.slice(0, 400)}…`
|
|
33
|
+
return value
|
|
34
|
+
}
|
package/package.json
CHANGED
|
@@ -1,30 +1,40 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@whyshu/argent",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Argent 启动器:一条命令启动「打包好的 dsh + Argent 插件/角色预设」,并管理本机 runner 常驻。",
|
|
3
|
+
"version": "0.2.0",
|
|
5
4
|
"type": "module",
|
|
6
|
-
"
|
|
7
|
-
|
|
8
|
-
},
|
|
5
|
+
"description": "Argent 单 bundle(dsh web / DSH Desktop 同一插件):ERP 连接器 + 问述代理 + 登录/续期 + MCP 网关 + 伞伞桌宠(含积分)",
|
|
6
|
+
"main": "src/index.js",
|
|
9
7
|
"exports": {
|
|
10
|
-
".": "./
|
|
8
|
+
".": "./src/index.js",
|
|
9
|
+
"./client": "./dist/client.js",
|
|
10
|
+
"./cordis.patch.yml": "./cordis.patch.yml",
|
|
11
11
|
"./package.json": "./package.json"
|
|
12
12
|
},
|
|
13
|
-
"
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
"
|
|
18
|
-
"@whyshu/argent-engine": "0.8.0-rc.11"
|
|
13
|
+
"license": "MIT",
|
|
14
|
+
"repository": {
|
|
15
|
+
"type": "git",
|
|
16
|
+
"url": "git+https://github.com/cstcen/argent-dsh.git",
|
|
17
|
+
"directory": "plugins/argent"
|
|
19
18
|
},
|
|
20
19
|
"files": [
|
|
21
|
-
"
|
|
22
|
-
"
|
|
23
|
-
"
|
|
20
|
+
"src/",
|
|
21
|
+
"dist/",
|
|
22
|
+
"mcp/",
|
|
23
|
+
"cordis.patch.yml",
|
|
24
24
|
"README.md"
|
|
25
25
|
],
|
|
26
|
-
"
|
|
27
|
-
|
|
28
|
-
|
|
26
|
+
"publishConfig": {
|
|
27
|
+
"access": "public"
|
|
28
|
+
},
|
|
29
|
+
"dsh": {
|
|
30
|
+
"bundle": {
|
|
31
|
+
"patch": "./cordis.patch.yml"
|
|
32
|
+
},
|
|
33
|
+
"client": {
|
|
34
|
+
"platform": "web"
|
|
35
|
+
},
|
|
36
|
+
"plugin": {
|
|
37
|
+
"displayName": "Argent(ERP + 问述代理 + 伞伞)"
|
|
38
|
+
}
|
|
29
39
|
}
|
|
30
40
|
}
|