@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
@@ -3031,3 +3031,142 @@
3031
3031
  document.addEventListener('DOMContentLoaded', init);
3032
3032
  if (document.readyState !== 'loading') init();
3033
3033
  })();
3034
+
3035
+ // ============ 联系方式与持久授权 (2026-09-19) ============
3036
+ // 独立 IIFE: 不改动既有逻辑, 只用 window.BolloonCore.contacts (编译进 mobile-core.js)。
3037
+ // 分工: 手机 = 输入/OTP/确认/展示待发内容/批准/签名授权; 桌面 = 落盘/执行/等待回复/证据。
3038
+ (function () {
3039
+ const $ = (sel) => document.querySelector(sel);
3040
+ const C = () => (window.BolloonCore && window.BolloonCore.contacts) || null;
3041
+ const esc = (v) => String(v == null ? '' : v).replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
3042
+
3043
+ function openSheet() { const el = $('#contacts-sheet'); if (el) el.hidden = false; }
3044
+ function closeSheet() { const el = $('#contacts-sheet'); if (el) el.hidden = true; }
3045
+ function setText(id, text) { const el = $(id); if (el) el.textContent = text; }
3046
+
3047
+ let _lastChallenge = null;
3048
+
3049
+ async function refresh() {
3050
+ const c = C();
3051
+ setText('#contacts-effective', '加载中...');
3052
+ if (!c) { setText('#contacts-effective', '内核未就绪 (mobile-core 没加载)'); return; }
3053
+ setText('#contacts-signing', c.deviceSigning()
3054
+ ? '本机可做设备签名 (Ed25519) — 授权由这台手机签名后桌面才认'
3055
+ : '⚠ 本机 WebView 不支持 Ed25519 设备签名 — 无法在手机上授权, 请在桌面端授权');
3056
+ // 桌面回来先补同步排队中的授权
3057
+ try { const fl = await c.flushQueue(); if (fl && fl.sent) setText('#contacts-signing', `已补同步 ${fl.sent} 条手机授权到桌面`); } catch (e) {}
3058
+
3059
+ let card = null;
3060
+ try { card = await c.card(); } catch (e) { card = null; }
3061
+ if (!card) { setText('#contacts-effective', '读取失败'); return; }
3062
+ setText('#contacts-effective',
3063
+ `当前授权: ${card.effective}` + (card.deviceSigning ? '' : ' · 本机不能签名'));
3064
+ setText('#contacts-willnot', '不会获得权限: ' + (card.willNotGet || []).join(' · '));
3065
+
3066
+ // 联系方式 (只有脱敏值)
3067
+ const list = $('#contacts-list');
3068
+ if (list) {
3069
+ list.innerHTML = (card.contacts && card.contacts.length)
3070
+ ? card.contacts.map((x) => `<div class="sheet-text">${esc(x.displayValue)} <span style="color:var(--text-secondary)">[${esc(x.kind)}] ${esc(x.state)} · ${esc(x.label)}</span></div>`).join('')
3071
+ : '<div class="sheet-text" style="color:var(--text-secondary)">还没有绑定 — 在下面输入手机号或邮箱</div>';
3072
+ }
3073
+
3074
+ // 待批准 (展示待发内容 → 批准/拒绝)
3075
+ const ap = $('#contacts-approvals');
3076
+ if (ap) {
3077
+ ap.innerHTML = (card.approvals && card.approvals.length)
3078
+ ? `<div class="sheet-title" style="margin-top:10px">待你批准 (${card.approvals.length})</div>` + card.approvals.map((a) => `
3079
+ <div style="border:1px solid var(--border);border-radius:10px;padding:8px;margin-top:6px">
3080
+ <div class="sheet-text" style="font-weight:600">${esc(a.contactName)} · ${esc(a.channel)}${a.reallySent ? '' : ' · 本地落盘(未真实外发)'}</div>
3081
+ <div class="sheet-text" style="font-size:12px">${a.subject ? esc(a.subject) + ' — ' : ''}${esc(a.bodyPreview)}</div>
3082
+ <div class="sheet-text" style="font-size:11px;color:var(--text-secondary)">原因: ${esc(a.reason)}</div>
3083
+ <div style="display:flex;gap:8px;margin-top:6px">
3084
+ <button class="sheet-choice" data-approve="${esc(a.consentId)}" style="flex:1">批准并发送</button>
3085
+ <button class="sheet-choice sheet-cancel" data-reject="${esc(a.consentId)}" style="flex:1">拒绝</button>
3086
+ </div>
3087
+ </div>`).join('')
3088
+ : '<div class="sheet-text" style="color:var(--text-secondary);margin-top:10px">没有待批准的联系</div>';
3089
+ ap.querySelectorAll('[data-approve]').forEach((b) => b.addEventListener('click', async () => {
3090
+ const id = b.getAttribute('data-approve');
3091
+ const r = await C().decide(id, 'approve');
3092
+ alert(r && r.ok ? '已批准并发送' : '批准失败: ' + ((r && r.error) || '未知'));
3093
+ refresh();
3094
+ }));
3095
+ ap.querySelectorAll('[data-reject]').forEach((b) => b.addEventListener('click', async () => {
3096
+ const id = b.getAttribute('data-reject');
3097
+ const r = await C().decide(id, 'reject', { reason: '手机端拒绝' });
3098
+ alert(r && r.ok ? '已拒绝' : '拒绝失败: ' + ((r && r.error) || '未知'));
3099
+ refresh();
3100
+ }));
3101
+ }
3102
+
3103
+ // 三个授权选项
3104
+ const ch = $('#contacts-choices');
3105
+ if (ch && !ch.dataset.bound) {
3106
+ ch.dataset.bound = '1';
3107
+ ch.innerHTML = (card.choices || []).map((x) => `<button class="sheet-choice" data-choice="${esc(x.id)}">${esc(x.label)}</button>`).join('');
3108
+ ch.querySelectorAll('[data-choice]').forEach((b) => b.addEventListener('click', async () => {
3109
+ const choice = b.getAttribute('data-choice');
3110
+ const warn = choice === 'full_contact_access'
3111
+ ? '完全授权联系方式能力: 手机号/邮箱能力交给 Agent, 普通与敏感内容不再逐次确认 (密钥/支付/合同承诺仍然拒绝)。确认吗?'
3112
+ : choice === 'persistent' ? '长期使用: 以后 Agent 联系已验证的人不再打断你 (仍受频率/任务/证据/撤销约束)。确认吗?'
3113
+ : '仅本次任务: 只授权当前任务使用。确认吗?';
3114
+ if (window.confirm && !window.confirm(warn)) return;
3115
+ const r = await C().authorize(choice);
3116
+ if (r && r.ok) alert('已授权: 桌面已验签接受\n' + (r.note || ''));
3117
+ else if (r && r.queued) alert('桌面暂时不在线\n授权已保存在手机本地队列, 桌面一上线自动同步\n(' + (r.error || '') + ')');
3118
+ else alert('授权失败: ' + ((r && r.error) || '未知'));
3119
+ refresh();
3120
+ }));
3121
+ }
3122
+ }
3123
+
3124
+ function bindEvents() {
3125
+ const item = $('#item-contacts');
3126
+ if (item) item.addEventListener('click', () => { openSheet(); refresh(); });
3127
+ const close = $('#contacts-close');
3128
+ if (close) close.addEventListener('click', closeSheet);
3129
+
3130
+ const bind = $('#contacts-bind');
3131
+ if (bind) bind.addEventListener('click', async () => {
3132
+ const v = ($('#contacts-value') || {}).value || '';
3133
+ if (!v.trim()) { alert('先输入手机号或邮箱'); return; }
3134
+ const isEmail = /@/.test(v);
3135
+ const r = await C().bind(isEmail ? 'email' : 'phone', v.trim(), isEmail ? undefined : 'CN');
3136
+ if (!r || !r.ok) { alert('绑定失败: ' + ((r && r.error) || '未知')); return; }
3137
+ _lastChallenge = r.data;
3138
+ alert('已登记 ' + r.data.displayValue + '\n通道: ' + (r.data.channelLabel || '') +
3139
+ (r.data.otpForLocalSink ? '\n本地落盘验证码: ' + r.data.otpForLocalSink : '\n验证码已通过通道发出, 收到后填在下面'));
3140
+ refresh();
3141
+ });
3142
+
3143
+ const verify = $('#contacts-verify');
3144
+ if (verify) verify.addEventListener('click', async () => {
3145
+ const code = ($('#contacts-code') || {}).value || '';
3146
+ if (!_lastChallenge) { alert('先绑定一个手机号或邮箱'); return; }
3147
+ const r = await C().verify(_lastChallenge.contactId, _lastChallenge.challengeId, code.trim());
3148
+ if (!r || !r.ok) { alert('验证失败: ' + ((r && r.error) || '未知')); return; }
3149
+ alert('验证通过: ' + r.data.displayValue + '\n下一步: 选择下面的授权等级 (验证 ≠ 自动长期授权)');
3150
+ refresh();
3151
+ });
3152
+
3153
+ const revoke = $('#contacts-revoke-all');
3154
+ if (revoke) revoke.addEventListener('click', async () => {
3155
+ if (window.confirm && !window.confirm('撤销全部联系方式授权? 之后 Agent 不能再自动联系任何人 (历史证据保留)')) return;
3156
+ const card = await C().card();
3157
+ const grants = (card && card.grants) || [];
3158
+ if (!grants.length) { alert('当前没有生效中的授权'); return; }
3159
+ let done = 0, err = '';
3160
+ for (const g of grants) {
3161
+ if (g.status !== 'active') continue;
3162
+ const r = await C().revoke(g.grantId, '手机端收回');
3163
+ if (r && r.ok) done++; else err = (r && r.error) || '未知';
3164
+ }
3165
+ alert(done ? `已撤销 ${done} 条授权` : ('撤销未完成: ' + err));
3166
+ refresh();
3167
+ });
3168
+ }
3169
+
3170
+ if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', bindEvents);
3171
+ else bindEvents();
3172
+ })();
@@ -0,0 +1,420 @@
1
+ /**
2
+ * routes-contacts.ts — 联系方式 / 授权 / 配对 的桌面端 API (2026-09-19, Phase 5/6)
3
+ *
4
+ * 全部返回**脱敏**数据: 完整手机号/邮箱永不出现在响应里。
5
+ * 与手机端的分工 (leo 冻结):
6
+ * 手机负责 输入 + OTP 确认 + 生物识别授权 + 展示待发内容 + 批准高风险联系
7
+ * 桌面负责 长期 Goal/Run、执行、恢复、持久化状态、证据
8
+ * 同步只传: identityId / contactId / verificationStatus / capability / consentScope / provider /
9
+ * secretRef 的关联状态 —— **不传**明文、不传密钥。
10
+ */
11
+ import * as crypto from 'crypto';
12
+ import * as fs from 'fs';
13
+ import * as os from 'os';
14
+ import * as path from 'path';
15
+ import { ContactChain } from '../agents/contacts/chain.js';
16
+ const PAIRING_TTL_MS = 5 * 60_000;
17
+ /** 明显的明文联系方式/密钥 —— 同步载荷里出现就直接拒绝 (防止"顺手同步了明文") */
18
+ export function looksLikePlaintextSecret(value) {
19
+ const walk = (v, pathStr) => {
20
+ if (v == null)
21
+ return null;
22
+ if (typeof v === 'string') {
23
+ const s = v.trim();
24
+ if (/\b(sk-[A-Za-z0-9]{12,}|AKIA[0-9A-Z]{12,}|ghp_[A-Za-z0-9]{20,}|npm_[A-Za-z0-9]{20,})\b/.test(s))
25
+ return `${pathStr}: 疑似密钥明文`;
26
+ if (/\b(password|passwd|smtp_pass|api_secret|refresh_token|client_secret)\b/i.test(pathStr) && s.length > 0)
27
+ return `${pathStr}: 疑似秘密字段`;
28
+ // 明文邮箱/手机号: 只有在 key 明确表示"值"时才拦 (displayValue 是脱敏的, 不会匹配)
29
+ if (/[^@\s]+@[^@\s]+\.[^@\s]+/.test(s) && !/^\*|@\*/.test(s) && s.includes('*') === false)
30
+ return `${pathStr}: 疑似邮箱明文`;
31
+ if (/^\+?\d{8,15}$/.test(s.replace(/[^\d+]/g, '')) && /value|phone|number|to$/i.test(pathStr))
32
+ return `${pathStr}: 疑似手机号明文`;
33
+ return null;
34
+ }
35
+ if (Array.isArray(v)) {
36
+ for (let i = 0; i < v.length; i++) {
37
+ const r = walk(v[i], `${pathStr}[${i}]`);
38
+ if (r)
39
+ return r;
40
+ }
41
+ return null;
42
+ }
43
+ if (typeof v === 'object') {
44
+ for (const [k, vv] of Object.entries(v)) {
45
+ const r = walk(vv, pathStr ? `${pathStr}.${k}` : k);
46
+ if (r)
47
+ return r;
48
+ }
49
+ return null;
50
+ }
51
+ return null;
52
+ };
53
+ return walk(value, '');
54
+ }
55
+ export function registerContactRoutes(app, opts = {}) {
56
+ const pairs = opts.pairs || new Map();
57
+ const ownerDidFromDisk = () => {
58
+ try {
59
+ const f = path.join(process.env.HOME || os.homedir(), '.bolloon', 'identity', 'user.json');
60
+ const j = JSON.parse(fs.readFileSync(f, 'utf8'));
61
+ if (j?.did)
62
+ return String(j.did);
63
+ }
64
+ catch { /* 还没有用户身份 → 用本地占位 */ }
65
+ return globalThis.__bolloonUserDid || 'did:bolln:local';
66
+ };
67
+ const chainFor = (ownerDid) => opts.chain || new ContactChain({
68
+ home: opts.home,
69
+ ownerDid: ownerDid || opts.ownerDid || ownerDidFromDisk(),
70
+ displayName: '本机用户',
71
+ });
72
+ const chainOf = (req) => chainFor(String(req?.body?.ownerDid || opts.ownerDid || ''));
73
+ // ── 列表 (脱敏) ────────────────────────────────────────────────────────────
74
+ app.get('/api/contacts', async (req, res) => {
75
+ try {
76
+ const c = chainOf(req);
77
+ res.json({
78
+ ok: true,
79
+ contacts: await c.listAuthorized(),
80
+ channels: c.channels(),
81
+ approvals: (await c.consents.list('pending')).map((a) => ({
82
+ consentId: a.consentId, contactId: a.contactId, contactName: a.contactName, channel: a.channel,
83
+ provider: a.provider, reallySent: a.reallySent, subject: a.subject, bodyPreview: a.bodyPreview,
84
+ goalId: a.goalId, reason: a.reason, createdAt: a.createdAt, expiresAt: a.expiresAt,
85
+ })),
86
+ waiting: (await c.store.readLedger({ limit: 200 })).filter((l) => l.activity === 'contact.sent').slice(-10),
87
+ });
88
+ }
89
+ catch (err) {
90
+ res.status(500).json({ ok: false, error: String(err?.message || err) });
91
+ }
92
+ });
93
+ // ── 绑定 (Phase 2/3): 输入 → 规范化 → 隐私说明 → 发验证码 → pending ─────────
94
+ app.post('/api/contacts/bind', async (req, res) => {
95
+ try {
96
+ const c = chainOf(req);
97
+ const kind = String(req.body?.kind || '');
98
+ if (kind !== 'phone' && kind !== 'email')
99
+ return res.status(400).json({ ok: false, error: 'kind 必须是 phone 或 email' });
100
+ const r = await c.bind({
101
+ kind,
102
+ value: String(req.body?.value || ''),
103
+ region: req.body?.region ? String(req.body.region) : undefined,
104
+ provider: req.body?.provider ? String(req.body.provider) : undefined,
105
+ secretRef: req.body?.secretRef ? String(req.body.secretRef) : undefined,
106
+ aliases: Array.isArray(req.body?.aliases) ? req.body.aliases.map(String) : undefined,
107
+ note: req.body?.note ? String(req.body.note) : undefined,
108
+ source: req.body?.source === 'agent_card' ? 'agent_card' : 'user_input',
109
+ });
110
+ if (!r.ok)
111
+ return res.status(400).json({ ok: false, error: r.error });
112
+ res.json({
113
+ ok: true,
114
+ contactId: r.contact.contactId,
115
+ displayValue: r.contact.displayValue,
116
+ verificationStatus: r.contact.verificationStatus,
117
+ channelLabel: r.channelLabel,
118
+ challengeId: r.challengeId,
119
+ // 只有本地落盘通道才回显验证码 (真外发通道不回显, 也不会出现在响应里)
120
+ otpForLocalSink: r.otpForLocalSink,
121
+ privacy: '完整手机号/邮箱不会进入 prompt / Run / Git; 外部只看到 已验证/可联系/不可联系/需要重新授权',
122
+ });
123
+ }
124
+ catch (err) {
125
+ res.status(500).json({ ok: false, error: String(err?.message || err) });
126
+ }
127
+ });
128
+ app.post('/api/contacts/verify', async (req, res) => {
129
+ try {
130
+ const c = chainOf(req);
131
+ const r = await c.verify({ contactId: String(req.body?.contactId || ''), challengeId: String(req.body?.challengeId || ''), code: String(req.body?.code || '') });
132
+ if (!r.ok)
133
+ return res.status(400).json({ ok: false, error: r.error });
134
+ res.json({ ok: true, contactId: r.contact.contactId, displayValue: r.contact.displayValue, verificationStatus: r.contact.verificationStatus, capabilities: r.contact.capabilities });
135
+ }
136
+ catch (err) {
137
+ res.status(500).json({ ok: false, error: String(err?.message || err) });
138
+ }
139
+ });
140
+ app.post('/api/contacts/:id/revoke', async (req, res) => {
141
+ try {
142
+ const c = chainOf(req);
143
+ const r = await c.revoke({ contactId: String(req.params.id), by: String(req.body?.by || 'user'), reason: req.body?.reason ? String(req.body.reason) : undefined });
144
+ if (!r.ok)
145
+ return res.status(404).json({ ok: false, error: r.error });
146
+ res.json({ ok: true, evidenceRef: r.evidenceRef, affectedGoals: r.affectedGoals });
147
+ }
148
+ catch (err) {
149
+ res.status(500).json({ ok: false, error: String(err?.message || err) });
150
+ }
151
+ });
152
+ // ── 任务级授权 (把联系方式绑定到某个长期任务) ───────────────────────────────
153
+ app.post('/api/contacts/:id/authorize', async (req, res) => {
154
+ try {
155
+ const c = chainOf(req);
156
+ const r = await c.authorizeForTask({ contactId: String(req.params.id), goalId: String(req.body?.goalId || ''), by: String(req.body?.by || 'user') });
157
+ if (!r.ok)
158
+ return res.status(400).json({ ok: false, error: r.error });
159
+ res.json({ ok: true, contactId: r.contact.contactId, consentScope: r.contact.consentScope });
160
+ }
161
+ catch (err) {
162
+ res.status(500).json({ ok: false, error: String(err?.message || err) });
163
+ }
164
+ });
165
+ // ── 预览 (不发送) ──────────────────────────────────────────────────────────
166
+ app.post('/api/contacts/preview', async (req, res) => {
167
+ try {
168
+ const c = chainOf(req);
169
+ const r = await c.preview({ contactId: String(req.body?.contactId || ''), goalId: req.body?.goalId ? String(req.body.goalId) : undefined, subject: req.body?.subject ? String(req.body.subject) : undefined, body: String(req.body?.body || '') });
170
+ res.json({ ok: true, allowed: r.decision.allowed, requiresApproval: r.decision.requiresApproval, blockKind: r.decision.blockKind, reallySent: r.decision.reallySent, preview: r.text });
171
+ }
172
+ catch (err) {
173
+ res.status(500).json({ ok: false, error: String(err?.message || err) });
174
+ }
175
+ });
176
+ // ── 发送 (走 policy; 需要批准就返回 awaiting_approval) ───────────────────────
177
+ app.post('/api/contacts/send', async (req, res) => {
178
+ try {
179
+ const c = chainOf(req);
180
+ const r = await c.send({
181
+ contactId: String(req.body?.contactId || ''),
182
+ goalId: req.body?.goalId ? String(req.body.goalId) : undefined,
183
+ runId: req.body?.runId ? String(req.body.runId) : undefined,
184
+ subject: req.body?.subject ? String(req.body.subject) : undefined,
185
+ body: String(req.body?.body || ''),
186
+ requestId: req.body?.requestId ? String(req.body.requestId) : undefined,
187
+ replyExpected: req.body?.replyExpected !== false,
188
+ });
189
+ const code = r.status === 'denied' ? 409 : r.status === 'failed' ? 502 : 200;
190
+ res.status(code).json({ ok: r.status === 'sent' || r.status === 'awaiting_reply', ...r });
191
+ }
192
+ catch (err) {
193
+ res.status(500).json({ ok: false, error: String(err?.message || err) });
194
+ }
195
+ });
196
+ // ── 批准 / 拒绝 (人工授权) ─────────────────────────────────────────────────
197
+ app.post('/api/contacts/approvals/:id/approve', async (req, res) => {
198
+ try {
199
+ const c = chainOf(req);
200
+ const r = await c.approveAndSend(String(req.params.id), { by: String(req.body?.by || 'user'), via: 'web', body: req.body?.body ? String(req.body.body) : undefined });
201
+ const code = r.ok ? 200 : (String(r.error || '').includes('not_found') ? 404 : 400);
202
+ res.status(code).json({ ok: r.ok, error: r.error, status: r.status, requestId: r.requestId });
203
+ }
204
+ catch (err) {
205
+ res.status(500).json({ ok: false, error: String(err?.message || err) });
206
+ }
207
+ });
208
+ app.post('/api/contacts/approvals/:id/reject', async (req, res) => {
209
+ try {
210
+ const c = chainOf(req);
211
+ const r = await c.consents.reject(String(req.params.id), { by: String(req.body?.by || 'user'), reason: req.body?.reason ? String(req.body.reason) : undefined });
212
+ const code = r.ok ? 200 : (String(r.error || '').includes('not_found') ? 404 : 400);
213
+ const cons = r.consent;
214
+ if (cons) {
215
+ await c.store.appendLedger({
216
+ ts: new Date().toISOString(), activity: 'contact.rejected', contactId: cons.contactId, goalId: cons.goalId,
217
+ detail: `人工拒绝 (by ${String(req.body?.by || 'user')}${req.body?.reason ? `, ${req.body.reason}` : ''}) · consent=${cons.consentId}`,
218
+ });
219
+ }
220
+ res.status(code).json({ ok: r.ok, error: r.error });
221
+ }
222
+ catch (err) {
223
+ res.status(500).json({ ok: false, error: String(err?.message || err) });
224
+ }
225
+ });
226
+ // ── 回复入口 (通道回调 / 手机转投): 来源校验不过就不唤醒、不入证据 ──────────────
227
+ app.post('/api/contacts/reply', async (req, res) => {
228
+ try {
229
+ const c = chainOf(req);
230
+ const r = await c.reply({
231
+ requestId: req.body?.requestId ? String(req.body.requestId) : undefined,
232
+ threadToken: req.body?.threadToken ? String(req.body.threadToken) : undefined,
233
+ from: String(req.body?.from || ''),
234
+ body: String(req.body?.body || ''),
235
+ eventId: req.body?.eventId ? String(req.body.eventId) : undefined,
236
+ });
237
+ const code = r.ok ? 200 : (r.reason === 'source_untrusted' ? 403 : 404);
238
+ res.status(code).json({ ok: r.ok, woke: r.woke, goalId: r.goalId, reason: r.reason, evidenceRef: r.evidenceRef });
239
+ }
240
+ catch (err) {
241
+ res.status(500).json({ ok: false, error: String(err?.message || err) });
242
+ }
243
+ });
244
+ // ── 手机—桌面配对 (Phase 5): 只同步 capability, 不同步明文/密钥 ───────────────
245
+ app.post('/api/contacts/pairing/challenge', async (_req, res) => {
246
+ const now = Date.now();
247
+ const pairingId = `pair-${now.toString(36)}-${crypto.randomBytes(3).toString('hex')}`;
248
+ const p = { pairingId, code: String(crypto.randomInt(0, 1_000_000)).padStart(6, '0'), createdAt: now, expiresAt: now + PAIRING_TTL_MS };
249
+ pairs.set(pairingId, p);
250
+ res.json({ ok: true, pairingId, code: p.code, expiresAt: p.expiresAt, note: '手机扫码/深链确认后调 /confirm; 挑战 5 分钟有效且一次性' });
251
+ });
252
+ app.post('/api/contacts/pairing/confirm', async (req, res) => {
253
+ try {
254
+ const pairingId = String(req.body?.pairingId || '');
255
+ const p = pairs.get(pairingId);
256
+ if (!p)
257
+ return res.status(404).json({ ok: false, error: 'pairing_not_found' });
258
+ if (p.usedAt)
259
+ return res.status(409).json({ ok: false, error: 'pairing_already_used' });
260
+ if (p.expiresAt < Date.now())
261
+ return res.status(410).json({ ok: false, error: 'pairing_expired' });
262
+ if (String(req.body?.code || '') !== p.code)
263
+ return res.status(401).json({ ok: false, error: 'pairing_code_mismatch' });
264
+ // 明文字段直接拒绝 —— 同步只允许传 capability 视图
265
+ const incoming = req.body?.contacts;
266
+ const leak = looksLikePlaintextSecret(incoming);
267
+ if (leak)
268
+ return res.status(400).json({ ok: false, error: `同步载荷含明文/秘密, 已拒绝: ${leak}` });
269
+ p.usedAt = Date.now();
270
+ p.deviceDid = req.body?.deviceDid ? String(req.body.deviceDid) : undefined;
271
+ pairs.set(pairingId, p);
272
+ const accepted = [];
273
+ const rejected = [];
274
+ const c = chainOf(req);
275
+ const allowedKeys = ['contactId', 'kind', 'displayValue', 'verificationStatus', 'capabilities', 'consentScope', 'provider', 'secretRef', 'state'];
276
+ for (const item of Array.isArray(incoming) ? incoming : []) {
277
+ const clean = {};
278
+ for (const k of allowedKeys)
279
+ if (item && k in item)
280
+ clean[k] = item[k];
281
+ const known = clean.contactId ? await c.store.getContact(String(clean.contactId)) : null;
282
+ if (!known)
283
+ rejected.push({ contactId: clean.contactId, reason: '桌面端不认识这个 contactId (需要在桌面端绑定/接收能力)' });
284
+ else
285
+ accepted.push({ contactId: known.contactId, displayValue: known.displayValue, verificationStatus: known.verificationStatus, parity: 'capability-only' });
286
+ }
287
+ res.json({
288
+ ok: true, deviceDid: p.deviceDid, accepted, rejected,
289
+ note: '两端只对齐 contactId/状态/capability; 明文与密钥各自留在本机',
290
+ evidence: await c.store.appendLedger({
291
+ ts: new Date().toISOString(), activity: 'contact.discovered',
292
+ detail: `手机—桌面配对完成 (deviceDid=${p.deviceDid || '未提供'}), 接受 ${accepted.length} 项能力, 拒绝 ${rejected.length} 项`,
293
+ }),
294
+ });
295
+ }
296
+ catch (err) {
297
+ res.status(500).json({ ok: false, error: String(err?.message || err) });
298
+ }
299
+ });
300
+ // ── 持久能力授权 (Phase 3/4/8/10): CLI / Web / 手机 读同一份 Grant 事实 ──────
301
+ app.get('/api/contacts/grants', async (req, res) => {
302
+ try {
303
+ const c = chainOf(req);
304
+ const summary = await c.grantsSummary();
305
+ res.json({
306
+ ok: true,
307
+ ...summary,
308
+ choices: [
309
+ { id: 'task_once', label: '仅本次任务' },
310
+ { id: 'persistent', label: '长期使用 (推荐)' },
311
+ { id: 'full_contact_access', label: '完全授权联系方式能力' },
312
+ ],
313
+ willNotGet: ['读取全部邮箱', '读取通讯录', '群发消息', '自动支付/转账', '代表你签署合同', '拿到手机号/邮箱明文'],
314
+ forbiddenAlways: ['凭证 (密钥/密码)', '银行账号', '支付/转账指令', '合同承诺'],
315
+ });
316
+ }
317
+ catch (err) {
318
+ res.status(500).json({ ok: false, error: String(err?.message || err) });
319
+ }
320
+ });
321
+ app.post('/api/contacts/grants', async (req, res) => {
322
+ try {
323
+ const c = chainOf(req);
324
+ const choice = ['task_once', 'persistent', 'full_contact_access'].includes(String(req.body?.choice)) ? String(req.body.choice) : 'persistent';
325
+ const r = await c.authorize({
326
+ choice,
327
+ grantedBy: String(req.body?.by || 'user'),
328
+ grantedVia: req.body?.via === 'mobile' ? 'mobile' : req.body?.via === 'onboard' ? 'onboard' : 'web',
329
+ });
330
+ res.json({ ok: r.ok, userLabel: r.userLabel, grant: r.grant });
331
+ }
332
+ catch (err) {
333
+ res.status(500).json({ ok: false, error: String(err?.message || err) });
334
+ }
335
+ });
336
+ app.post('/api/contacts/grants/:id/:action', async (req, res) => {
337
+ try {
338
+ const c = chainOf(req);
339
+ const id = String(req.params.id);
340
+ const action = String(req.params.action);
341
+ const by = String(req.body?.by || 'user');
342
+ if (action === 'pause') {
343
+ const r = await c.pauseGrant(id, { by });
344
+ return res.status(r.ok ? 200 : 400).json({ ok: r.ok, error: r.error, grant: r.grant });
345
+ }
346
+ if (action === 'resume') {
347
+ const r = await c.resumeGrant(id, { by });
348
+ return res.status(r.ok ? 200 : 400).json({ ok: r.ok, error: r.error, grant: r.grant });
349
+ }
350
+ if (action === 'revoke') {
351
+ const r = await c.revokeGrant(id, { by, reason: req.body?.reason ? String(req.body.reason) : undefined });
352
+ return res.status(r.ok ? 200 : 400).json({ ok: r.ok, error: r.error, affectedGoals: r.affectedGoals });
353
+ }
354
+ res.status(400).json({ ok: false, error: `未知动作 ${action}` });
355
+ }
356
+ catch (err) {
357
+ res.status(500).json({ ok: false, error: String(err?.message || err) });
358
+ }
359
+ });
360
+ app.post('/api/contacts/grants/revoke-all', async (req, res) => {
361
+ try {
362
+ const c = chainOf(req);
363
+ const r = await c.revokeAllGrants({ by: String(req.body?.by || 'user'), reason: req.body?.reason ? String(req.body.reason) : undefined });
364
+ res.json({ ok: true, ...r });
365
+ }
366
+ catch (err) {
367
+ res.status(500).json({ ok: false, error: String(err?.message || err) });
368
+ }
369
+ });
370
+ /** 登记手机设备公钥 (配对阶段; 之后只接受它签名的授权) */
371
+ app.post('/api/contacts/devices', async (req, res) => {
372
+ try {
373
+ const c = chainOf(req);
374
+ const deviceId = String(req.body?.deviceId || '');
375
+ const publicKeyPem = String(req.body?.publicKeyPem || '');
376
+ if (!deviceId || !/BEGIN PUBLIC KEY/.test(publicKeyPem))
377
+ return res.status(400).json({ ok: false, error: 'deviceId/publicKeyPem 必填 (PEM 公钥)' });
378
+ const d = await c.registerDevice(deviceId, publicKeyPem, req.body?.label ? String(req.body.label) : undefined);
379
+ res.json({ ok: true, device: { deviceId: d.deviceId, label: d.label, registeredAt: d.registeredAt } });
380
+ }
381
+ catch (err) {
382
+ res.status(500).json({ ok: false, error: String(err?.message || err) });
383
+ }
384
+ });
385
+ /** 手机同步签名 Grant (Phase 4): 验签 → 版本冲突 → 撤销优先 */
386
+ app.post('/api/contacts/grants/sync', async (req, res) => {
387
+ try {
388
+ const c = chainOf(req);
389
+ const incoming = req.body?.grant;
390
+ if (!incoming?.grantId || !incoming?.signature)
391
+ return res.status(400).json({ ok: false, error: '需要带签名的 grant (grantId + signature)' });
392
+ const r = await c.syncGrant(incoming);
393
+ return res.status(r.ok ? 200 : 409).json({ ok: r.ok, code: r.code, reason: r.reason, grantId: r.grant?.grantId, grantVersion: r.grant?.grantVersion });
394
+ }
395
+ catch (err) {
396
+ res.status(500).json({ ok: false, error: String(err?.message || err) });
397
+ }
398
+ });
399
+ /** 手机撤销 → 桌面立即失效 */
400
+ app.post('/api/contacts/grants/revoke-sync', async (req, res) => {
401
+ try {
402
+ const c = chainOf(req);
403
+ const r = await c.syncRevocation(String(req.body?.grantId || ''), {
404
+ by: String(req.body?.by || 'mobile'),
405
+ reason: req.body?.reason ? String(req.body.reason) : undefined,
406
+ version: Number(req.body?.version) || undefined,
407
+ revokedAt: req.body?.revokedAt ? String(req.body.revokedAt) : undefined,
408
+ signature: req.body?.signature, // 手机签名 (有则验签, 未登记设备一律拒)
409
+ });
410
+ res.status(r.ok ? 200 : 400).json({ ok: r.ok, error: r.reason, grant: r.grant });
411
+ }
412
+ catch (err) {
413
+ res.status(500).json({ ok: false, error: String(err?.message || err) });
414
+ }
415
+ });
416
+ // ── 隐藏守卫自检 (验收/测试直接用) ──────────────────────────────────────────
417
+ app.get('/api/contacts/_selfcheck', (_req, res) => {
418
+ res.json({ ok: true, plaintextGuard: looksLikePlaintextSecret({ contacts: [{ value: '+8613812345678' }] }) });
419
+ });
420
+ }
@@ -13,6 +13,8 @@ import { registerJudgmentsRoutes } from './routes-judgments.js';
13
13
  import { registerLlmConfigRoutes } from './routes-llm-config.js';
