@bolloon/bolloon-agent 0.4.29 → 0.4.30
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/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/pi-sdk.js +10 -0
- 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/external-events.js.map +1 -1
- package/dist/electron-build/agents/goal-store.js.map +1 -1
- 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/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 +5 -0
- package/package.json +1 -1
package/dist/web/mobile.js
CHANGED
|
@@ -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) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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
|
+
}
|
package/dist/web/server.js
CHANGED
|
@@ -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
|