@bolloon/bolloon-agent 0.4.30 → 0.4.31
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/dist/agents/agent-gateway.js +5 -0
- package/dist/agents/agent-manifest-protocol.js +29 -0
- package/dist/agents/economic-policy.js +7 -0
- package/dist/agents/gateway-network.js +6 -0
- package/dist/agents/local-signer.js +59 -0
- package/dist/agents/network-pulse.js +450 -0
- package/dist/agents/task-contract.js +322 -0
- package/dist/agents/task-inbox.js +267 -0
- package/dist/agents/task-transport.js +280 -0
- package/dist/agents/x402/transaction-store.js +8 -1
- package/dist/cli/commands/agent.js +230 -0
- package/dist/cli/commands/index.js +61 -0
- package/dist/cli/commands/mcp.js +98 -0
- package/dist/cli/commands/network.js +334 -0
- package/dist/cli/commands/payment.js +125 -0
- package/dist/cli/commands/tasks.js +1236 -0
- package/dist/cli/commands/trade.js +229 -0
- package/dist/cli/commands/wallet.js +450 -0
- package/dist/cli/mcp/bridge.js +105 -0
- package/dist/cli/mcp/server.js +185 -0
- package/dist/cli/mcp/tools.js +612 -0
- package/dist/cli/protocol-envelope.js +346 -0
- package/dist/cli-entry.js +141 -17
- package/dist/electron-build/agents/agent-gateway.js +5 -0
- package/dist/electron-build/agents/agent-gateway.js.map +1 -1
- package/dist/electron-build/agents/agent-manifest-protocol.js +62 -0
- package/dist/electron-build/agents/agent-manifest-protocol.js.map +1 -1
- package/dist/electron-build/agents/economic-policy.js +7 -0
- package/dist/electron-build/agents/economic-policy.js.map +1 -1
- package/dist/electron-build/agents/gateway-network.js +6 -0
- package/dist/electron-build/agents/gateway-network.js.map +1 -1
- package/dist/electron-build/agents/network-pulse.js +504 -0
- package/dist/electron-build/agents/network-pulse.js.map +1 -0
- package/dist/electron-build/agents/x402/transaction-store.js +8 -1
- package/dist/electron-build/agents/x402/transaction-store.js.map +1 -1
- package/dist/web/server.js +29 -0
- package/dist/web/task-frame-server.js +211 -0
- package/package.json +1 -1
|
@@ -0,0 +1,450 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* wallet.ts — P3 `bolloon wallet status|policy|set-policy|sign`
|
|
3
|
+
*
|
|
4
|
+
* 薄包装 (不重实现):
|
|
5
|
+
* status → 读 `~/.bolloon/wallet.json` (**只读**; 绝不为了看一眼状态就创建钱包) + `economic-policy` 配置 + 签名审计账本摘要
|
|
6
|
+
* policy → `economic-policy.LocalEconomicPolicy` (load 后读 config/dailySpent)
|
|
7
|
+
* set-policy → 同上 + `updateConfig()` + 落盘 (`~/.bolloon/economic-policy.json`)
|
|
8
|
+
* sign → **2026-09-21 (P3 收尾)**: 用本机钱包签一段 payload; 唯一放行闸 = `task-contract.authorizeWalletSignature`
|
|
9
|
+
* (fail-closed), 每次签名写 `~/.bolloon/wallet-signatures.jsonl` 审计账本。
|
|
10
|
+
*
|
|
11
|
+
* 红线 (P1 §5.5 / §5.6): **私钥绝不进 stdout/日志** (本文件里私钥只活在局部变量里, 不进任何返回值/data);
|
|
12
|
+
* 审计账本只记摘要字段 (payload 只记 sha256); `wallet sign` 也**不代付款** —— 它只签名, 不放钱。
|
|
13
|
+
*/
|
|
14
|
+
import * as fs from 'fs';
|
|
15
|
+
import * as os from 'os';
|
|
16
|
+
import * as path from 'path';
|
|
17
|
+
import { okEnvelope, failEnvelope, line, title, hint, plain, opt, optAll, } from '../protocol-envelope.js';
|
|
18
|
+
export const WALLET_USAGE = `
|
|
19
|
+
${title('bolloon wallet')}
|
|
20
|
+
bolloon wallet status [--json] 钱包可用性 + 策略 + 最近签名审计 (只读; 不创建钱包)
|
|
21
|
+
bolloon wallet policy [--json] 当前支付策略 (单笔/日限额/白名单/速率)
|
|
22
|
+
bolloon wallet set-policy --per-tx 1 --daily 10 [--allow-service research] [--allow-recipient 0x..] [--rate-limit 5]
|
|
23
|
+
bolloon wallet sign --payload "<文本>" [--mode agent-authorized|autonomous|policy|manual] \\
|
|
24
|
+
[--capability <c>] [--amount <USDC 人类单位>] [--network <net>] [--per-tx <原子>] [--daily <原子>]
|
|
25
|
+
|
|
26
|
+
选项: --json · --quiet · --request-id <id> · --timeout <ms>
|
|
27
|
+
说明: wallet sign 必须过放行闸 (fail-closed): 需要本机显式授权 (签名策略文件 ~/.bolloon/signing-policy.json 的
|
|
28
|
+
agentAuthorized=true, 或环境变量 BOLLOON_AGENT_AUTHORIZED=1) + 自主模式 + 本机钱包 + 网络/能力/额度/不重复。
|
|
29
|
+
**绝不输出私钥**; 审计写 ~/.bolloon/wallet-signatures.jsonl (只记摘要)。
|
|
30
|
+
`;
|
|
31
|
+
const walletFile = () => path.join(process.env.HOME || os.homedir(), '.bolloon', 'wallet.json');
|
|
32
|
+
/**
|
|
33
|
+
* 只读探测钱包 (不创建)。存在性检查与地址读取都走文件, 不调 `loadOrCreateWallet`
|
|
34
|
+
* —— 那是**写路径** (没有就生成新钱包), 不该被一条 status 查询触发。
|
|
35
|
+
*/
|
|
36
|
+
function readWallet() {
|
|
37
|
+
const f = walletFile();
|
|
38
|
+
if (!fs.existsSync(f))
|
|
39
|
+
return { present: false, address: null, hasKey: false };
|
|
40
|
+
try {
|
|
41
|
+
const j = JSON.parse(fs.readFileSync(f, 'utf8'));
|
|
42
|
+
const addr = typeof j?.address === 'string' && j.address ? j.address : null;
|
|
43
|
+
const hasKey = typeof j?.privateKey === 'string' && j.privateKey.length > 0;
|
|
44
|
+
// ★ 私钥连读都不往外带 (它只在本机进程里用)
|
|
45
|
+
return { present: !!addr, address: addr, hasKey };
|
|
46
|
+
}
|
|
47
|
+
catch (e) {
|
|
48
|
+
return { present: false, address: null, hasKey: false, error: `wallet.json 读不出来: ${String(e?.message || e).slice(0, 120)}` };
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
/** 策略读取: 用类实例 + 显式 await load (getEconomicPolicy() 的 load 是不 await 的, 一次性 CLI 会读到默认值) */
|
|
52
|
+
async function loadPolicy() {
|
|
53
|
+
const { LocalEconomicPolicy } = await import('../../agents/economic-policy.js');
|
|
54
|
+
const p = new LocalEconomicPolicy();
|
|
55
|
+
// 注意 (如实标注): load() 内含 resetIfNewDay() → 跨天时会**落盘**一份默认配置
|
|
56
|
+
// (`~/.bolloon/economic-policy.json`)。这是既有服务自身的行为 (Runtime 每次读策略也一样), 不是 CLI 另加的写。
|
|
57
|
+
await p.load();
|
|
58
|
+
return p;
|
|
59
|
+
}
|
|
60
|
+
export async function walletCommand(flags) {
|
|
61
|
+
const sub = flags.positionals[0];
|
|
62
|
+
switch (sub) {
|
|
63
|
+
case 'status': return walletStatus(flags);
|
|
64
|
+
case 'policy': return walletPolicy(flags);
|
|
65
|
+
case 'set-policy': return walletSetPolicy(flags);
|
|
66
|
+
case 'sign': return walletSign(flags);
|
|
67
|
+
default:
|
|
68
|
+
return {
|
|
69
|
+
envelope: failEnvelope('INVALID_ARGUMENT', sub ? `未知 wallet 子命令: ${sub}` : '缺少 wallet 子命令', { usage: plain(WALLET_USAGE.trim()) }, [], 'needs_human'),
|
|
70
|
+
human: WALLET_USAGE,
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
async function walletStatus(flags) {
|
|
75
|
+
const w = readWallet();
|
|
76
|
+
const policy = await loadPolicy();
|
|
77
|
+
const cfg = policy.config();
|
|
78
|
+
const spent = await policy.dailySpent();
|
|
79
|
+
const { readSignatureAudit } = await import('../../agents/task-contract.js');
|
|
80
|
+
const audit = await readSignatureAudit(undefined, 5);
|
|
81
|
+
const data = {
|
|
82
|
+
walletAvailable: w.present,
|
|
83
|
+
address: w.address, // 公开地址 (收款用); **私钥永不出现**
|
|
84
|
+
privateKeyPrinted: false,
|
|
85
|
+
walletFile: '~/.bolloon/wallet.json',
|
|
86
|
+
readOnly: true,
|
|
87
|
+
walletFileError: w.error || null,
|
|
88
|
+
policy: {
|
|
89
|
+
perTransactionLimit: cfg.perTransactionLimit,
|
|
90
|
+
dailyLimit: cfg.dailyLimit,
|
|
91
|
+
allowedRecipients: cfg.allowedRecipients,
|
|
92
|
+
allowedServices: cfg.allowedServices,
|
|
93
|
+
rateLimitPerMinute: cfg.rateLimitPerMinute,
|
|
94
|
+
dailySpent: spent,
|
|
95
|
+
source: '~/.bolloon/economic-policy.json (缺文件 = 保守默认值)',
|
|
96
|
+
},
|
|
97
|
+
signatureAudit: {
|
|
98
|
+
file: '~/.bolloon/wallet-signatures.jsonl',
|
|
99
|
+
recent: audit.map((a) => ({ at: a.at, kind: a.kind, mode: a.mode, requestId: a.requestId, taskId: a.taskId || null, signerFingerprint: a.signerFingerprint })),
|
|
100
|
+
},
|
|
101
|
+
};
|
|
102
|
+
const human = [
|
|
103
|
+
title('bolloon wallet status'),
|
|
104
|
+
line('钱包可用', w.present ? '是 (地址已就绪)' : '否 —— 本机还没有钱包 (首次 x402 付款时自动生成)'),
|
|
105
|
+
...(w.address ? [line('地址', w.address)] : []),
|
|
106
|
+
line('私钥', '未输出 (只在本机进程可用; 这条命令是只读的)'),
|
|
107
|
+
line('单笔上限', `${cfg.perTransactionLimit} USDC`),
|
|
108
|
+
line('日限额', `${cfg.dailyLimit} USDC (今日已用 ${spent})`),
|
|
109
|
+
line('白名单', `收款方 ${cfg.allowedRecipients.length ? cfg.allowedRecipients.join(', ') : '(全部)'} · 服务 ${cfg.allowedServices.length ? cfg.allowedServices.join(', ') : '(全部)'}`),
|
|
110
|
+
line('速率', `${cfg.rateLimitPerMinute}/min`),
|
|
111
|
+
line('签名审计', `${audit.length} 条 (最近; 只给摘要, 无正文/无私钥)`),
|
|
112
|
+
...(w.error ? [line('说明', w.error)] : []),
|
|
113
|
+
].join('\n');
|
|
114
|
+
if (!w.present) {
|
|
115
|
+
// §3 WALLET_UNAVAILABLE (放行闸 walletAvailable=false): 说清现状与下一步, 不编造地址
|
|
116
|
+
return {
|
|
117
|
+
envelope: failEnvelope('WALLET_UNAVAILABLE', '本机还没有可用钱包 (wallet.json 不存在或不可读)', data, [], 'needs_human'),
|
|
118
|
+
human: `${human}\n\n${hint('下一步: 钱包在首次 x402 付款时自动生成 (~/.bolloon/wallet.json, 0600); 或 bolloon model / bolloon setup 先跑一遍初始化。策略见 bolloon wallet policy')}`,
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
return {
|
|
122
|
+
envelope: okEnvelope('OK', `钱包可用 (${String(w.address).slice(0, 10)}…), 策略: 单笔 ≤ ${cfg.perTransactionLimit} / 日 ≤ ${cfg.dailyLimit}`, data, w.address ? [String(w.address)] : [], null),
|
|
123
|
+
human,
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
async function walletPolicy(flags) {
|
|
127
|
+
const policy = await loadPolicy();
|
|
128
|
+
const cfg = policy.config();
|
|
129
|
+
const spent = await policy.dailySpent();
|
|
130
|
+
const data = { ...cfg, dailySpent: spent, source: '~/.bolloon/economic-policy.json (缺文件 = 保守默认值)' };
|
|
131
|
+
return {
|
|
132
|
+
envelope: okEnvelope('OK', `支付策略: 单笔 ≤ ${cfg.perTransactionLimit}, 日 ≤ ${cfg.dailyLimit} (今日已用 ${spent})`, data, [], null),
|
|
133
|
+
human: [
|
|
134
|
+
title('bolloon wallet policy'),
|
|
135
|
+
line('单笔上限', `${cfg.perTransactionLimit} USDC`),
|
|
136
|
+
line('每日预算', `${cfg.dailyLimit} USDC (今日已用 ${spent})`),
|
|
137
|
+
line('允许收款方', cfg.allowedRecipients.length ? cfg.allowedRecipients.join(', ') : '(全部)'),
|
|
138
|
+
line('允许服务', cfg.allowedServices.length ? cfg.allowedServices.join(', ') : '(全部)'),
|
|
139
|
+
line('速率', `${cfg.rateLimitPerMinute}/min`),
|
|
140
|
+
`\n ${hint('改: bolloon wallet set-policy --per-tx 1 --daily 10 [--allow-service research]')}`,
|
|
141
|
+
].join('\n'),
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
async function walletSetPolicy(flags) {
|
|
145
|
+
const patch = {};
|
|
146
|
+
const perTx = opt(flags, '--per-tx');
|
|
147
|
+
const daily = opt(flags, '--daily');
|
|
148
|
+
const rate = opt(flags, '--rate-limit');
|
|
149
|
+
const recipients = optAll(flags, '--allow-recipient');
|
|
150
|
+
const services = optAll(flags, '--allow-service');
|
|
151
|
+
if (perTx !== undefined)
|
|
152
|
+
patch.perTransactionLimit = Number(perTx);
|
|
153
|
+
if (daily !== undefined)
|
|
154
|
+
patch.dailyLimit = Number(daily);
|
|
155
|
+
if (rate !== undefined)
|
|
156
|
+
patch.rateLimitPerMinute = Number(rate);
|
|
157
|
+
if (recipients.length)
|
|
158
|
+
patch.allowedRecipients = recipients;
|
|
159
|
+
if (services.length)
|
|
160
|
+
patch.allowedServices = services;
|
|
161
|
+
if (!Object.keys(patch).length) {
|
|
162
|
+
return {
|
|
163
|
+
envelope: failEnvelope('INVALID_ARGUMENT', '没有要改的字段', { usage: plain(WALLET_USAGE.trim()), accepted: ['--per-tx', '--daily', '--rate-limit', '--allow-recipient', '--allow-service'] }, [], 'needs_human'),
|
|
164
|
+
human: `${WALLET_USAGE}\n${hint('示例: bolloon wallet set-policy --per-tx 0.05 --daily 0.1 --allow-service research')}`,
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
const bad = Object.entries(patch).filter(([, v]) => typeof v === 'number' && !Number.isFinite(v));
|
|
168
|
+
if (bad.length) {
|
|
169
|
+
return {
|
|
170
|
+
envelope: failEnvelope('INVALID_ARGUMENT', `数值不合法: ${bad.map(([k]) => k).join(', ')}`, { patch }, [], 'needs_human'),
|
|
171
|
+
human: `${title('bolloon wallet set-policy')}\n 数值不合法: ${bad.map(([k]) => k).join(', ')}`,
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
const policy = await loadPolicy();
|
|
175
|
+
const before = policy.config();
|
|
176
|
+
policy.updateConfig(patch);
|
|
177
|
+
await policy.save(); // 落盘: 与 recordSpend/resetIfNewDay 同一份文件格式
|
|
178
|
+
const after = policy.config();
|
|
179
|
+
const spent = await policy.dailySpent();
|
|
180
|
+
const data = { before, after, changed: patch, dailySpent: spent, file: '~/.bolloon/economic-policy.json', sourceCommand: 'economic-policy.LocalEconomicPolicy.updateConfig + save' };
|
|
181
|
+
return {
|
|
182
|
+
envelope: okEnvelope('POLICY_UPDATED', `策略已更新: 单笔 ≤ ${after.perTransactionLimit}, 日 ≤ ${after.dailyLimit}`, data, [], null),
|
|
183
|
+
human: [
|
|
184
|
+
title('bolloon wallet set-policy'),
|
|
185
|
+
line('单笔上限', `${before.perTransactionLimit} → ${after.perTransactionLimit}`),
|
|
186
|
+
line('每日预算', `${before.dailyLimit} → ${after.dailyLimit}`),
|
|
187
|
+
line('速率', `${before.rateLimitPerMinute} → ${after.rateLimitPerMinute}/min`),
|
|
188
|
+
line('允许收款方', after.allowedRecipients.length ? after.allowedRecipients.join(', ') : '(全部)'),
|
|
189
|
+
line('允许服务', after.allowedServices.length ? after.allowedServices.join(', ') : '(全部)'),
|
|
190
|
+
line('落地文件', '~/.bolloon/economic-policy.json'),
|
|
191
|
+
`\n ${hint('注意: 这是系统级闸门; 任务级闸门 (M1 0.05/0.02/0.10) 独立生效, 两层都过才放行')}`,
|
|
192
|
+
].join('\n'),
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
// ── sign (P3 收尾: 受控自主签名, 唯一放行闸 = authorizeWalletSignature) ────────
|
|
196
|
+
/** 本机签名授权策略文件 (它是**本机用户**的显式授权; CLI 参数不能凭空授予) */
|
|
197
|
+
const signingPolicyFile = () => path.join(process.env.HOME || os.homedir(), '.bolloon', 'signing-policy.json');
|
|
198
|
+
/**
|
|
199
|
+
* 读本机签名授权策略。**唯一**两个授权来源:
|
|
200
|
+
* ① `~/.bolloon/signing-policy.json` 的 `agentAuthorized: true`
|
|
201
|
+
* ② 环境变量 `BOLLOON_AGENT_AUTHORIZED=1` (本机进程显式开启)
|
|
202
|
+
* 命令行参数只能**收紧** (取更小限额 / 白名单交集), **不能**授予权限。
|
|
203
|
+
*/
|
|
204
|
+
export function readSigningPolicy() {
|
|
205
|
+
const out = {
|
|
206
|
+
agentAuthorized: String(process.env.BOLLOON_AGENT_AUTHORIZED || '') === '1',
|
|
207
|
+
source: String(process.env.BOLLOON_AGENT_AUTHORIZED || '') === '1' ? 'env BOLLOON_AGENT_AUTHORIZED=1' : 'default (未授权)',
|
|
208
|
+
};
|
|
209
|
+
try {
|
|
210
|
+
const f = signingPolicyFile();
|
|
211
|
+
if (fs.existsSync(f)) {
|
|
212
|
+
const j = JSON.parse(fs.readFileSync(f, 'utf8'));
|
|
213
|
+
if (j?.agentAuthorized === true) {
|
|
214
|
+
out.agentAuthorized = true;
|
|
215
|
+
out.source = `${f} (agentAuthorized=true)`;
|
|
216
|
+
}
|
|
217
|
+
if (Array.isArray(j?.allowedNetworks))
|
|
218
|
+
out.allowedNetworks = j.allowedNetworks.map(String);
|
|
219
|
+
if (Array.isArray(j?.allowedCapabilities))
|
|
220
|
+
out.allowedCapabilities = j.allowedCapabilities.map(String);
|
|
221
|
+
if (typeof j?.maxPerTxAtomic === 'string')
|
|
222
|
+
out.maxPerTxAtomic = j.maxPerTxAtomic;
|
|
223
|
+
if (typeof j?.dailyLimitAtomic === 'string')
|
|
224
|
+
out.dailyLimitAtomic = j.dailyLimitAtomic;
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
catch (e) {
|
|
228
|
+
out.fileError = String(e?.message || e).slice(0, 160);
|
|
229
|
+
}
|
|
230
|
+
return out;
|
|
231
|
+
}
|
|
232
|
+
const ATOMIC = /^[0-9]+$/;
|
|
233
|
+
/** 取更小的原子额度 (夹紧, 不放大) */
|
|
234
|
+
const minAtomic = (a, b) => {
|
|
235
|
+
const ok = (v) => !!v && ATOMIC.test(v);
|
|
236
|
+
if (!ok(a))
|
|
237
|
+
return ok(b) ? b : undefined;
|
|
238
|
+
if (!ok(b))
|
|
239
|
+
return a;
|
|
240
|
+
return BigInt(a) <= BigInt(b) ? a : b;
|
|
241
|
+
};
|
|
242
|
+
/** 人类单位 → 原子单位 (0 / 非法 / 超精度 → null) */
|
|
243
|
+
function toAtomicHuman(human, decimals = 6) {
|
|
244
|
+
const s = String(human ?? '').trim();
|
|
245
|
+
if (!/^[0-9]+(\.[0-9]+)?$/.test(s))
|
|
246
|
+
return null;
|
|
247
|
+
const [i, f = ''] = s.split('.');
|
|
248
|
+
if (f.length > decimals)
|
|
249
|
+
return null;
|
|
250
|
+
return BigInt(i + (f + '0'.repeat(decimals)).slice(0, decimals)).toString();
|
|
251
|
+
}
|
|
252
|
+
async function walletSign(flags) {
|
|
253
|
+
const payload = opt(flags, '--payload') || opt(flags, '--message') || flags.positionals.slice(1).join(' ').trim();
|
|
254
|
+
if (!payload) {
|
|
255
|
+
return {
|
|
256
|
+
envelope: failEnvelope('INVALID_ARGUMENT', '缺少 --payload (要签什么)', { usage: plain(WALLET_USAGE.trim()), accepted: ['--payload "<文本>"'] }, [], 'needs_human'),
|
|
257
|
+
human: `${WALLET_USAGE}\n${hint('示例: BOLLOON_AGENT_AUTHORIZED=1 bolloon wallet sign --payload "order-42" --capability research --amount 0.01')}`,
|
|
258
|
+
};
|
|
259
|
+
}
|
|
260
|
+
const { isPaymentMode, authorizeWalletSignature, recordSignatureAudit, readSignatureAudit } = await import('../../agents/task-contract.js');
|
|
261
|
+
const { payloadDigest, fingerprintOf } = await import('../../agents/local-signer.js');
|
|
262
|
+
const modeRaw = opt(flags, '--mode') || 'agent-authorized';
|
|
263
|
+
if (!isPaymentMode(modeRaw)) {
|
|
264
|
+
return {
|
|
265
|
+
envelope: failEnvelope('INVALID_ARGUMENT', `--mode 非法: ${modeRaw} (要 manual|policy|autonomous|agent-authorized)`, { mode: modeRaw }, [], 'needs_human'),
|
|
266
|
+
human: WALLET_USAGE,
|
|
267
|
+
};
|
|
268
|
+
}
|
|
269
|
+
const capability = opt(flags, '--capability') || 'wallet.sign';
|
|
270
|
+
const network = opt(flags, '--network') || 'base-sepolia';
|
|
271
|
+
const currency = String(opt(flags, '--currency') || 'USDC').toUpperCase();
|
|
272
|
+
const amountHuman = opt(flags, '--amount');
|
|
273
|
+
const amountAtomic = amountHuman === undefined ? '0' : (toAtomicHuman(amountHuman, currency === 'ETH' ? 18 : 6) ?? '');
|
|
274
|
+
if (amountAtomic === '') {
|
|
275
|
+
return {
|
|
276
|
+
envelope: failEnvelope('INVALID_ARGUMENT', `--amount 不是合法正数 (收到 ${amountHuman}); 原子单位由本命令换算`, { amount: amountHuman, currency }, [], 'needs_human'),
|
|
277
|
+
human: WALLET_USAGE,
|
|
278
|
+
};
|
|
279
|
+
}
|
|
280
|
+
// 幂等键: 同一个 payload 派生出同一个 requestId (同一请求只签一次); 也可 --request-id 原样指定
|
|
281
|
+
const { createHash } = await import('crypto');
|
|
282
|
+
const requestId = flags.requestId || `wsig-${createHash('sha256').update(`${payload}|${capability}|${amountAtomic}`).digest('hex').slice(0, 16)}`;
|
|
283
|
+
// 本机策略 (只能收紧)
|
|
284
|
+
const pol = readSigningPolicy();
|
|
285
|
+
const cliPerTx = opt(flags, '--per-tx');
|
|
286
|
+
const cliDaily = opt(flags, '--daily');
|
|
287
|
+
const maxPerTxAtomic = minAtomic(pol.maxPerTxAtomic, cliPerTx && ATOMIC.test(cliPerTx) ? cliPerTx : undefined);
|
|
288
|
+
const dailyLimitAtomic = minAtomic(pol.dailyLimitAtomic, cliDaily && ATOMIC.test(cliDaily) ? cliDaily : undefined);
|
|
289
|
+
// 今日已签额度 + 已签过的 requestId (都来自审计账本: 只记摘要, 无正文)
|
|
290
|
+
const audit = await readSignatureAudit(undefined, 1000);
|
|
291
|
+
const todayStart = new Date();
|
|
292
|
+
todayStart.setHours(0, 0, 0, 0);
|
|
293
|
+
const today = audit.filter((a) => a.at >= todayStart.getTime());
|
|
294
|
+
const spentTodayAtomic = today.reduce((acc, a) => acc + (/^[0-9]+$/.test(String(a.amountAtomic || '')) ? BigInt(String(a.amountAtomic)) : 0n), 0n).toString();
|
|
295
|
+
const signedRequestIds = audit.map((a) => a.requestId);
|
|
296
|
+
const w = readWallet();
|
|
297
|
+
const decision = authorizeWalletSignature({
|
|
298
|
+
mode: modeRaw,
|
|
299
|
+
agentAuthorized: pol.agentAuthorized,
|
|
300
|
+
walletAvailable: w.present && w.hasKey,
|
|
301
|
+
network,
|
|
302
|
+
capability,
|
|
303
|
+
requestId,
|
|
304
|
+
amountAtomic,
|
|
305
|
+
allowedNetworks: pol.allowedNetworks,
|
|
306
|
+
allowedCapabilities: pol.allowedCapabilities,
|
|
307
|
+
maxPerTxAtomic,
|
|
308
|
+
dailyLimitAtomic,
|
|
309
|
+
spentTodayAtomic,
|
|
310
|
+
signedRequestIds,
|
|
311
|
+
});
|
|
312
|
+
if (!decision.allowed) {
|
|
313
|
+
// fail-closed: 逐条映射成 §3 的码 (绝不"再试一次就放行")
|
|
314
|
+
const c = decision.checks;
|
|
315
|
+
let code = 'POLICY_DENIED';
|
|
316
|
+
let next = 'needs_human';
|
|
317
|
+
if (c.agentAuthorized === false) {
|
|
318
|
+
code = 'AGENT_NOT_AUTHORIZED';
|
|
319
|
+
next = 'needs_human';
|
|
320
|
+
}
|
|
321
|
+
else if (c.walletAvailable === false) {
|
|
322
|
+
code = 'WALLET_UNAVAILABLE';
|
|
323
|
+
next = 'needs_human';
|
|
324
|
+
}
|
|
325
|
+
else if (c.notDuplicate === false) {
|
|
326
|
+
code = 'DUPLICATE_REQUEST';
|
|
327
|
+
next = null;
|
|
328
|
+
}
|
|
329
|
+
else if (c.modeIsAutonomous === false) {
|
|
330
|
+
code = 'PAYMENT_REQUIRED';
|
|
331
|
+
next = 'approve_payment';
|
|
332
|
+
}
|
|
333
|
+
else if (c.amountIsInteger === false || c.underPerTx === false || c.underDaily === false) {
|
|
334
|
+
code = 'BUDGET_EXCEEDED';
|
|
335
|
+
next = 'raise_budget';
|
|
336
|
+
}
|
|
337
|
+
else {
|
|
338
|
+
code = 'POLICY_DENIED';
|
|
339
|
+
next = 'needs_human';
|
|
340
|
+
}
|
|
341
|
+
const data = {
|
|
342
|
+
allowed: false, reason: decision.reason, checks: decision.checks,
|
|
343
|
+
requestId, mode: modeRaw, capability, network, amountAtomic,
|
|
344
|
+
walletAvailable: w.present && w.hasKey, walletFile: '~/.bolloon/wallet.json',
|
|
345
|
+
signingPolicy: { agentAuthorized: pol.agentAuthorized, source: pol.source, allowedNetworks: pol.allowedNetworks ?? null, allowedCapabilities: pol.allowedCapabilities ?? null, maxPerTxAtomic: maxPerTxAtomic ?? null, dailyLimitAtomic: dailyLimitAtomic ?? null },
|
|
346
|
+
spentTodayAtomic,
|
|
347
|
+
signed: false, privateKeyPrinted: false, paid: false,
|
|
348
|
+
note: '放行闸拒绝: 没有签名、没有写审计、没有付款 (fail-closed)',
|
|
349
|
+
};
|
|
350
|
+
const howTo = {
|
|
351
|
+
AGENT_NOT_AUTHORIZED: '要让本机 Agent 自主签名: 写 ~/.bolloon/signing-policy.json {"agentAuthorized":true,...} 或设 BOLLOON_AGENT_AUTHORIZED=1',
|
|
352
|
+
WALLET_UNAVAILABLE: '本机还没有钱包 (~/.bolloon/wallet.json; 首次 x402 付款时自动生成)',
|
|
353
|
+
DUPLICATE_REQUEST: `这个 requestId 已经签过了 (${requestId}); 同一请求只签一次`,
|
|
354
|
+
PAYMENT_REQUIRED: 'manual/policy 模式要人工放行, 不走自主签名闸',
|
|
355
|
+
BUDGET_EXCEEDED: '降金额, 或放宽 --per-tx/--daily 与策略文件里的额度',
|
|
356
|
+
POLICY_DENIED: '网络/能力不在允许列表 (signing-policy.json 的 allowedNetworks/allowedCapabilities)',
|
|
357
|
+
};
|
|
358
|
+
return {
|
|
359
|
+
envelope: failEnvelope(code, decision.reason || '拒绝签名', data, [requestId], next),
|
|
360
|
+
human: [
|
|
361
|
+
title('bolloon wallet sign'),
|
|
362
|
+
line('结果', `拒绝 (${code})`),
|
|
363
|
+
line('原因', decision.reason || ''),
|
|
364
|
+
line('逐条检查', Object.entries(decision.checks).map(([k, v]) => `${k}=${v ? '✓' : '✗'}`).join(' ')),
|
|
365
|
+
line('怎么放行', howTo[code] || '(见 checks)'),
|
|
366
|
+
line('本次签名', '没有'),
|
|
367
|
+
line('本次付款', '没有'),
|
|
368
|
+
].join('\n'),
|
|
369
|
+
};
|
|
370
|
+
}
|
|
371
|
+
// ★ 真签名: 私钥只在这几行里存在 (局部变量, 不返回、不打印、不落盘)
|
|
372
|
+
let signature = '';
|
|
373
|
+
let address = String(w.address || '');
|
|
374
|
+
let selfVerified = null;
|
|
375
|
+
let signError = null;
|
|
376
|
+
try {
|
|
377
|
+
const { privateKeyToAccount } = await import('viem/accounts');
|
|
378
|
+
const raw = JSON.parse(fs.readFileSync(walletFile(), 'utf8'));
|
|
379
|
+
const pk = String(raw?.privateKey || '');
|
|
380
|
+
if (!/^0x[0-9a-fA-F]{64}$/.test(pk))
|
|
381
|
+
throw new Error('wallet.json 里没有可用的私钥字段 (格式不对)');
|
|
382
|
+
const account = privateKeyToAccount(pk);
|
|
383
|
+
address = account.address;
|
|
384
|
+
signature = await account.signMessage({ message: payload });
|
|
385
|
+
// 现场自检: 用地址把签名验回来 (签名不是"打印出来就算数")
|
|
386
|
+
const viem = await import('viem');
|
|
387
|
+
selfVerified = await viem.verifyMessage({ address: account.address, message: payload, signature });
|
|
388
|
+
}
|
|
389
|
+
catch (e) {
|
|
390
|
+
signError = String(e?.message || e).slice(0, 200);
|
|
391
|
+
}
|
|
392
|
+
if (!signature) {
|
|
393
|
+
return {
|
|
394
|
+
envelope: failEnvelope('WALLET_UNAVAILABLE', `钱包签名失败: ${signError}`, { allowed: true, checks: decision.checks, requestId, walletFile: '~/.bolloon/wallet.json', privateKeyPrinted: false, signature: null, paid: false }, [requestId], 'needs_human'),
|
|
395
|
+
human: `${title('bolloon wallet sign')}\n 签名失败: ${signError}\n (放行闸过了, 但钱包模块没签出来 —— 如实报, 没有假装签过)`,
|
|
396
|
+
};
|
|
397
|
+
}
|
|
398
|
+
// ★ 审计: 只记摘要 (payload 只记 sha256; 绝不记正文/私钥)
|
|
399
|
+
const digest = payloadDigest(payload);
|
|
400
|
+
await recordSignatureAudit({
|
|
401
|
+
kind: 'wallet_payload',
|
|
402
|
+
mode: modeRaw,
|
|
403
|
+
requestId,
|
|
404
|
+
taskId: opt(flags, '--task-id'),
|
|
405
|
+
amountAtomic,
|
|
406
|
+
currency,
|
|
407
|
+
network,
|
|
408
|
+
capability,
|
|
409
|
+
signerFingerprint: fingerprintOf(address),
|
|
410
|
+
payloadDigest: digest,
|
|
411
|
+
});
|
|
412
|
+
const data = {
|
|
413
|
+
allowed: true,
|
|
414
|
+
checks: decision.checks,
|
|
415
|
+
requestId,
|
|
416
|
+
mode: modeRaw,
|
|
417
|
+
capability,
|
|
418
|
+
network,
|
|
419
|
+
amountAtomic,
|
|
420
|
+
spentTodayAtomic: (BigInt(spentTodayAtomic) + BigInt(amountAtomic)).toString(),
|
|
421
|
+
address, // 公开地址
|
|
422
|
+
signerFingerprint: fingerprintOf(address),
|
|
423
|
+
signature, // 签名 (公开材料)
|
|
424
|
+
algorithm: 'eip191 personal_sign (viem/accounts)',
|
|
425
|
+
payloadDigest: digest,
|
|
426
|
+
payloadBytes: Buffer.byteLength(payload, 'utf8'),
|
|
427
|
+
payloadPrinted: false, // 正文不输出 (只给摘要)
|
|
428
|
+
selfVerified,
|
|
429
|
+
auditFile: '~/.bolloon/wallet-signatures.jsonl',
|
|
430
|
+
privateKeyPrinted: false,
|
|
431
|
+
paid: false,
|
|
432
|
+
note: '只签名, 不放钱; 私钥全程只在本机进程的局部变量里',
|
|
433
|
+
};
|
|
434
|
+
return {
|
|
435
|
+
envelope: okEnvelope('WALLET_SIGNED', `已用本机钱包签名 (${String(address).slice(0, 10)}…, 自检=${selfVerified === true ? '通过' : '未通过'})`, data, [requestId, String(address), `payload:${digest.slice(0, 16)}`], null),
|
|
436
|
+
human: [
|
|
437
|
+
title('bolloon wallet sign'),
|
|
438
|
+
line('结果', '已签名 (放行闸 9 条全过)'),
|
|
439
|
+
line('地址', address),
|
|
440
|
+
line('指纹', fingerprintOf(address)),
|
|
441
|
+
line('算法', 'eip191 personal_sign'),
|
|
442
|
+
line('签名', `${signature.slice(0, 24)}… (${signature.length} 字符)`),
|
|
443
|
+
line('payload 摘要', `${digest} (${Buffer.byteLength(payload, 'utf8')} 字节; 正文未输出)`),
|
|
444
|
+
line('自检验签', selfVerified === true ? '通过 (用地址验回来了)' : '未通过'),
|
|
445
|
+
line('审计', '已写 ~/.bolloon/wallet-signatures.jsonl (只记摘要)'),
|
|
446
|
+
line('私钥', '未输出 (绝不进 stdout/日志)'),
|
|
447
|
+
line('本次付款', '没有 (只签名)'),
|
|
448
|
+
].join('\n'),
|
|
449
|
+
};
|
|
450
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* bridge.ts — P4 MCP 适配层的**唯一**调用通道
|
|
3
|
+
*
|
|
4
|
+
* 硬约束 (leo 2026-09-21 / `docs/wiki/agent-access-layer.md` §1):
|
|
5
|
+
* **MCP 不复制业务逻辑。** 所有 tool / resource 都只有一条路:
|
|
6
|
+
* 构造 P3 命令的 argv → `parseFlags` → `GROUP_COMMANDS[group]` (P3 命令函数) → 信封原样返回
|
|
7
|
+
* 这里**没有**任何业务判断 (不判断成败、不算钱、不发付款、不写交易记录)。
|
|
8
|
+
*
|
|
9
|
+
* 三条映射规矩:
|
|
10
|
+
* ① 信封**原样**返回: `{ok, code, message, data, evidence, next_action}` 一个字段都不改
|
|
11
|
+
* (`ok:false` 不许被 MCP 层改写成成功 —— 失败也走 MCP 的 isError=true, 见 server.ts)
|
|
12
|
+
* ② 输出前过 H 层防护: 私钥/助记词类字段一律剥离 (P1 §5.5) + 付款回执原文一律剥离 (§5.6 私有层)
|
|
13
|
+
* ③ 超时与异常兜底**复用** P3 的 `commandResult` (同一条实现, 不许第二套语义)
|
|
14
|
+
*/
|
|
15
|
+
import { parseFlags, commandResult, finalizeEnvelope, redactSecrets, } from '../protocol-envelope.js';
|
|
16
|
+
import { GROUP_COMMANDS } from '../commands/index.js';
|
|
17
|
+
/**
|
|
18
|
+
* 付款回执 / 原始 HTTP 回执字段: **绝不出本机** (P1 §5.6 私有层)。
|
|
19
|
+
* P3 的信封今天本来就不带它们 (例如 trade show 只给 `paymentReceiptPresent: boolean`),
|
|
20
|
+
* 这里是**兜底**: 万一将来某个服务把原文塞进了 data, MCP 层也必须在出口拦掉。
|
|
21
|
+
*/
|
|
22
|
+
const RECEIPT_FIELDS = ['paymentReceipt', 'paymentReceiptRaw', 'rawReceipt', 'rawHeader', 'receiptRaw'];
|
|
23
|
+
/** 允许出现在 tool 入参里的时间上限 (防止把一个数字写成天荒地老) */
|
|
24
|
+
const MAX_TIMEOUT_MS = 30 * 60 * 1000;
|
|
25
|
+
export function defaultTimeoutMs() {
|
|
26
|
+
const raw = process.env.BOLLOON_MCP_TIMEOUT_MS;
|
|
27
|
+
if (!raw)
|
|
28
|
+
return undefined;
|
|
29
|
+
const n = Number(raw);
|
|
30
|
+
return Number.isFinite(n) && n > 0 ? Math.min(Math.floor(n), MAX_TIMEOUT_MS) : undefined;
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* 调一次 P3 命令组, 拿**原样**信封 (只做出口防护, 不做任何解释)。
|
|
34
|
+
* 注意: 这里绝不写 stdout —— MCP 的 stdout 是 JSON-RPC 流 (`commandResult` 不打印)。
|
|
35
|
+
*/
|
|
36
|
+
export async function callP3(call) {
|
|
37
|
+
const flags = parseFlags(call.argv);
|
|
38
|
+
const timeoutMs = call.opts?.timeoutMs ?? defaultTimeoutMs();
|
|
39
|
+
if (timeoutMs)
|
|
40
|
+
flags.timeoutMs = timeoutMs;
|
|
41
|
+
if (call.opts?.requestId)
|
|
42
|
+
flags.requestId = call.opts.requestId;
|
|
43
|
+
const fn = GROUP_COMMANDS[call.group];
|
|
44
|
+
if (!fn) {
|
|
45
|
+
// 不可能发生 (group 由 tools.ts 写死); 但绝不静默
|
|
46
|
+
return {
|
|
47
|
+
ok: false,
|
|
48
|
+
code: 'INTERNAL_ERROR',
|
|
49
|
+
message: `MCP 适配层不知道命令组 '${String(call.group)}'`,
|
|
50
|
+
data: { group: String(call.group) },
|
|
51
|
+
evidence: [],
|
|
52
|
+
next_action: 'needs_human',
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
// ★ 唯一调用点: P3 命令函数 (薄包装; 超时/异常兜底也走 P3 的 commandResult)
|
|
56
|
+
const result = await commandResult(flags, fn);
|
|
57
|
+
// 收敛成冻结字段顺序 + 私钥类字段剥离 (finalizeEnvelope 与 CLI --json 是同一个出口)
|
|
58
|
+
return mcpSafeEnvelope(finalizeEnvelope(result.envelope, flags));
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* 出口防护 (MCP 特有的**加严**, 不改 P3 语义):
|
|
62
|
+
* · 整封 (含 evidence) 深扫 `AUDIT_FORBIDDEN_KEYS`: privateKey / secret / instruction / taskText / mnemonic / seed
|
|
63
|
+
* → 命中即替成 `[redacted]` 并在 data 里记 `redacted_fields` (不静默)
|
|
64
|
+
* · 付款回执原文类字段 → 替成 `[stripped]` 并记 `receipt_stripped: true`
|
|
65
|
+
* 注意: 这里只**删减**输出, 从不改 `ok`/`code`/`next_action` (不许把失败洗成成功)。
|
|
66
|
+
*/
|
|
67
|
+
export function mcpSafeEnvelope(env) {
|
|
68
|
+
const hits = [];
|
|
69
|
+
const redacted = redactSecrets(env, hits);
|
|
70
|
+
const stripped = [];
|
|
71
|
+
const data = stripReceipts(redacted.data, stripped, '$') || {};
|
|
72
|
+
const out = {
|
|
73
|
+
ok: env.ok,
|
|
74
|
+
code: env.code,
|
|
75
|
+
message: env.message,
|
|
76
|
+
data,
|
|
77
|
+
evidence: Array.isArray(redacted.evidence) ? redacted.evidence : [],
|
|
78
|
+
next_action: env.next_action ?? null,
|
|
79
|
+
};
|
|
80
|
+
if (hits.length)
|
|
81
|
+
out.data.redacted_fields = hits;
|
|
82
|
+
if (stripped.length) {
|
|
83
|
+
out.data.receipt_stripped = true;
|
|
84
|
+
out.data.receipt_stripped_fields = stripped;
|
|
85
|
+
out.data.receipt_note = '付款回执原文属于私有层 (P1 §5.6), 绝不出本机 —— MCP 出口已剥离';
|
|
86
|
+
}
|
|
87
|
+
return out;
|
|
88
|
+
}
|
|
89
|
+
function stripReceipts(value, hits, path) {
|
|
90
|
+
if (Array.isArray(value))
|
|
91
|
+
return value.map((v, i) => stripReceipts(v, hits, `${path}[${i}]`));
|
|
92
|
+
if (value && typeof value === 'object') {
|
|
93
|
+
const out = {};
|
|
94
|
+
for (const [k, v] of Object.entries(value)) {
|
|
95
|
+
if (RECEIPT_FIELDS.includes(k) && v !== null && v !== undefined) {
|
|
96
|
+
hits.push(`${path}.${k}`);
|
|
97
|
+
out[k] = '[stripped]';
|
|
98
|
+
continue;
|
|
99
|
+
}
|
|
100
|
+
out[k] = stripReceipts(v, hits, `${path}.${k}`);
|
|
101
|
+
}
|
|
102
|
+
return out;
|
|
103
|
+
}
|
|
104
|
+
return value;
|
|
105
|
+
}
|