@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.
Files changed (83) hide show
  1. package/dist/agents/agent-gateway.js +5 -0
  2. package/dist/agents/agent-manifest-protocol.js +29 -0
  3. package/dist/agents/contacts/chain.js +579 -0
  4. package/dist/agents/contacts/cli.js +158 -0
  5. package/dist/agents/contacts/consent.js +124 -0
  6. package/dist/agents/contacts/grant-payload.js +45 -0
  7. package/dist/agents/contacts/grants.js +451 -0
  8. package/dist/agents/contacts/policy.js +264 -0
  9. package/dist/agents/contacts/preview-types.js +6 -0
  10. package/dist/agents/contacts/providers.js +341 -0
  11. package/dist/agents/contacts/store.js +326 -0
  12. package/dist/agents/contacts/tools.js +154 -0
  13. package/dist/agents/contacts/types.js +148 -0
  14. package/dist/agents/economic-policy.js +7 -0
  15. package/dist/agents/gateway-network.js +6 -0
  16. package/dist/agents/local-signer.js +59 -0
  17. package/dist/agents/network-pulse.js +450 -0
  18. package/dist/agents/pi-sdk.js +10 -0
  19. package/dist/agents/task-contract.js +322 -0
  20. package/dist/agents/task-inbox.js +267 -0
  21. package/dist/agents/task-transport.js +280 -0
  22. package/dist/agents/x402/transaction-store.js +8 -1
  23. package/dist/cli/commands/agent.js +230 -0
  24. package/dist/cli/commands/index.js +61 -0
  25. package/dist/cli/commands/mcp.js +98 -0
  26. package/dist/cli/commands/network.js +334 -0
  27. package/dist/cli/commands/payment.js +125 -0
  28. package/dist/cli/commands/tasks.js +1236 -0
  29. package/dist/cli/commands/trade.js +229 -0
  30. package/dist/cli/commands/wallet.js +450 -0
  31. package/dist/cli/mcp/bridge.js +105 -0
  32. package/dist/cli/mcp/server.js +185 -0
  33. package/dist/cli/mcp/tools.js +612 -0
  34. package/dist/cli/protocol-envelope.js +346 -0
  35. package/dist/cli-entry.js +141 -17
  36. package/dist/electron-build/agents/agent-gateway.js +5 -0
  37. package/dist/electron-build/agents/agent-gateway.js.map +1 -1
  38. package/dist/electron-build/agents/agent-manifest-protocol.js +62 -0
  39. package/dist/electron-build/agents/agent-manifest-protocol.js.map +1 -1
  40. package/dist/electron-build/agents/contacts/chain.js +619 -0
  41. package/dist/electron-build/agents/contacts/chain.js.map +1 -0
  42. package/dist/electron-build/agents/contacts/consent.js +163 -0
  43. package/dist/electron-build/agents/contacts/consent.js.map +1 -0
  44. package/dist/electron-build/agents/contacts/grant-payload.js +52 -0
  45. package/dist/electron-build/agents/contacts/grant-payload.js.map +1 -0
  46. package/dist/electron-build/agents/contacts/grants.js +500 -0
  47. package/dist/electron-build/agents/contacts/grants.js.map +1 -0
  48. package/dist/electron-build/agents/contacts/policy.js +271 -0
  49. package/dist/electron-build/agents/contacts/policy.js.map +1 -0
  50. package/dist/electron-build/agents/contacts/preview-types.js +8 -0
  51. package/dist/electron-build/agents/contacts/preview-types.js.map +1 -0
  52. package/dist/electron-build/agents/contacts/providers.js +384 -0
  53. package/dist/electron-build/agents/contacts/providers.js.map +1 -0
  54. package/dist/electron-build/agents/contacts/store.js +370 -0
  55. package/dist/electron-build/agents/contacts/store.js.map +1 -0
  56. package/dist/electron-build/agents/contacts/tools.js +159 -0
  57. package/dist/electron-build/agents/contacts/tools.js.map +1 -0
  58. package/dist/electron-build/agents/contacts/types.js +166 -0
  59. package/dist/electron-build/agents/contacts/types.js.map +1 -0
  60. package/dist/electron-build/agents/economic-policy.js +7 -0
  61. package/dist/electron-build/agents/economic-policy.js.map +1 -1
  62. package/dist/electron-build/agents/external-events.js.map +1 -1
  63. package/dist/electron-build/agents/gateway-network.js +6 -0
  64. package/dist/electron-build/agents/gateway-network.js.map +1 -1
  65. package/dist/electron-build/agents/goal-store.js.map +1 -1
  66. package/dist/electron-build/agents/network-pulse.js +504 -0
  67. package/dist/electron-build/agents/network-pulse.js.map +1 -0
  68. package/dist/electron-build/agents/pi-sdk.js +10 -0
  69. package/dist/electron-build/agents/pi-sdk.js.map +1 -1
  70. package/dist/electron-build/agents/x402/transaction-store.js +8 -1
  71. package/dist/electron-build/agents/x402/transaction-store.js.map +1 -1
  72. package/dist/electron-build/security/tool-gate.js +4 -0
  73. package/dist/electron-build/security/tool-gate.js.map +1 -1
  74. package/dist/index.js +30 -0
  75. package/dist/security/tool-gate.js +4 -0
  76. package/dist/web/mobile-contacts.js +329 -0
  77. package/dist/web/mobile-core.js +481 -15
  78. package/dist/web/mobile.html +32 -0
  79. package/dist/web/mobile.js +139 -0
  80. package/dist/web/routes-contacts.js +420 -0
  81. package/dist/web/server.js +34 -0
  82. package/dist/web/task-frame-server.js +211 -0
  83. package/package.json +1 -1