14
14
  import { registerExternalEngineRoutes } from './routes-external-engines.js';
15
15
  import { registerTaskRoutes } from './routes-tasks.js';
16
+ // 2026-09-19: 联系方式 / 人工批准 / 手机—桌面配对 路由
17
+ import { registerContactRoutes } from './routes-contacts.js';
16
18
  // 2026-09-13: 微支付信息服务 (x402) 路由
17
19
  import { registerX402InfoRoutes } from './routes-x402-info.js';
18
20
  import { loadPeerTier, recordInteraction, tierLabel } from '../social/dunbar-tier.js';
@@ -6797,6 +6799,9 @@ fetchState();
6797
6799
  });
6798
6800
  // 2026-07-06: Task Queue API 抽到 ./routes-tasks.ts
6799
6801
  registerTaskRoutes(app, { broadcast, getAgentForChannel });
6802
+ // 2026-09-19: 社交身份 API (DID + 已验证联系方式 + 联系能力 + 权限 + 证据)
6803
+ // 只返回脱敏值; 明文联系方式不进 Run/prompt/Git
6804
+ registerContactRoutes(app, {});
6800
6805
  // 2026-09-13: 微支付信息服务 (x402) — 发布 / 402 收款 / 买方代付 / 验真
6801
6806
  registerX402InfoRoutes(app);
