@bolloon/bolloon-agent 0.4.29 → 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/contacts/chain.js +579 -0
- package/dist/agents/contacts/cli.js +158 -0
- package/dist/agents/contacts/consent.js +124 -0
- package/dist/agents/contacts/grant-payload.js +45 -0
- package/dist/agents/contacts/grants.js +451 -0
- package/dist/agents/contacts/policy.js +264 -0
- package/dist/agents/contacts/preview-types.js +6 -0
- package/dist/agents/contacts/providers.js +341 -0
- package/dist/agents/contacts/store.js +326 -0
- package/dist/agents/contacts/tools.js +154 -0
- package/dist/agents/contacts/types.js +148 -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/pi-sdk.js +10 -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/contacts/chain.js +619 -0
- package/dist/electron-build/agents/contacts/chain.js.map +1 -0
- package/dist/electron-build/agents/contacts/consent.js +163 -0
- package/dist/electron-build/agents/contacts/consent.js.map +1 -0
- package/dist/electron-build/agents/contacts/grant-payload.js +52 -0
- package/dist/electron-build/agents/contacts/grant-payload.js.map +1 -0
- package/dist/electron-build/agents/contacts/grants.js +500 -0
- package/dist/electron-build/agents/contacts/grants.js.map +1 -0
- package/dist/electron-build/agents/contacts/policy.js +271 -0
- package/dist/electron-build/agents/contacts/policy.js.map +1 -0
- package/dist/electron-build/agents/contacts/preview-types.js +8 -0
- package/dist/electron-build/agents/contacts/preview-types.js.map +1 -0
- package/dist/electron-build/agents/contacts/providers.js +384 -0
- package/dist/electron-build/agents/contacts/providers.js.map +1 -0
- package/dist/electron-build/agents/contacts/store.js +370 -0
- package/dist/electron-build/agents/contacts/store.js.map +1 -0
- package/dist/electron-build/agents/contacts/tools.js +159 -0
- package/dist/electron-build/agents/contacts/tools.js.map +1 -0
- package/dist/electron-build/agents/contacts/types.js +166 -0
- package/dist/electron-build/agents/contacts/types.js.map +1 -0
- 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/external-events.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/goal-store.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/pi-sdk.js +10 -0
- package/dist/electron-build/agents/pi-sdk.js.map +1 -1
- 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/electron-build/security/tool-gate.js +4 -0
- package/dist/electron-build/security/tool-gate.js.map +1 -1
- package/dist/index.js +30 -0
- package/dist/security/tool-gate.js +4 -0
- package/dist/web/mobile-contacts.js +329 -0
- package/dist/web/mobile-core.js +481 -15
- package/dist/web/mobile.html +32 -0
- package/dist/web/mobile.js +139 -0
- package/dist/web/routes-contacts.js +420 -0
- package/dist/web/server.js +34 -0
- package/dist/web/task-frame-server.js +211 -0
- package/package.json +1 -1
|
@@ -0,0 +1,322 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* task-contract.ts — bolloon-task/1 任务协议 (Phase 1)
|
|
3
|
+
*
|
|
4
|
+
* 把「任务委派」与「交易协议」统一起来: agent_delegate 是快速委派, 这里给出**标准化任务生命周期**。
|
|
5
|
+
* 设计要点 (leo 2026-09-21 计划 + 支付规则修正):
|
|
6
|
+
* · 任务状态与支付事实**严格分离** —— "已付款" 永远不等于 "任务成功"
|
|
7
|
+
* · requestId 幂等; taskId 生命周期; capability 匹配; budget 约束; signature 证明来源
|
|
8
|
+
* · 任务正文只走 P2P / 受保护链路, **绝不进公开脉冲**
|
|
9
|
+
* · 支付模式: manual | policy | autonomous | **agent-authorized** (受控自主签名)
|
|
10
|
+
* · 私钥只在**本机进程**内可用: 公共网页 / P2P 消息 / Pulse / 公开交易记录永远拿不到;
|
|
11
|
+
* 每次签名写审计账本; 越权网络 / 越额 / 重复 requestId 一律拒绝
|
|
12
|
+
*
|
|
13
|
+
* 本文件是纯契约层 (类型 + 状态机 + 校验 + 签名 + 审计 + 公开投影), 不做 IO 编排。
|
|
14
|
+
*/
|
|
15
|
+
import * as fs from 'fs';
|
|
16
|
+
import * as os from 'os';
|
|
17
|
+
import * as path from 'path';
|
|
18
|
+
import * as crypto from 'crypto';
|
|
19
|
+
import { canonicalize } from './network-pulse.js';
|
|
20
|
+
export const TASK_PROTOCOL = 'bolloon-task/1';
|
|
21
|
+
// ── 任务状态机 ──────────────────────────────────────────────────────────────
|
|
22
|
+
export const TASK_STATES = [
|
|
23
|
+
'discovered', // 发现候选
|
|
24
|
+
'quoted', // 已报价
|
|
25
|
+
'policy_denied', // 策略拒绝 (终态)
|
|
26
|
+
'submitted', // 已签名发出
|
|
27
|
+
'accepted', // 对方接受
|
|
28
|
+
'payment_required', // 等付款
|
|
29
|
+
'paying', // 付款中
|
|
30
|
+
'paid', // 付款已提交 (≠ 成功)
|
|
31
|
+
'running', // 对方执行中
|
|
32
|
+
'delivered', // 已交付
|
|
33
|
+
'verified', // 已验真 (终态)
|
|
34
|
+
'rejected', // 对方拒绝 (终态)
|
|
35
|
+
'failed', // 失败 (终态)
|
|
36
|
+
'cancelled', // 取消 (终态)
|
|
37
|
+
];
|
|
38
|
+
/** 合法迁移图 (非法迁移一律**拒绝**, 不静默修正) */
|
|
39
|
+
export const TASK_TRANSITIONS = {
|
|
40
|
+
discovered: ['quoted', 'policy_denied', 'cancelled'],
|
|
41
|
+
quoted: ['submitted', 'policy_denied', 'cancelled'],
|
|
42
|
+
policy_denied: [],
|
|
43
|
+
submitted: ['accepted', 'rejected', 'payment_required', 'failed', 'cancelled'],
|
|
44
|
+
accepted: ['payment_required', 'paying', 'running', 'rejected', 'failed', 'cancelled'],
|
|
45
|
+
payment_required: ['paying', 'cancelled', 'failed'],
|
|
46
|
+
paying: ['paid', 'payment_required', 'failed'], // 不确定 → 回到 payment_required 等对账 (不许当失败)
|
|
47
|
+
paid: ['running', 'delivered', 'failed'],
|
|
48
|
+
running: ['delivered', 'failed'],
|
|
49
|
+
delivered: ['verified', 'failed'], // 交付后必须验真才算成功
|
|
50
|
+
verified: [],
|
|
51
|
+
rejected: [],
|
|
52
|
+
failed: [],
|
|
53
|
+
cancelled: [],
|
|
54
|
+
};
|
|
55
|
+
export const TASK_TERMINAL_STATES = ['policy_denied', 'verified', 'rejected', 'failed', 'cancelled'];
|
|
56
|
+
export function isTaskState(v) {
|
|
57
|
+
return typeof v === 'string' && TASK_STATES.includes(v);
|
|
58
|
+
}
|
|
59
|
+
export function isTaskTerminal(s) {
|
|
60
|
+
return TASK_TERMINAL_STATES.includes(s);
|
|
61
|
+
}
|
|
62
|
+
export function canTransitionTask(from, to) {
|
|
63
|
+
return Array.isArray(TASK_TRANSITIONS[from]) && TASK_TRANSITIONS[from].includes(to);
|
|
64
|
+
}
|
|
65
|
+
export function checkTaskMove(from, to) {
|
|
66
|
+
if (!isTaskState(from) || !isTaskState(to))
|
|
67
|
+
return { ok: false, reason: `未知任务状态: ${String(from)} → ${String(to)}` };
|
|
68
|
+
if (from === to)
|
|
69
|
+
return { ok: true };
|
|
70
|
+
if (!canTransitionTask(from, to)) {
|
|
71
|
+
return { ok: false, reason: `非法任务迁移 ${from} → ${to} (拒绝, 不静默修正)` };
|
|
72
|
+
}
|
|
73
|
+
return { ok: true };
|
|
74
|
+
}
|
|
75
|
+
// ── 支付事实 (与任务状态分离) ───────────────────────────────────────────────
|
|
76
|
+
/** 与 `x402/settlement-state.ts` 的两层事实保持同一套取值 (单测里有对齐断言) */
|
|
77
|
+
export const TASK_PAYMENT_FACTS = [
|
|
78
|
+
'unpaid', 'payment_submitted', 'payment_verified', 'partially_settled',
|
|
79
|
+
'fully_settled', 'refund_pending', 'refunded', 'unknown',
|
|
80
|
+
];
|
|
81
|
+
export function isPaymentFact(v) {
|
|
82
|
+
return typeof v === 'string' && TASK_PAYMENT_FACTS.includes(v);
|
|
83
|
+
}
|
|
84
|
+
/** local-dev 能到哪一步 (与 x402 红线一致: 永不 fully_settled) */
|
|
85
|
+
export function maxFactForMode(mode) {
|
|
86
|
+
// 与 settlement-state 的 CHAIN_BACKED_FACTS / LOCAL_DEV_MAX_FACT 对齐 (单测有断言)
|
|
87
|
+
return mode === 'facilitator' ? 'fully_settled' : mode === 'local-dev' ? 'payment_submitted' : 'unpaid';
|
|
88
|
+
}
|
|
89
|
+
/** "成功" 的唯一判据: 任务 verified ∧ 支付事实到链上口径 */
|
|
90
|
+
export function isTaskSuccessful(task) {
|
|
91
|
+
return task.state === 'verified'
|
|
92
|
+
&& (task.paymentFact === 'fully_settled' || task.paymentFact === 'payment_verified');
|
|
93
|
+
}
|
|
94
|
+
// ── 支付模式 (含 leo 修正的 agent-authorized) ───────────────────────────────
|
|
95
|
+
export const PAYMENT_MODES = ['manual', 'policy', 'autonomous', 'agent-authorized'];
|
|
96
|
+
export function isPaymentMode(v) {
|
|
97
|
+
return typeof v === 'string' && PAYMENT_MODES.includes(v);
|
|
98
|
+
}
|
|
99
|
+
// ── 幂等 id ─────────────────────────────────────────────────────────────────
|
|
100
|
+
function sha256Hex(s) {
|
|
101
|
+
return crypto.createHash('sha256').update(s).digest('hex');
|
|
102
|
+
}
|
|
103
|
+
/** 由 (任务正文, capability, 买方) 确定性派生 requestId —— 重发不会产生第二笔付款 */
|
|
104
|
+
export function taskRequestId(input) {
|
|
105
|
+
const h = sha256Hex(`${input.instruction.trim()}|${input.capability}|${input.buyerDid}|${input.salt || ''}`);
|
|
106
|
+
return `treq-${h.slice(0, 16)}`;
|
|
107
|
+
}
|
|
108
|
+
export function newTaskId(now = Date.now()) {
|
|
109
|
+
return `task-${now.toString(36)}-${crypto.randomBytes(3).toString('hex')}`;
|
|
110
|
+
}
|
|
111
|
+
// ── 校验 ────────────────────────────────────────────────────────────────────
|
|
112
|
+
const ATOMIC_RE = /^[0-9]+$/; // 原子单位必须是正整数 (浮点/负数/空一律拒)
|
|
113
|
+
export function validateTaskRequest(req, opts = {}) {
|
|
114
|
+
const issues = [];
|
|
115
|
+
const now = opts.now ?? Date.now();
|
|
116
|
+
if (req?.protocol !== TASK_PROTOCOL)
|
|
117
|
+
issues.push(`协议版本不对: ${String(req?.protocol)} (要 ${TASK_PROTOCOL})`);
|
|
118
|
+
for (const k of ['taskId', 'requestId', 'capability', 'instruction', 'buyerDid', 'providerDid']) {
|
|
119
|
+
if (!String(req?.[k] || '').trim())
|
|
120
|
+
issues.push(`缺字段 ${k}`);
|
|
121
|
+
}
|
|
122
|
+
if (!isPaymentMode(req?.paymentMode))
|
|
123
|
+
issues.push(`支付模式非法: ${String(req?.paymentMode)}`);
|
|
124
|
+
if (req?.budget) {
|
|
125
|
+
const b = req.budget;
|
|
126
|
+
if (!ATOMIC_RE.test(String(b.maxAmount ?? '')))
|
|
127
|
+
issues.push(`budget.maxAmount 必须是正整数原子单位字符串 (收到 ${String(b.maxAmount)})`);
|
|
128
|
+
if (b.currency !== 'USDC' && b.currency !== 'ETH')
|
|
129
|
+
issues.push(`budget.currency 只支持 USDC/ETH (收到 ${String(b.currency)})`);
|
|
130
|
+
if (!String(b.network || '').trim())
|
|
131
|
+
issues.push('budget.network 必填');
|
|
132
|
+
if (opts.allowedNetworks?.length && !opts.allowedNetworks.includes(String(b.network)))
|
|
133
|
+
issues.push(`网络不在允许列表: ${String(b.network)}`);
|
|
134
|
+
if (opts.maxAmountAtomic && ATOMIC_RE.test(String(b.maxAmount ?? '')) && BigInt(b.maxAmount) > BigInt(opts.maxAmountAtomic)) {
|
|
135
|
+
issues.push(`预算超过单笔上限: ${b.maxAmount} > ${opts.maxAmountAtomic}`);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
if (req?.deadline !== undefined) {
|
|
139
|
+
if (!Number.isFinite(req.deadline))
|
|
140
|
+
issues.push('deadline 非法');
|
|
141
|
+
else if (req.deadline <= now)
|
|
142
|
+
issues.push(`deadline 已过期 (${req.deadline} <= ${now})`);
|
|
143
|
+
}
|
|
144
|
+
if (!String(req?.signature || '').trim())
|
|
145
|
+
issues.push('缺签名 (任务必须由发送方签名)');
|
|
146
|
+
// 时钟漂移 (防重放): 只看 deadline 是否荒谬地久远
|
|
147
|
+
const maxSkew = opts.maxSkewMs ?? 24 * 60 * 60 * 1000;
|
|
148
|
+
if (req?.deadline !== undefined && req.deadline > now + maxSkew * 30)
|
|
149
|
+
issues.push('deadline 过远 (疑似伪造)');
|
|
150
|
+
return { ok: issues.length === 0, issues };
|
|
151
|
+
}
|
|
152
|
+
/** 报价与请求是否自洽 (agent 不得接受被篡改的报价) */
|
|
153
|
+
export function validateQuoteAgainstRequest(quote, req, opts = {}) {
|
|
154
|
+
const issues = [];
|
|
155
|
+
if (quote?.protocol !== TASK_PROTOCOL)
|
|
156
|
+
issues.push(`报价协议版本不对: ${String(quote?.protocol)}`);
|
|
157
|
+
if (!quote?.taskId || quote.taskId !== req?.taskId)
|
|
158
|
+
issues.push('报价的 taskId 与请求不一致 (可能被篡改)');
|
|
159
|
+
if (!quote?.requestId || quote.requestId !== req?.requestId)
|
|
160
|
+
issues.push('报价的 requestId 与请求不一致 (回执不能跨请求复用)');
|
|
161
|
+
if (quote?.capability !== req?.capability)
|
|
162
|
+
issues.push(`报价能力与请求不一致: ${String(quote?.capability)} ≠ ${String(req?.capability)}`);
|
|
163
|
+
if (!ATOMIC_RE.test(String(quote?.amount ?? '')))
|
|
164
|
+
issues.push(`报价金额必须是正整数原子单位字符串 (收到 ${String(quote?.amount)})`);
|
|
165
|
+
if (req?.budget) {
|
|
166
|
+
if (quote?.currency !== req.budget.currency)
|
|
167
|
+
issues.push('报价币种与预算不一致');
|
|
168
|
+
if (quote?.network !== req.budget.network)
|
|
169
|
+
issues.push('报价网络与预算不一致');
|
|
170
|
+
if (ATOMIC_RE.test(String(quote?.amount ?? '')) && BigInt(String(quote?.amount)) > BigInt(req.budget.maxAmount)) {
|
|
171
|
+
issues.push(`报价超过任务预算: ${quote.amount} > ${req.budget.maxAmount}`);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
if (opts.maxAmountAtomic && ATOMIC_RE.test(String(quote?.amount ?? '')) && BigInt(String(quote?.amount)) > BigInt(opts.maxAmountAtomic)) {
|
|
175
|
+
issues.push(`报价超过单笔上限: ${quote.amount} > ${opts.maxAmountAtomic}`);
|
|
176
|
+
}
|
|
177
|
+
return { ok: issues.length === 0, issues };
|
|
178
|
+
}
|
|
179
|
+
/** 接收方去重: 同一个 requestId 只接受一次 (重复请求不得重复执行/重复收费) */
|
|
180
|
+
export function dedupeInbox(inbox, incoming) {
|
|
181
|
+
const hit = inbox.find((x) => x.requestId === incoming.requestId);
|
|
182
|
+
return hit ? { dup: true, reason: `requestId ${incoming.requestId} 已在收件箱 (幂等: 不重复接受)` } : { dup: false };
|
|
183
|
+
}
|
|
184
|
+
// ── 签名 (受控自主签名: 私钥只在本机进程) ───────────────────────────────────
|
|
185
|
+
/** 签名覆盖内容: 去掉 signature 字段后的规范化 JSON */
|
|
186
|
+
export function signablePayload(payload) {
|
|
187
|
+
const { signature, ...rest } = payload;
|
|
188
|
+
void signature;
|
|
189
|
+
return canonicalize(rest);
|
|
190
|
+
}
|
|
191
|
+
export async function signTaskEnvelope(payload, keypair) {
|
|
192
|
+
const { KeyManager } = await import('@diap/sdk');
|
|
193
|
+
// 与 src/network/agent-network.ts 一致: 签名输入是 Uint8Array
|
|
194
|
+
const bytes = new TextEncoder().encode(signablePayload(payload));
|
|
195
|
+
const sig = await KeyManager.sign(keypair, bytes);
|
|
196
|
+
return { ...payload, signature: typeof sig === 'string' ? sig : Buffer.from(sig).toString('base64') };
|
|
197
|
+
}
|
|
198
|
+
/**
|
|
199
|
+
* 信封里的签名是 **base64 字符串** (便于走 JSON/P2P), 但 @diap/sdk 的
|
|
200
|
+
* `KeyManager.verify` 要的是 64 字节 `Uint8Array` (ed25519) —— 必须解码回去。
|
|
201
|
+
* 同时容忍 128 位十六进制写法 (老格式)。
|
|
202
|
+
*/
|
|
203
|
+
export function decodeSignature(sig) {
|
|
204
|
+
if (/^[0-9a-f]{128}$/i.test(sig))
|
|
205
|
+
return new Uint8Array(Buffer.from(sig, 'hex'));
|
|
206
|
+
return new Uint8Array(Buffer.from(sig, 'base64'));
|
|
207
|
+
}
|
|
208
|
+
export async function verifyTaskEnvelope(payload, publicKey) {
|
|
209
|
+
try {
|
|
210
|
+
if (!payload?.signature)
|
|
211
|
+
return false;
|
|
212
|
+
const { KeyManager } = await import('@diap/sdk');
|
|
213
|
+
const bytes = new TextEncoder().encode(signablePayload(payload));
|
|
214
|
+
return await KeyManager.verify(publicKey, bytes, decodeSignature(payload.signature));
|
|
215
|
+
}
|
|
216
|
+
catch {
|
|
217
|
+
return false;
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
/**
|
|
221
|
+
* **受控自主签名** 的唯一放行判据。任何一条不满足 → 拒绝签名 (fail-closed)。
|
|
222
|
+
* 注意: 私钥永远不进这里 —— 这里只决定"允不允许让本机钱包模块去签"。
|
|
223
|
+
*/
|
|
224
|
+
export function authorizeWalletSignature(req) {
|
|
225
|
+
const checks = {
|
|
226
|
+
modeIsAutonomous: req.mode === 'autonomous' || req.mode === 'agent-authorized',
|
|
227
|
+
agentAuthorized: req.agentAuthorized === true,
|
|
228
|
+
walletAvailable: req.walletAvailable === true,
|
|
229
|
+
networkAllowed: !req.allowedNetworks?.length || req.allowedNetworks.includes(req.network),
|
|
230
|
+
capabilityAllowed: !req.allowedCapabilities?.length || req.allowedCapabilities.includes(req.capability),
|
|
231
|
+
underPerTx: !req.maxPerTxAtomic || (ATOMIC_RE.test(req.amountAtomic) && BigInt(req.amountAtomic) <= BigInt(req.maxPerTxAtomic)),
|
|
232
|
+
underDaily: !req.dailyLimitAtomic || BigInt(req.spentTodayAtomic || '0') + BigInt(ATOMIC_RE.test(req.amountAtomic) ? req.amountAtomic : '0') <= BigInt(req.dailyLimitAtomic),
|
|
233
|
+
notDuplicate: !(req.signedRequestIds || []).includes(req.requestId),
|
|
234
|
+
amountIsInteger: ATOMIC_RE.test(req.amountAtomic),
|
|
235
|
+
};
|
|
236
|
+
const failed = Object.entries(checks).filter(([, v]) => !v).map(([k]) => k);
|
|
237
|
+
if (failed.length)
|
|
238
|
+
return { allowed: false, reason: `拒绝签名: ${failed.join(', ')}`, checks };
|
|
239
|
+
return { allowed: true, checks };
|
|
240
|
+
}
|
|
241
|
+
const auditFile = (h) => path.join(h || process.env.HOME || os.homedir(), '.bolloon', 'wallet-signatures.jsonl');
|
|
242
|
+
export async function recordSignatureAudit(entry, h) {
|
|
243
|
+
const row = { at: Date.now(), payloadDigest: entry.payloadDigest || '', ...entry };
|
|
244
|
+
try {
|
|
245
|
+
fs.mkdirSync(path.dirname(auditFile(h)), { recursive: true });
|
|
246
|
+
fs.appendFileSync(auditFile(h), JSON.stringify(row) + '\n', 'utf8');
|
|
247
|
+
// ★ 公开观察层: 钱包签名是一次真实活动 → 计一次签名 (fire-and-forget, 只计数不记内容)
|
|
248
|
+
try {
|
|
249
|
+
const np = await import('./network-pulse.js');
|
|
250
|
+
void np.recordNetworkEvent({
|
|
251
|
+
type: 'wallet_signed',
|
|
252
|
+
did: entry.signerFingerprint,
|
|
253
|
+
agentId: entry.taskId || entry.requestId,
|
|
254
|
+
taskId: entry.taskId || entry.requestId,
|
|
255
|
+
signed: true,
|
|
256
|
+
}, h);
|
|
257
|
+
}
|
|
258
|
+
catch { /* noop */ }
|
|
259
|
+
}
|
|
260
|
+
catch { /* 审计写失败不该炸主流程, 但调用方应看到 */ }
|
|
261
|
+
}
|
|
262
|
+
export async function readSignatureAudit(h, limit = 50) {
|
|
263
|
+
try {
|
|
264
|
+
const lines = fs.readFileSync(auditFile(h), 'utf8').trim().split('\n').filter(Boolean);
|
|
265
|
+
return lines.slice(-limit).map((l) => JSON.parse(l));
|
|
266
|
+
}
|
|
267
|
+
catch {
|
|
268
|
+
return [];
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
/** 审计条目里绝不允许出现的东西 */
|
|
272
|
+
export const AUDIT_FORBIDDEN_KEYS = ['privateKey', 'secret', 'instruction', 'taskText', 'mnemonic', 'seed'];
|
|
273
|
+
export function assertAuditSafe(entry) {
|
|
274
|
+
const issues = [];
|
|
275
|
+
const walk = (v, p) => {
|
|
276
|
+
if (Array.isArray(v))
|
|
277
|
+
return v.forEach((x, i) => walk(x, `${p}[${i}]`));
|
|
278
|
+
if (v && typeof v === 'object') {
|
|
279
|
+
for (const [k, val] of Object.entries(v)) {
|
|
280
|
+
if (AUDIT_FORBIDDEN_KEYS.includes(k))
|
|
281
|
+
issues.push(`${p}.${k} 不该出现在签名审计里`);
|
|
282
|
+
walk(val, `${p}.${k}`);
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
};
|
|
286
|
+
walk(entry, '$');
|
|
287
|
+
return issues;
|
|
288
|
+
}
|
|
289
|
+
/** 金额只以**区间**对外 (不暴露精确金额) */
|
|
290
|
+
export function amountBucket(amountAtomic) {
|
|
291
|
+
if (!ATOMIC_RE.test(amountAtomic))
|
|
292
|
+
return 'tiny';
|
|
293
|
+
const n = Number(amountAtomic);
|
|
294
|
+
if (n < 10_000)
|
|
295
|
+
return 'tiny'; // < 0.01 USDC
|
|
296
|
+
if (n < 100_000)
|
|
297
|
+
return 'small'; // < 0.1
|
|
298
|
+
if (n < 1_000_000)
|
|
299
|
+
return 'medium'; // < 1
|
|
300
|
+
return 'large';
|
|
301
|
+
}
|
|
302
|
+
/**
|
|
303
|
+
* 任务的**公开摘要**: 只保留可公开的粗粒度事实。
|
|
304
|
+
* 绝不包含: DID / 任务正文 / 精确金额 / 交易哈希 / 私有 Agent 名。
|
|
305
|
+
*/
|
|
306
|
+
export function toPublicSummary(task, opts = {}) {
|
|
307
|
+
const group = opts.capabilityGroupOf ? opts.capabilityGroupOf(task.capability) : 'other';
|
|
308
|
+
const settlement = task.paymentFact === 'fully_settled' ? 'chain'
|
|
309
|
+
: task.paymentFact === 'payment_submitted' || task.paymentMode === 'local-dev' ? 'local-dev'
|
|
310
|
+
: 'none';
|
|
311
|
+
const kind = task.state === 'verified' ? 'trade_verified'
|
|
312
|
+
: task.state === 'delivered' ? 'task_completed'
|
|
313
|
+
: task.state === 'accepted' || task.state === 'running' ? 'task_accepted'
|
|
314
|
+
: 'task_posted';
|
|
315
|
+
return {
|
|
316
|
+
kind,
|
|
317
|
+
capabilityGroup: group,
|
|
318
|
+
...(task.amountAtomic ? { amountBucket: amountBucket(task.amountAtomic) } : {}),
|
|
319
|
+
settlement,
|
|
320
|
+
state: task.state,
|
|
321
|
+
};
|
|
322
|
+
}
|
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* task-inbox.ts — bolloon-task/1 收件箱 + 本机任务台账 + 结果落盘 (P3, 2026-09-21)
|
|
3
|
+
*
|
|
4
|
+
* 落盘布局 (全部在 `~/.bolloon/tasks/` 下, 与 M3.2 的 `<task-id>.yaml` 计划状态机同目录不冲突):
|
|
5
|
+
* inbox/<requestId>.json 收到的 TaskRequest (+ 本机的接单状态) —— 待处理收件箱
|
|
6
|
+
* local/<requestId>.json 本机**发出**的任务台账 (状态推进 / 对端回执 / 收到结果)
|
|
7
|
+
* results/<taskId>.json 已存的 TaskResult 信封 (内容哈希/CID + 签名)
|
|
8
|
+
* bodies/<taskId>.txt **交付正文** —— 私有一层: 只在本地落盘, 绝不进公开投影/stdout
|
|
9
|
+
*
|
|
10
|
+
* 纪律:
|
|
11
|
+
* · 幂等: 同一个 requestId 只落一次 (`dedupeInbox` 契约层去重), 重复到达 → 返回既有事实, 不覆盖;
|
|
12
|
+
* · 任务正文 (instruction) 只在**本地**文件里, 公开投影 / Pulse / registry 里永远没有它;
|
|
13
|
+
* · 不绕过任何状态机: 本模块只做落盘/读盘, 状态合法性由 `task-contract.checkTaskMove` 判 (调用方);
|
|
14
|
+
* · 任何落盘失败都不静默吞掉事实: 返回值里带 `{ok:false, error}`。
|
|
15
|
+
*/
|
|
16
|
+
import * as fs from 'fs';
|
|
17
|
+
import * as os from 'os';
|
|
18
|
+
import * as path from 'path';
|
|
19
|
+
import * as crypto from 'crypto';
|
|
20
|
+
import { dedupeInbox, } from './task-contract.js';
|
|
21
|
+
// ── 路径 ────────────────────────────────────────────────────────────────────
|
|
22
|
+
const homeOf = (home) => home || process.env.HOME || os.homedir();
|
|
23
|
+
export const tasksRoot = (home) => path.join(homeOf(home), '.bolloon', 'tasks');
|
|
24
|
+
export const inboxDir = (home) => path.join(tasksRoot(home), 'inbox');
|
|
25
|
+
export const localDir = (home) => path.join(tasksRoot(home), 'local');
|
|
26
|
+
export const resultsDir = (home) => path.join(tasksRoot(home), 'results');
|
|
27
|
+
export const bodiesDir = (home) => path.join(tasksRoot(home), 'bodies');
|
|
28
|
+
function ensure(dir) {
|
|
29
|
+
try {
|
|
30
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
31
|
+
return true;
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
return false;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
function writeJson(file, value) {
|
|
38
|
+
try {
|
|
39
|
+
ensure(path.dirname(file));
|
|
40
|
+
fs.writeFileSync(file, JSON.stringify(value, null, 2) + '\n', 'utf8');
|
|
41
|
+
return { ok: true };
|
|
42
|
+
}
|
|
43
|
+
catch (e) {
|
|
44
|
+
return { ok: false, error: String(e?.message || e).slice(0, 200) };
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
function readJson(file) {
|
|
48
|
+
try {
|
|
49
|
+
if (!fs.existsSync(file))
|
|
50
|
+
return null;
|
|
51
|
+
return JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
52
|
+
}
|
|
53
|
+
catch {
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
/** 只允许 requestId 当文件名 (防路径穿越); 非法 → null */
|
|
58
|
+
function safeId(id) {
|
|
59
|
+
const s = String(id || '').trim();
|
|
60
|
+
return /^[A-Za-z0-9._-]{4,128}$/.test(s) ? s : null;
|
|
61
|
+
}
|
|
62
|
+
const inboxFile = (requestId, home) => {
|
|
63
|
+
const id = safeId(requestId);
|
|
64
|
+
return id ? path.join(inboxDir(home), `${id}.json`) : null;
|
|
65
|
+
};
|
|
66
|
+
export function listInbox(home) {
|
|
67
|
+
try {
|
|
68
|
+
const dir = inboxDir(home);
|
|
69
|
+
if (!fs.existsSync(dir))
|
|
70
|
+
return [];
|
|
71
|
+
return fs.readdirSync(dir)
|
|
72
|
+
.filter((f) => f.endsWith('.json'))
|
|
73
|
+
.map((f) => readJson(path.join(dir, f)))
|
|
74
|
+
.filter((x) => !!x?.requestId)
|
|
75
|
+
.sort((a, b) => b.receivedAt - a.receivedAt);
|
|
76
|
+
}
|
|
77
|
+
catch {
|
|
78
|
+
return [];
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
export function readInboxItem(requestId, home) {
|
|
82
|
+
const f = inboxFile(requestId, home);
|
|
83
|
+
return f ? readJson(f) : null;
|
|
84
|
+
}
|
|
85
|
+
export function writeInboxItem(item, home) {
|
|
86
|
+
const f = inboxFile(item.requestId, home);
|
|
87
|
+
if (!f)
|
|
88
|
+
return { ok: false, error: `requestId 非法 (不能当文件名): ${item.requestId}` };
|
|
89
|
+
return writeJson(f, item);
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* 收到一个 TaskRequest → 落盘到收件箱。
|
|
93
|
+
* **幂等**: 同一 requestId 已在箱里 → `{dup:true}` 并返回既有条目 (不覆盖、不重复执行、不重复收费)。
|
|
94
|
+
*/
|
|
95
|
+
export function saveIncomingRequest(req, opts = {}) {
|
|
96
|
+
const existing = readInboxItem(req.requestId, opts.home);
|
|
97
|
+
if (existing) {
|
|
98
|
+
const d = dedupeInbox([{ requestId: existing.requestId }], { requestId: req.requestId });
|
|
99
|
+
return { ok: true, dup: true, item: existing, reason: d.reason || 'requestId 已在收件箱 (幂等)' };
|
|
100
|
+
}
|
|
101
|
+
const now = opts.now ?? Date.now();
|
|
102
|
+
const item = {
|
|
103
|
+
protocol: 'bolloon-task/1',
|
|
104
|
+
requestId: req.requestId,
|
|
105
|
+
taskId: req.taskId,
|
|
106
|
+
capability: req.capability,
|
|
107
|
+
instruction: String(req.instruction || ''),
|
|
108
|
+
buyerDid: req.buyerDid,
|
|
109
|
+
providerDid: req.providerDid,
|
|
110
|
+
budget: req.budget,
|
|
111
|
+
deadline: req.deadline,
|
|
112
|
+
paymentMode: req.paymentMode,
|
|
113
|
+
state: 'pending',
|
|
114
|
+
receivedAt: now,
|
|
115
|
+
updatedAt: now,
|
|
116
|
+
requestSignature: String(req.signature || ''),
|
|
117
|
+
requestSignatureVerified: opts.requestVerified ?? null,
|
|
118
|
+
buyerPublicKeyHex: opts.buyerPublicKeyHex ?? null,
|
|
119
|
+
replyTo: opts.replyTo ?? null,
|
|
120
|
+
};
|
|
121
|
+
const w = writeInboxItem(item, opts.home);
|
|
122
|
+
if (!w.ok)
|
|
123
|
+
return { ok: false, dup: false, error: w.error };
|
|
124
|
+
return { ok: true, dup: false, item };
|
|
125
|
+
}
|
|
126
|
+
/** 只改已知字段 (state/accept/reject/result/...); 不存在 → `{ok:false}` */
|
|
127
|
+
export function patchInboxItem(requestId, patch, home) {
|
|
128
|
+
const cur = readInboxItem(requestId, home);
|
|
129
|
+
if (!cur)
|
|
130
|
+
return { ok: false, error: `收件箱里没有 ${requestId}` };
|
|
131
|
+
const next = { ...cur, ...patch, updatedAt: Date.now() };
|
|
132
|
+
const w = writeInboxItem(next, home);
|
|
133
|
+
return w.ok ? { ok: true, item: next } : { ok: false, error: w.error };
|
|
134
|
+
}
|
|
135
|
+
export function findInboxByTaskId(taskId, home) {
|
|
136
|
+
return listInbox(home).find((i) => i.taskId === taskId) || null;
|
|
137
|
+
}
|
|
138
|
+
const localFile = (requestId, home) => {
|
|
139
|
+
const id = safeId(requestId);
|
|
140
|
+
return id ? path.join(localDir(home), `${id}.json`) : null;
|
|
141
|
+
};
|
|
142
|
+
export function listLocalTasks(home) {
|
|
143
|
+
try {
|
|
144
|
+
const dir = localDir(home);
|
|
145
|
+
if (!fs.existsSync(dir))
|
|
146
|
+
return [];
|
|
147
|
+
return fs.readdirSync(dir)
|
|
148
|
+
.filter((f) => f.endsWith('.json'))
|
|
149
|
+
.map((f) => readJson(path.join(dir, f)))
|
|
150
|
+
.filter((x) => !!x?.requestId)
|
|
151
|
+
.sort((a, b) => b.sentAt - a.sentAt);
|
|
152
|
+
}
|
|
153
|
+
catch {
|
|
154
|
+
return [];
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
export function readLocalTask(requestId, home) {
|
|
158
|
+
const f = localFile(requestId, home);
|
|
159
|
+
return f ? readJson(f) : null;
|
|
160
|
+
}
|
|
161
|
+
export function saveLocalTask(rec, home) {
|
|
162
|
+
const f = localFile(rec.requestId, home);
|
|
163
|
+
if (!f)
|
|
164
|
+
return { ok: false, error: `requestId 非法: ${rec.requestId}` };
|
|
165
|
+
return writeJson(f, rec);
|
|
166
|
+
}
|
|
167
|
+
/** 幂等: 同一 requestId 已经有台账 → 返回既有记录 (不覆盖已推进的状态) */
|
|
168
|
+
export function upsertLocalTask(rec, home) {
|
|
169
|
+
const cur = readLocalTask(rec.requestId, home);
|
|
170
|
+
if (cur)
|
|
171
|
+
return { ok: true, created: false, record: cur };
|
|
172
|
+
const w = saveLocalTask(rec, home);
|
|
173
|
+
return w.ok ? { ok: true, created: true, record: rec } : { ok: false, created: false, error: w.error };
|
|
174
|
+
}
|
|
175
|
+
export function patchLocalTask(requestId, patch, home) {
|
|
176
|
+
const cur = readLocalTask(requestId, home);
|
|
177
|
+
if (!cur)
|
|
178
|
+
return { ok: false, error: `本机台账里没有 ${requestId}` };
|
|
179
|
+
const next = { ...cur, ...patch, updatedAt: Date.now() };
|
|
180
|
+
const w = saveLocalTask(next, home);
|
|
181
|
+
return w.ok ? { ok: true, record: next } : { ok: false, error: w.error };
|
|
182
|
+
}
|
|
183
|
+
// ── 结果 (信封 + 正文分开存) ────────────────────────────────────────────────
|
|
184
|
+
const resultFile = (taskId, home) => {
|
|
185
|
+
const id = safeId(taskId);
|
|
186
|
+
return id ? path.join(resultsDir(home), `${id}.json`) : null;
|
|
187
|
+
};
|
|
188
|
+
export function saveResult(result, home) {
|
|
189
|
+
const f = resultFile(result.taskId, home);
|
|
190
|
+
if (!f)
|
|
191
|
+
return { ok: false, error: `taskId 非法: ${result.taskId}` };
|
|
192
|
+
return writeJson(f, result);
|
|
193
|
+
}
|
|
194
|
+
export function readResult(taskId, home) {
|
|
195
|
+
const f = resultFile(taskId, home);
|
|
196
|
+
return f ? readJson(f) : null;
|
|
197
|
+
}
|
|
198
|
+
/** 找结果: 先按 taskId, 再按收件箱/台账里的 requestId 反查 */
|
|
199
|
+
export function findResult(idOrTaskId, home) {
|
|
200
|
+
const direct = readResult(idOrTaskId, home);
|
|
201
|
+
if (direct)
|
|
202
|
+
return { result: direct, taskId: direct.taskId, source: 'results' };
|
|
203
|
+
const inb = readInboxItem(idOrTaskId, home);
|
|
204
|
+
if (inb?.result)
|
|
205
|
+
return { result: inb.result, taskId: inb.taskId, source: 'inbox' };
|
|
206
|
+
const loc = readLocalTask(idOrTaskId, home);
|
|
207
|
+
if (loc?.result)
|
|
208
|
+
return { result: loc.result, taskId: loc.taskId, source: 'local' };
|
|
209
|
+
const byTask = listLocalTasks(home).find((t) => t.taskId === idOrTaskId);
|
|
210
|
+
if (byTask?.result)
|
|
211
|
+
return { result: byTask.result, taskId: byTask.taskId, source: 'local' };
|
|
212
|
+
return null;
|
|
213
|
+
}
|
|
214
|
+
/**
|
|
215
|
+
* 交付正文落盘 (**私有一层**)。返回真实内容哈希 —— 它就是 TaskResult.contentHash 的来源,
|
|
216
|
+
* 绝不由调用方随口给。
|
|
217
|
+
*/
|
|
218
|
+
export function saveBody(taskId, text, home) {
|
|
219
|
+
const id = safeId(taskId);
|
|
220
|
+
if (!id)
|
|
221
|
+
return { ok: false, error: `taskId 非法: ${taskId}` };
|
|
222
|
+
try {
|
|
223
|
+
const dir = bodiesDir(home);
|
|
224
|
+
ensure(dir);
|
|
225
|
+
const file = path.join(dir, `${id}.txt`);
|
|
226
|
+
const buf = Buffer.from(text, 'utf8');
|
|
227
|
+
fs.writeFileSync(file, buf);
|
|
228
|
+
return { ok: true, file, contentHash: crypto.createHash('sha256').update(buf).digest('hex'), bytes: buf.length };
|
|
229
|
+
}
|
|
230
|
+
catch (e) {
|
|
231
|
+
return { ok: false, error: String(e?.message || e).slice(0, 200) };
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
/** 读交付正文 + 复核哈希 (正文**不进 stdout**; 调用方只报哈希/字节数) */
|
|
235
|
+
export function readBody(taskId, home) {
|
|
236
|
+
const id = safeId(taskId);
|
|
237
|
+
const dir = bodiesDir(home);
|
|
238
|
+
const file = id ? path.join(dir, `${id}.txt`) : path.join(dir, `${String(taskId)}.txt`);
|
|
239
|
+
const recorded = readResult(taskId, home)?.contentHash || null;
|
|
240
|
+
try {
|
|
241
|
+
if (!id || !fs.existsSync(file))
|
|
242
|
+
return { present: false, file, bytes: 0, contentHash: null, matches: null, reason: '正文不在盘上' };
|
|
243
|
+
const buf = fs.readFileSync(file);
|
|
244
|
+
const h = crypto.createHash('sha256').update(buf).digest('hex');
|
|
245
|
+
return { present: true, file, bytes: buf.length, contentHash: h, matches: recorded ? h === recorded : null, text: buf.toString('utf8') };
|
|
246
|
+
}
|
|
247
|
+
catch (e) {
|
|
248
|
+
return { present: false, file, bytes: 0, contentHash: null, matches: null, reason: String(e?.message || e).slice(0, 120) };
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
export function inboxSummary(item, now = Date.now()) {
|
|
252
|
+
return {
|
|
253
|
+
requestId: item.requestId,
|
|
254
|
+
taskId: item.taskId,
|
|
255
|
+
capability: item.capability,
|
|
256
|
+
sender: String(item.buyerDid || '').slice(0, 28),
|
|
257
|
+
state: item.state,
|
|
258
|
+
receivedAt: item.receivedAt,
|
|
259
|
+
deadline: item.deadline ?? null,
|
|
260
|
+
deadlineInMs: item.deadline ? item.deadline - now : null,
|
|
261
|
+
budget: item.budget ?? null,
|
|
262
|
+
paymentMode: item.paymentMode,
|
|
263
|
+
requestSignatureVerified: item.requestSignatureVerified ?? null,
|
|
264
|
+
instructionPreview: String(item.instruction || '').slice(0, 60) + (String(item.instruction || '').length > 60 ? '…' : ''),
|
|
265
|
+
hasResult: !!item.result,
|
|
266
|
+
};
|
|
267
|
+
}
|