@@ -0,0 +1,264 @@
1
+ /**
2
+ * contacts/policy.ts — 联系方式调用策略门 (2026-09-19, Phase 6)
3
+ *
4
+ * 联系方式是高风险动作, **不许直接暴露给 Agent**。调用链固定:
5
+ * Agent → PiAgentHarness → contact policy → consent → recipient → rate → preview → provider → send → evidence
6
+ *
7
+ * 本文件只做"能不能发/要不要人批"的判定 + 可审计预览, 不碰 provider、不写盘。
8
+ * 判定顺序固定, 每种拒绝都给出**机器可读的 blockKind** (供测试与审计), 不返回泛泛的"不允许"。
9
+ */
10
+ import { readGoal } from '../goal-store.js';
11
+ import { evaluateGrant, categoriesOf } from './grants.js';
12
+ import { scanForbidden } from './types.js';
13
+ import { scanSensitive } from './types.js';
14
+ const CHANNEL_LABEL = {
15
+ 'local-sink': '本地落盘 (未真实外发)',
16
+ 'http-webhook': '短信网关 HTTP (真实外发)',
17
+ smtp: 'SMTP 邮件 (真实外发)',
18
+ };
19
+ function reallySentFor(provider) {
20
+ return provider === 'smtp' || provider === 'http-webhook';
21
+ }
22
+ /** 任务级 auto_send 授权: 用户明确给某个 Goal 开过才行 (默认没有) */
23
+ export function taskAutoAuthorized(goal, contact) {
24
+ if (!goal)
25
+ return false;
26
+ const flag = goal.continuation?.autoSendContacts;
27
+ if (flag !== true)
28
+ return false;
29
+ if (contact.policy !== 'auto_send' && contact.policy !== 'send_after_approval')
30
+ return false;
31
+ if (contact.limits.requireApprovalEachTime)
32
+ return false;
33
+ return true;
34
+ }
35
+ export async function decideContactAction(input, ctx) {
36
+ const now = ctx.now ? ctx.now() : Date.now();
37
+ const base = {
38
+ allowed: false, requiresApproval: false, sensitive: [], firstContact: false, reallySent: false,
39
+ authorizationMode: 'one_time', approvalSkipped: false, policyDecision: 'not_evaluated', forbiddenCategories: [],
40
+ };
41
+ if (input.action === 'list')
42
+ return { ...base, allowed: true };
43
+ if (!input.contactId)
44
+ return { ...base, blockKind: 'not_found', reason: '缺少 contactId (禁止按模糊姓名自动发送)' };
45
+ const contact = await ctx.store.getContact(input.contactId);
46
+ if (!contact)
47
+ return { ...base, blockKind: 'not_found', reason: `contactId=${input.contactId} 不存在` };
48
+ // 1) 验证状态
49
+ if (contact.revokedAt || contact.verificationStatus === 'revoked') {
50
+ return { ...base, blockKind: 'consent_revoked', reason: '授权已被撤销 —— 历史证据保留, 但后续调用一律拒绝' };
51
+ }
52
+ if (contact.verificationStatus === 'blocked')
53
+ return { ...base, blockKind: 'contact_blocked', reason: '该联系方式已被标记 blocked' };
54
+ if (contact.verificationStatus === 'expired')
55
+ return { ...base, blockKind: 'verification_expired', reason: '验证已过期, 需重新验证' };
56
+ if (contact.verificationStatus !== 'verified') {
57
+ return { ...base, blockKind: 'unverified_contact', reason: `未验证的联系方式不能用于真实发送 (当前 ${contact.verificationStatus})` };
58
+ }
59
+ // 2) 通道是否配置好
60
+ const reallySent = reallySentFor(contact.provider);
61
+ if (reallySent && contact.secretRef && !(await ctx.store.secrets.has(contact.secretRef))) {
62
+ return { ...base, reallySent, blockKind: 'provider_not_configured', reason: `通道 ${contact.provider} 需要 secretRef=${contact.secretRef}, 但 Secret Store 里没有` };
63
+ }
64
+ // 3) 批量永远禁止
65
+ const recipients = input.recipients || [input.contactId];
66
+ if (recipients.length > 1) {
67
+ return { ...base, reallySent, blockKind: 'batch_forbidden', reason: `批量发送永远禁止 (收到 ${recipients.length} 个收件人)` };
68
+ }
69
+ // 4) 正常发送才要求正文
70
+ if ((input.action === 'send' || input.action === 'preview') && !input.body) {
71
+ return { ...base, reallySent, blockKind: 'missing_content', reason: '缺少正文' };
72
+ }
73
+ // 7) 频率限制
74
+ const limits = contact.limits || { dailyMax: 5, perTaskMax: 3, requireApprovalEachTime: false };
75
+ const today = await ctx.store.countSentToday(contact.contactId, now);
76
+ const forGoal = input.goalId ? await ctx.store.countSentForGoal(input.goalId) : 0;
77
+ const usage = { today, todayMax: limits.dailyMax, forGoal, goalMax: limits.perTaskMax };
78
+ // 7.5) 持久授权判定 (Phase 5): Grant 是否存在/有效/覆盖 channel·contact·task·content
79
+ const forbidden = scanForbidden(`${input.subject || ''}\n${input.body || ''}`);
80
+ base.forbiddenCategories = forbidden;
81
+ const sensitiveEarly = scanSensitive(`${input.subject || ''}\n${input.body || ''}`);
82
+ const scope0 = contact.consentScope || { kinds: [], taskRefs: [], grantedAt: '', grantedBy: '' };
83
+ const limits0 = contact.limits || { dailyMax: 5, perTaskMax: 3, requireApprovalEachTime: false };
84
+ const usage0 = {
85
+ today: await ctx.store.countSentToday(contact.contactId, now),
86
+ todayMax: limits0.dailyMax,
87
+ forGoal: input.goalId ? await ctx.store.countSentForGoal(input.goalId) : 0,
88
+ goalMax: limits0.perTaskMax,
89
+ };
90
+ let grantEval = null;
91
+ if (ctx.grants?.ready)
92
+ await ctx.grants.ready();
93
+ if (ctx.grants?.corrupt) {
94
+ // Grant 存储读不出来 → **拒绝自动发送**, 且大声说出来 (不静默当成"无权限"或"已授权")
95
+ base.grantBlock = 'grant_store_unreadable';
96
+ base.policyDecision = 'grant_store_unreadable:refuse_auto_send';
97
+ base.authorizationMode = 'one_time';
98
+ return { ...base, reallySent, usage: usage0, allowed: true, requiresApproval: true,
99
+ approvalReason: 'policy',
100
+ reason: '联系方式授权文件损坏/不可读 → 已拒绝自动发送, 需要人工批准并修复 grants.json (不会静默当成无权限或已授权)' };
101
+ }
102
+ if (ctx.grants) {
103
+ // 优先拿"管辖 Grant"(含撤销/暂停) 以给出精确原因; 拿不到再退到 activeFor
104
+ const g = ctx.grants.latestFor
105
+ ? await ctx.grants.latestFor(contact.ownerDid)
106
+ : await ctx.grants.activeFor(contact.ownerDid, now);
107
+ grantEval = evaluateGrant(g, {
108
+ ownerDid: contact.ownerDid,
109
+ channel: contact.kind,
110
+ contactId: contact.contactId,
111
+ contactVerified: contact.verificationStatus === 'verified',
112
+ contactRevoked: !!contact.revokedAt,
113
+ taskBoundToGoal: !!input.goalId && scope0.taskRefs.includes(input.goalId),
114
+ goalId: input.goalId,
115
+ goalMatchesGrant: ctx.goalMatchesGrant,
116
+ sensitiveCategories: categoriesOf(sensitiveEarly),
117
+ hasForbiddenContent: forbidden.length > 0,
118
+ deviceTrusted: ctx.deviceTrusted,
119
+ }, now);
120
+ if (grantEval.grant)
121
+ base.grant = { grantId: grantEval.grant.grantId, level: grantEval.grant.level, version: grantEval.grant.grantVersion };
122
+ base.authorizationMode = grantEval.authorizationMode;
123
+ base.approvalSkipped = grantEval.approvalSkipped;
124
+ base.policyDecision = grantEval.policyDecision;
125
+ if (!grantEval.ok) {
126
+ // 硬拒 (撤销/暂停/范围外/禁放行类别/设备不可信) → 直接不允许
127
+ // 例外: grant_missing / grant_suspended / grant_sensitive_content_denied → 退回人工批准 (不是"不允许")
128
+ const soft = grantEval.needsHumanApproval && (grantEval.block === 'grant_missing' || grantEval.block === 'grant_suspended' || grantEval.block === 'grant_sensitive_content_denied');
129
+ base.grantBlock = grantEval.block;
130
+ base.grantMissing = grantEval.block === 'grant_missing';
131
+ if (!soft) {
132
+ return { ...base, reallySent, usage: usage0,
133
+ blockKind: grantEval.block === 'forbidden_content_category' ? 'forbidden_content_category' : 'grant_denied',
134
+ reason: grantEval.reason };
135
+ }
136
+ base.approvalReason = grantEval.block === 'grant_sensitive_content_denied' ? 'sensitive_content' : 'policy';
137
+ }
138
+ }
139
+ // 8) contact 级硬禁 (与任务无关): draft_only
140
+ if (contact.policy === 'draft_only' && input.action === 'send') {
141
+ return { ...base, reallySent, usage, blockKind: 'policy_draft_only', reason: '该联系方式策略为 draft_only (只允许起草, 不允许发送)' };
142
+ }
143
+ // 8.5) 同一 requestId 不得重复发送 (幂等)
144
+ if (input.action === 'send' && input.requestId) {
145
+ const prior = await ctx.store.findSend(input.requestId);
146
+ if (prior && prior.status === 'sent') {
147
+ return { ...base, reallySent, usage, blockKind: 'duplicate_request', reason: `requestId=${input.requestId} 已经发送过 (状态 ${prior.status}), 不重复发送` };
148
+ }
149
+ if (ctx.hasPendingConsent && await ctx.hasPendingConsent(input.requestId)) {
150
+ return { ...base, reallySent, usage, blockKind: 'duplicate_request', reason: `requestId=${input.requestId} 已有待批准请求, 不重复排队` };
151
+ }
152
+ }
153
+ // 8.6) 频率限制 (contact 级 + 任务级)
154
+ if (input.action === 'send' && today >= limits.dailyMax) {
155
+ return { ...base, reallySent, usage, blockKind: 'rate_limited', reason: `今日已发 ${today}/${limits.dailyMax} 条, 超出发送频率` };
156
+ }
157
+ if (input.action === 'send' && input.goalId && forGoal >= limits.perTaskMax) {
158
+ return { ...base, reallySent, usage, blockKind: 'rate_limited', reason: `本任务已发 ${forGoal}/${limits.perTaskMax} 条, 超出任务上限` };
159
+ }
160
+ // 8.7) 任务边界: 是否绑定到这个任务 (发送必须有任务归属)
161
+ let goal = null;
162
+ if (input.goalId) {
163
+ goal = await readGoal(input.goalId);
164
+ if (!goal)
165
+ return { ...base, reallySent, blockKind: 'not_bound_to_task', reason: `goalId=${input.goalId} 不存在` };
166
+ if (goal.status === 'completed' || goal.status === 'failed') {
167
+ return { ...base, reallySent, usage, blockKind: 'goal_closed', reason: `任务已 ${goal.status}, 不允许再对外发送` };
168
+ }
169
+ }
170
+ const scope = contact.consentScope || { kinds: [], taskRefs: [], grantedAt: '', grantedBy: '' };
171
+ const boundToTask = !!input.goalId && (scope.taskRefs.includes(input.goalId) || (!!input.taskKind && scope.kinds.includes(input.taskKind)) || scope.kinds.includes('any'));
172
+ // "范围被授权覆盖" = 授权有效, 或只是内容敏感/暂停这类**软**情形 (软情形退回一次性人工批准,
173
+ // 而不是报"未绑定任务"这种会误导人的原因)
174
+ const SOFT_GRANT_COVER = new Set(['grant_sensitive_content_denied', 'grant_suspended']);
175
+ const coveredByGrant = !!(grantEval && (grantEval.ok || (!!grantEval.block && SOFT_GRANT_COVER.has(grantEval.block))));
176
+ if (input.action === 'send' && !boundToTask && !coveredByGrant) {
177
+ return { ...base, reallySent, usage, blockKind: 'not_bound_to_task',
178
+ reason: `该联系方式没有授权给任务 ${input.goalId || '(未指定)'} (未绑定任务联系人, 也没有覆盖它的长期授权)` };
179
+ }
180
+ // 9) 需要人工批准的几种情况
181
+ const sensitive = sensitiveEarly;
182
+ const firstContact = !(ctx.hasSentBefore ? await ctx.hasSentBefore(contact.contactId) : (await ctx.store.listSends()).some((s) => s.contactId === contact.contactId && s.status === 'sent'));
183
+ let requiresApproval = false;
184
+ let approvalReason;
185
+ // 持久授权命中且允许自动发送 → 跳过人工批准 (但下面的检查一个都不少)
186
+ if (grantEval && grantEval.ok && grantEval.approvalSkipped) {
187
+ requiresApproval = false;
188
+ approvalReason = undefined;
189
+ }
190
+ else if (firstContact && !(grantEval && grantEval.needsHumanApproval)) {
191
+ requiresApproval = true;
192
+ approvalReason = 'first_contact';
193
+ }
194
+ else if (grantEval && !grantEval.ok && grantEval.needsHumanApproval) {
195
+ requiresApproval = true;
196
+ approvalReason = base.approvalReason || 'policy';
197
+ }
198
+ else if (sensitive.length > 0) {
199
+ requiresApproval = true;
200
+ approvalReason = 'sensitive_content';
201
+ }
202
+ else if (limits.requireApprovalEachTime) {
203
+ requiresApproval = true;
204
+ approvalReason = 'each_time';
205
+ }
206
+ else if (contact.policy === 'send_after_approval' && !taskAutoAuthorized(goal, contact)) {
207
+ requiresApproval = true;
208
+ approvalReason = 'policy';
209
+ }
210
+ return {
211
+ allowed: true,
212
+ requiresApproval,
213
+ approvalReason,
214
+ sensitive,
215
+ firstContact,
216
+ reallySent,
217
+ usage,
218
+ grant: base.grant,
219
+ grantBlock: base.grantBlock,
220
+ grantMissing: base.grantMissing,
221
+ authorizationMode: base.authorizationMode,
222
+ approvalSkipped: requiresApproval ? false : base.approvalSkipped,
223
+ policyDecision: base.policyDecision,
224
+ forbiddenCategories: base.forbiddenCategories,
225
+ preview: buildPreview({ contact, input, reallySent, sensitive, firstContact, requiresApproval, approvalReason, usage, goal }),
226
+ };
227
+ }
228
+ export function buildPreview(b) {
229
+ const name = (b.contact.aliases && b.contact.aliases[0]) || b.contact.displayValue;
230
+ return {
231
+ contactName: name,
232
+ channel: b.contact.kind,
233
+ provider: b.contact.provider,
234
+ channelLabel: CHANNEL_LABEL[b.contact.provider] || b.contact.provider,
235
+ reallySent: b.reallySent,
236
+ recipient: b.contact.displayValue, // 只有脱敏值
237
+ subject: b.input.subject,
238
+ goalId: b.input.goalId,
239
+ goalObjective: b.goal?.objective,
240
+ permission: '单条发送 (批量永远禁止)',
241
+ usage: b.usage,
242
+ replyExpected: undefined,
243
+ firstContact: b.firstContact,
244
+ requiresApproval: b.requiresApproval,
245
+ approvalReason: b.approvalReason,
246
+ sensitive: b.sensitive.map((s) => `${s.kind}: ${s.hint}`),
247
+ lines: [
248
+ '将联系:',
249
+ ` 联系人: ${name}`,
250
+ ` 通道: ${b.contact.kind === 'phone' ? '手机号' : '邮箱'} · ${CHANNEL_LABEL[b.contact.provider] || b.contact.provider}`,
251
+ ` 收件人: ${b.contact.displayValue} (脱敏)`,
252
+ b.input.subject ? ` 主题: ${b.input.subject}` : ' 主题: (无)',
253
+ ` 任务: ${b.input.goalId || '(未绑定任务)'}${b.goal?.objective ? ` — ${String(b.goal.objective).slice(0, 60)}` : ''}`,
254
+ ` 权限: 单条发送 (今日 ${b.usage.today}/${b.usage.todayMax} · 本任务 ${b.usage.forGoal}/${b.usage.goalMax})`,
255
+ ` 首次联系: ${b.firstContact ? '是 (必须人工批准)' : '否'}`,
256
+ ` 敏感内容: ${b.sensitive.length ? b.sensitive.map((s) => s.kind).join(', ') + ' (必须人工批准)' : '无'}`,
257
+ ` 是否需要批准: ${b.requiresApproval ? `是 (${b.approvalReason || 'policy'})` : '否'}`,
258
+ ],
259
+ };
260
+ }
261
+ /** 人读渲染 (CLI / Web 卡片 / 审批流程都用这一份) */
262
+ export function renderPreview(p) {
263
+ return p.lines.join('\n');
264
+ }
@@ -0,0 +1,6 @@
1
+ /**
2
+ * contacts/preview-types.ts — 预览/限额的共享类型 (2026-09-19)
3
+ *
4
+ * 单独一个文件是为了断开 types.ts ↔ policy.ts 的循环引用: 两边都要用这两个形状。
5
+ */
6
+ export {};
@@ -0,0 +1,341 @@
1
+ /**
2
+ * contacts/providers.ts — 通道适配器 + 验证码挑战 (2026-09-19, Phase 2/3)
3
+ *
4
+ * 三种通道, 各自**如实**:
5
+ * local-sink 本地落盘 (outbox/*.json) —— 真写文件, 但**没有真外发**; 预览/证据里必须
6
+ * 标成"本地落盘(非真实外发)", 不许让用户以为对方收到了。
7
+ * http-webhook 真 HTTP POST (短信网关/自建服务), 需要 secretRef (Bearer token)
8
+ * smtp 真 SMTP 会话 (net/tls, AUTH LOGIN), 需要 secretRef (host/port/user/pass/from)
9
+ *
10
+ * 未配置 = 明确报 `provider_not_configured` 并拒绝发送, **绝不假装成功**。
11
+ *
12
+ * 验证码挑战 (Phase 2/3):
13
+ * 验证码只以 sha256(salt+code) 落盘, 明文只在"投递那一刻"存在; 有次数上限、有过期、一次性。
14
+ */
15
+ import * as fsp from 'fs/promises';
16
+ import * as net from 'net';
17
+ import * as tls from 'tls';
18
+ import * as path from 'path';
19
+ import * as crypto from 'crypto';
20
+ // ── 本地落盘 (真写文件, 但明确不是真外发) ────────────────────────────────────
21
+ export const localSinkProvider = {
22
+ id: 'local-sink',
23
+ kind: 'phone', // kind 仅作声明; 实际两种都能落
24
+ emulatesRealSend: false,
25
+ async configured() { return true; },
26
+ async deliver(msg, ctx) {
27
+ const file = path.join(ctx.store.dir, 'outbox', `${msg.requestId}.json`);
28
+ await fsp.mkdir(path.dirname(file), { recursive: true });
29
+ await fsp.writeFile(file, JSON.stringify({
30
+ // 明文收件人只落在这个本地文件里 (它就是"发件箱"), 不进 evidence/ledger
31
+ to: msg.to, kind: msg.kind, subject: msg.subject, body: msg.body,
32
+ threadToken: msg.threadToken, requestId: msg.requestId, at: new Date().toISOString(),
33
+ channel: 'local-sink', note: '本地落盘通道: 未真实外发 (仅供开发/验收)',
34
+ }, null, 2), 'utf8');
35
+ return { ok: true, providerMessageId: `local-${msg.requestId}`, reallySent: false };
36
+ },
37
+ };
38
+ // ── HTTP 网关 (真外发, 需要 secret) ─────────────────────────────────────────
39
+ export const httpWebhookProvider = {
40
+ id: 'http-webhook',
41
+ kind: 'phone',
42
+ emulatesRealSend: true,
43
+ async configured(store, secretRef) { return !!(secretRef && await store.secrets.has(secretRef)); },
44
+ async deliver(msg, ctx) {
45
+ const secret = await ctx.store.secrets.get(ctx.secretRef);
46
+ if (!secret)
47
+ return { ok: false, reallySent: false, error: `provider_not_configured: 缺少 secretRef=${ctx.secretRef}`, errorClass: 'provider_not_configured' };
48
+ let cfg;
49
+ try {
50
+ cfg = JSON.parse(secret.value);
51
+ }
52
+ catch {
53
+ return { ok: false, reallySent: false, error: 'secret 不是合法 JSON', errorClass: 'provider_not_configured' };
54
+ }
55
+ if (!cfg?.endpoint)
56
+ return { ok: false, reallySent: false, error: 'secret 缺 endpoint', errorClass: 'provider_not_configured' };
57
+ try {
58
+ const res = await fetch(cfg.endpoint, {
59
+ method: 'POST',
60
+ headers: { 'content-type': 'application/json', ...(cfg.token ? { authorization: `Bearer ${cfg.token}` } : {}) },
61
+ body: JSON.stringify({ to: msg.to, text: msg.body, threadToken: msg.threadToken, requestId: msg.requestId }),
62
+ });
63
+ const text = await res.text().catch(() => '');
64
+ if (!res.ok) {
65
+ const cls = res.status === 401 || res.status === 403 ? 'provider_auth_failed' : 'provider_rejected';
66
+ return { ok: false, reallySent: false, error: `HTTP ${res.status}: ${text.slice(0, 120)}`, errorClass: cls };
67
+ }
68
+ let id = '';
69
+ try {
70
+ id = String(JSON.parse(text)?.id || JSON.parse(text)?.sid || '');
71
+ }
72
+ catch { /* 网关没给 id 就不编 */ }
73
+ return { ok: true, reallySent: true, providerMessageId: id || `http-${msg.requestId}` };
74
+ }
75
+ catch (err) {
76
+ return { ok: false, reallySent: false, error: String(err?.message || err), errorClass: 'provider_unreachable' };
77
+ }
78
+ },
79
+ };
80
+ class SmtpSession {
81
+ sock;
82
+ buf = '';
83
+ /** 已收但还没被读走的完整行 —— 必须排队, 不能"没人等就丢" */
84
+ lines = [];
85
+ waiters = [];
86
+ closed = false;
87
+ constructor(sock) {
88
+ this.sock = sock;
89
+ sock.setEncoding('utf8');
90
+ sock.on('data', (d) => {
91
+ this.buf += d;
92
+ let idx;
93
+ while ((idx = this.buf.indexOf('\r\n')) >= 0) {
94
+ const line = this.buf.slice(0, idx);
95
+ this.buf = this.buf.slice(idx + 2);
96
+ const w = this.waiters.shift();
97
+ if (w)
98
+ w(line);
99
+ else
100
+ this.lines.push(line); // 2026-09-19: 排队而不是丢弃
101
+ }
102
+ });
103
+ sock.on('error', (err) => this.fail(new Error(err?.message || 'smtp_socket_error')));
104
+ sock.on('close', () => this.fail(new Error('smtp_connection_closed')));
105
+ }
106
+ fail(err) {
107
+ this.closed = true;
108
+ const ws = this.waiters;
109
+ this.waiters = [];
110
+ for (const w of ws)
111
+ w(`__ERROR__${err.message}`);
112
+ }
113
+ /**
114
+ * 读一条"完整"应答 (SMTP 多行应答: `250-续行` 到 `250 最后一行` 才算完)。
115
+ * 2026-09-19 真跑抓到: 一条 chunk 里带多行时, 早先的实现只喂第一个等待者、**其余行被丢掉** →
116
+ * 客户端死等超时 (表现就是"一发 EHLO 就 smtp_timeout")。
117
+ */
118
+ async readReply(timeoutMs = 15000) {
119
+ const lines = [];
120
+ const deadline = Date.now() + timeoutMs;
121
+ while (Date.now() < deadline) {
122
+ const line = await this.nextLine(deadline - Date.now());
123
+ if (line.startsWith('__ERROR__'))
124
+ throw new Error(line.slice('__ERROR__'.length));
125
+ lines.push(line);
126
+ if (/^\d{3} /.test(line))
127
+ break; // 空格 = 最后一行
128
+ }
129
+ return lines.join('\n');
130
+ }
131
+ nextLine(timeoutMs) {
132
+ if (this.lines.length)
133
+ return Promise.resolve(this.lines.shift());
134
+ if (this.closed)
135
+ return Promise.reject(new Error('smtp_connection_closed'));
136
+ return new Promise((resolve, reject) => {
137
+ const t = setTimeout(() => {
138
+ this.waiters = this.waiters.filter((w) => w !== fn);
139
+ reject(new Error('smtp_timeout'));
140
+ }, Math.max(1, timeoutMs));
141
+ const fn = (l) => { clearTimeout(t); resolve(l); };
142
+ this.waiters.push(fn);
143
+ });
144
+ }
145
+ async cmd(text) {
146
+ this.sock.write(text + '\r\n');
147
+ return this.readReply();
148
+ }
149
+ write(text) { this.sock.write(text); }
150
+ end() { try {
151
+ this.sock.end();
152
+ }
153
+ catch { /* 已断 */ } }
154
+ }
155
+ function classifySmtpError(err, code) {
156
+ const msg = String(err?.message || err || '').toLowerCase();
157
+ if (msg.includes('smtp_timeout'))
158
+ return 'provider_unreachable';
159
+ if (msg.includes('econnrefused') || msg.includes('etimedout') || msg.includes('enotfound') || code === 'ECONNREFUSED')
160
+ return 'provider_unreachable';
161
+ return 'unknown';
162
+ }
163
+ /** 真 SMTP 发送 (net / tls, AUTH LOGIN)。信封里的收件人只在这里出现。 */
164
+ export async function smtpDeliver(cfg, msg) {
165
+ let sock;
166
+ let s;
167
+ try {
168
+ sock = cfg.secure
169
+ ? tls.connect({ host: cfg.host, port: cfg.port, servername: cfg.tlsServername || cfg.host })
170
+ : net.connect({ host: cfg.host, port: cfg.port });
171
+ // 2026-09-19: **先挂监听再等连接** —— 服务器可能在 TCP 建好那一刻就把 "220 ..." 发过来,
172
+ // 监听器挂晚了会丢问候语, 表现是"客户端干等到超时"(真跑 SMTP 时抓到过)。
173
+ s = new SmtpSession(sock);
174
+ await new Promise((resolve, reject) => {
175
+ const onErr = (e) => reject(e);
176
+ if (sock.connecting === false) {
177
+ resolve();
178
+ return;
179
+ }
180
+ sock.once('error', onErr);
181
+ const ev = cfg.secure ? 'secureConnect' : 'connect';
182
+ sock.once(ev, () => { sock.off('error', onErr); resolve(); });
183
+ setTimeout(() => reject(new Error('connect_timeout')), 12000).unref?.();
184
+ });
185
+ }
186
+ catch (err) {
187
+ return { ok: false, reallySent: false, error: String(err?.message || err), errorClass: 'provider_unreachable' };
188
+ }
189
+ try {
190
+ const greet = await s.readReply();
191
+ if (!/^220/.test(greet))
192
+ return { ok: false, reallySent: false, error: `SMTP 问候异常: ${greet.slice(0, 80)}`, errorClass: 'provider_rejected' };
193
+ const ehlo = await s.cmd(`EHLO bolloon.local`);
194
+ if (!/^250/.test(ehlo))
195
+ return { ok: false, reallySent: false, error: `EHLO 被拒: ${ehlo.slice(0, 80)}`, errorClass: 'provider_rejected' };
196
+ if (cfg.user && cfg.pass) {
197
+ const a1 = await s.cmd('AUTH LOGIN');
198
+ if (!/^334/.test(a1))
199
+ return { ok: false, reallySent: false, error: `AUTH LOGIN 不支持: ${a1.slice(0, 80)}`, errorClass: 'provider_auth_failed' };
200
+ const a2 = await s.cmd(Buffer.from(cfg.user).toString('base64'));
201
+ if (!/^334/.test(a2))
202
+ return { ok: false, reallySent: false, error: `用户名被拒: ${a2.slice(0, 80)}`, errorClass: 'provider_auth_failed' };
203
+ const a3 = await s.cmd(Buffer.from(cfg.pass).toString('base64'));
204
+ if (!/^235/.test(a3))
205
+ return { ok: false, reallySent: false, error: `认证失败: ${a3.slice(0, 80)}`, errorClass: 'provider_auth_failed' };
206
+ }
207
+ const mf = await s.cmd(`MAIL FROM:<${cfg.from}>`);
208
+ if (!/^250/.test(mf))
209
+ return { ok: false, reallySent: false, error: `MAIL FROM 被拒: ${mf.slice(0, 80)}`, errorClass: 'provider_rejected' };
210
+ const rc = await s.cmd(`RCPT TO:<${msg.to}>`);
211
+ if (!/^250/.test(rc))
212
+ return { ok: false, reallySent: false, error: `RCPT TO 被拒: ${rc.slice(0, 80)}`, errorClass: 'invalid_recipient' };
213
+ const dt = await s.cmd('DATA');
214
+ if (!/^354/.test(dt))
215
+ return { ok: false, reallySent: false, error: `DATA 被拒: ${dt.slice(0, 80)}`, errorClass: 'provider_rejected' };
216
+ const body = msg.body.replace(/\r?\n/g, '\r\n').replace(/^\./gm, '..');
217
+ const headers = [
218
+ `From: ${msg.fromName ? `"${msg.fromName}" <${cfg.from}>` : cfg.from}`,
219
+ `To: <${msg.to}>`,
220
+ `Subject: ${msg.subject || '(无主题)'}`,
221
+ `Date: ${new Date().toUTCString()}`,
222
+ `Message-ID: <${msg.requestId}@bolloon>`,
223
+ `X-Bolloon-Thread: ${msg.threadToken}`,
224
+ `X-Bolloon-Request: ${msg.requestId}`,
225
+ 'MIME-Version: 1.0',
226
+ 'Content-Type: text/plain; charset=utf-8',
227
+ ].join('\r\n');
228
+ const sent = await s.cmd(`${headers}\r\n\r\n${body}\r\n.`);
229
+ if (!/^250/.test(sent))
230
+ return { ok: false, reallySent: false, error: `发送被拒: ${sent.slice(0, 120)}`, errorClass: 'provider_rejected' };
231
+ s.cmd('QUIT').catch(() => { });
232
+ const id = (sent.match(/250[ -](?:2\.0\.0\s+)?(?:Ok\s+)?(?:queued as\s+)?(\S+)/i)?.[1]) || `smtp-${msg.requestId}`;
233
+ return { ok: true, reallySent: true, providerMessageId: id };
234
+ }
235
+ catch (err) {
236
+ return { ok: false, reallySent: false, error: String(err?.message || err), errorClass: classifySmtpError(err, err?.code) };
237
+ }
238
+ finally {
239
+ s.end();
240
+ }
241
+ }
242
+ export const smtpProvider = {
243
+ id: 'smtp',
244
+ kind: 'email',
245
+ emulatesRealSend: true,
246
+ async configured(store, secretRef) { return !!(secretRef && await store.secrets.has(secretRef)); },
247
+ async deliver(msg, ctx) {
248
+ const secret = await ctx.store.secrets.get(ctx.secretRef);
249
+ if (!secret)
250
+ return { ok: false, reallySent: false, error: `provider_not_configured: 缺少 secretRef=${ctx.secretRef}`, errorClass: 'provider_not_configured' };
251
+ let cfg;
252
+ try {
253
+ cfg = JSON.parse(secret.value);
254
+ }
255
+ catch {
256
+ return { ok: false, reallySent: false, error: 'secret 不是合法 JSON', errorClass: 'provider_not_configured' };
257
+ }
258
+ if (!cfg?.host || !cfg?.from)
259
+ return { ok: false, reallySent: false, error: 'secret 缺 host/from', errorClass: 'provider_not_configured' };
260
+ return smtpDeliver({ ...cfg, port: cfg.port || 587 }, msg);
261
+ },
262
+ };
263
+ const PROVIDERS = {
264
+ 'local-sink': localSinkProvider,
265
+ 'http-webhook': httpWebhookProvider,
266
+ smtp: smtpProvider,
267
+ };
268
+ export function getProvider(id) {
269
+ return PROVIDERS[id] || null;
270
+ }
271
+ export function listProviders() {
272
+ return Object.values(PROVIDERS).map((p) => ({ id: p.id, kind: p.kind, reallySent: p.emulatesRealSend }));
273
+ }
274
+ const OTP_TTL_MS = 10 * 60_000;
275
+ const OTP_MAX_ATTEMPTS = 5;
276
+ export function generateOtp() {
277
+ return String(crypto.randomInt(0, 1_000_000)).padStart(6, '0');
278
+ }
279
+ export function hashCode(code, salt) {
280
+ return crypto.createHash('sha256').update(`${salt}:${code}`).digest('hex');
281
+ }
282
+ export class OtpStore {
283
+ dir;
284
+ file;
285
+ constructor(dir) {
286
+ this.dir = dir;
287
+ this.file = path.join(dir, 'otp.json');
288
+ }
289
+ async list() {
290
+ try {
291
+ return JSON.parse(await fsp.readFile(this.file, 'utf8'));
292
+ }
293
+ catch (err) {
294
+ if (err?.code === 'ENOENT')
295
+ return [];
296
+ throw err;
297
+ }
298
+ }
299
+ async write(all) {
300
+ const tmp = `${this.file}.tmp-${process.pid}`;
301
+ await fsp.mkdir(this.dir, { recursive: true });
302
+ await fsp.writeFile(tmp, JSON.stringify(all.slice(-200), null, 2), 'utf8');
303
+ await fsp.rename(tmp, this.file);
304
+ }
305
+ /** 生成挑战 —— 返回明文 once (调用方马上投递, 不落盘) */
306
+ async issue(opts) {
307
+ const now = opts.now ?? Date.now();
308
+ const code = generateOtp();
309
+ const salt = crypto.randomBytes(8).toString('hex');
310
+ const challenge = {
311
+ challengeId: `otp-${now.toString(36)}-${crypto.randomBytes(3).toString('hex')}`,
312
+ contactId: opts.contactId, kind: opts.kind, provider: opts.provider,
313
+ salt, codeHash: hashCode(code, salt), attempts: 0, maxAttempts: OTP_MAX_ATTEMPTS,
314
+ createdAt: new Date(now).toISOString(), expiresAt: new Date(now + OTP_TTL_MS).toISOString(),
315
+ };
316
+ const all = await this.list();
317
+ all.push(challenge);
318
+ await this.write(all);
319
+ return { challenge, code };
320
+ }
321
+ async verify(challengeId, code, now = Date.now()) {
322
+ const all = await this.list();
323
+ const c = all.find((x) => x.challengeId === challengeId);
324
+ if (!c)
325
+ return { ok: false, reason: 'challenge_not_found' };
326
+ if (c.usedAt)
327
+ return { ok: false, reason: 'already_used' };
328
+ if (Date.parse(c.expiresAt) < now)
329
+ return { ok: false, reason: 'expired' };
330
+ if (c.attempts >= c.maxAttempts)
331
+ return { ok: false, reason: 'too_many_attempts' };
332
+ c.attempts += 1;
333
+ if (hashCode(String(code).trim(), c.salt) !== c.codeHash) {
334
+ await this.write(all);
335
+ return { ok: false, reason: 'wrong_code' };
336
+ }
337
+ c.usedAt = new Date(now).toISOString();
338
+ await this.write(all);
339
+ return { ok: true };
340
+ }
341
+ }