6802
6807
  // 2026-07-06: LLM/Video/Audio 配置路由抽到 ./routes-llm-config.ts
@@ -8216,6 +8221,35 @@ fetchState();
8216
8221
  console.warn('[24h] Failed to load heartbeat modules:', err);
8217
8222
  }
8218
8223
  // 健康检查端点
8224
+ // 2026-09-18: 网络脉冲公开只读投影 (给 bolloon-UI 网关页; 无认证, 短缓存, 只有聚合)
8225
+ // 永远不返回原始事件 / registry / DID / peerId / IP / 钱包 / 任务正文。
8226
+ app.get('/api/public/network/progress', async (req, res) => {
8227
+ try {
8228
+ const np = await import('../agents/network-pulse.js');
8229
+ const snap = await np.getNetworkPulse({});
8230
+ // agent_sites = 本节点**显式发布**的公开指针 (IPNS); 没发布就是空数组, 不猜
8231
+ const body = JSON.stringify({ ...snap, status: np.snapshotStatus(snap), agent_sites: np.readAgentSites() });
8232
+ const { createHash } = await import('crypto');
8233
+ const etag = '"' + createHash('sha256').update(body).digest('hex').slice(0, 32) + '"';
8234
+ res.setHeader('Cache-Control', 'public, max-age=15, stale-while-revalidate=15');
8235
+ res.setHeader('ETag', etag);
8236
+ if (req.headers['if-none-match'] === etag) {
8237
+ res.status(304).end();
8238
+ return;
8239
+ }
8240
+ res.type('application/json').send(body);
8241
+ }
8242
+ catch (e) {
8243
+ // 观察层不可用 → 如实 unavailable (不是 500 空体, 也不是"网络为空")
8244
+ try {
8245
+ const np = await import('../agents/network-pulse.js');
8246
+ res.type('application/json').send(JSON.stringify(await np.getNetworkPulse({ unavailable: true })));
8247
+ }
8248
+ catch {
8249
+ res.status(503).json({ status: 'unavailable', error: 'network pulse unavailable' });
8250
+ }
8251
+ }
8252
+ });
8219
8253
  app.get('/api/health', async (req, res) => {
8220
8254
  try {
8221
8255
  if (!healthMonitor) {