@bolloon/bolloon-agent 0.4.33 → 0.5.0

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 (143) hide show
  1. package/dist/agents/contacts/chain.js +27 -7
  2. package/dist/agents/execution-supervisor.js +493 -35
  3. package/dist/agents/external-events.js +35 -6
  4. package/dist/agents/gateway-group.js +100 -12
  5. package/dist/agents/goal-criteria.js +10 -3
  6. package/dist/agents/goal-flywheel/block-executor.js +346 -0
  7. package/dist/agents/goal-flywheel/continuation-decision.js +592 -0
  8. package/dist/agents/goal-flywheel/goal-change.js +754 -0
  9. package/dist/agents/goal-flywheel/index.js +26 -0
  10. package/dist/agents/goal-flywheel/memory-layers.js +227 -0
  11. package/dist/agents/goal-flywheel/run-closure.js +809 -0
  12. package/dist/agents/goal-flywheel/skill-candidate.js +628 -0
  13. package/dist/agents/goal-flywheel/types.js +329 -0
  14. package/dist/agents/goal-flywheel/wiring/change.js +288 -0
  15. package/dist/agents/goal-flywheel/wiring/closure.js +697 -0
  16. package/dist/agents/goal-flywheel/wiring/continuation.js +432 -0
  17. package/dist/agents/goal-flywheel/wiring/contract.js +270 -0
  18. package/dist/agents/goal-flywheel/wiring/index.js +26 -0
  19. package/dist/agents/goal-flywheel/wiring/monitor.js +254 -0
  20. package/dist/agents/goal-flywheel/wiring/seams.js +592 -0
  21. package/dist/agents/goal-flywheel/work-contract.js +478 -0
  22. package/dist/agents/goal-flywheel/work-monitor.js +488 -0
  23. package/dist/agents/goal-flywheel-wiring.js +1870 -0
  24. package/dist/agents/goal-state-reducer.js +406 -0
  25. package/dist/agents/goal-store.js +35 -1
  26. package/dist/agents/mobile-flywheel-view.js +144 -0
  27. package/dist/agents/mobile-task-actions-verify.js +74 -0
  28. package/dist/agents/mobile-task-actions.js +178 -0
  29. package/dist/agents/mobile-task-views.js +199 -0
  30. package/dist/agents/network-pulse.js +304 -18
  31. package/dist/agents/pi-sdk.js +75 -20
  32. package/dist/agents/run-store.js +18 -5
  33. package/dist/agents/skill-readiness.js +52 -10
  34. package/dist/agents/subagent-manager.js +209 -3
  35. package/dist/agents/task/task-runner.js +9 -3
  36. package/dist/agents/task-group.js +8 -51
  37. package/dist/agents/task-public-text.js +89 -0
  38. package/dist/cli/commands/index.js +1 -0
  39. package/dist/cli/commands/tasks.js +350 -2
  40. package/dist/cli/identity-command.js +177 -0
  41. package/dist/cli/setup-wizard.js +71 -0
  42. package/dist/cli/update-commands.js +150 -24
  43. package/dist/cli-entry.js +12 -2
  44. package/dist/electron-build/agents/contacts/chain.js +26 -6
  45. package/dist/electron-build/agents/contacts/chain.js.map +1 -1
  46. package/dist/electron-build/agents/execution-supervisor.js +492 -34
  47. package/dist/electron-build/agents/execution-supervisor.js.map +1 -1
  48. package/dist/electron-build/agents/external-events.js +34 -5
  49. package/dist/electron-build/agents/external-events.js.map +1 -1
  50. package/dist/electron-build/agents/goal-criteria.js +10 -3
  51. package/dist/electron-build/agents/goal-criteria.js.map +1 -1
  52. package/dist/electron-build/agents/goal-flywheel/continuation-decision.js +598 -0
  53. package/dist/electron-build/agents/goal-flywheel/continuation-decision.js.map +1 -0
  54. package/dist/electron-build/agents/goal-flywheel/goal-change.js +773 -0
  55. package/dist/electron-build/agents/goal-flywheel/goal-change.js.map +1 -0
  56. package/dist/electron-build/agents/goal-flywheel/memory-layers.js +269 -0
  57. package/dist/electron-build/agents/goal-flywheel/memory-layers.js.map +1 -0
  58. package/dist/electron-build/agents/goal-flywheel/run-closure.js +818 -0
  59. package/dist/electron-build/agents/goal-flywheel/run-closure.js.map +1 -0
  60. package/dist/electron-build/agents/goal-flywheel/skill-candidate.js +642 -0
  61. package/dist/electron-build/agents/goal-flywheel/skill-candidate.js.map +1 -0
  62. package/dist/electron-build/agents/goal-flywheel/types.js +333 -0
  63. package/dist/electron-build/agents/goal-flywheel/types.js.map +1 -0
  64. package/dist/electron-build/agents/goal-flywheel/wiring/change.js +294 -0
  65. package/dist/electron-build/agents/goal-flywheel/wiring/change.js.map +1 -0
  66. package/dist/electron-build/agents/goal-flywheel/wiring/closure.js +742 -0
  67. package/dist/electron-build/agents/goal-flywheel/wiring/closure.js.map +1 -0
  68. package/dist/electron-build/agents/goal-flywheel/wiring/continuation.js +444 -0
  69. package/dist/electron-build/agents/goal-flywheel/wiring/continuation.js.map +1 -0
  70. package/dist/electron-build/agents/goal-flywheel/wiring/contract.js +279 -0
  71. package/dist/electron-build/agents/goal-flywheel/wiring/contract.js.map +1 -0
  72. package/dist/electron-build/agents/goal-flywheel/wiring/index.js +111 -0
  73. package/dist/electron-build/agents/goal-flywheel/wiring/index.js.map +1 -0
  74. package/dist/electron-build/agents/goal-flywheel/wiring/monitor.js +264 -0
  75. package/dist/electron-build/agents/goal-flywheel/wiring/monitor.js.map +1 -0
  76. package/dist/electron-build/agents/goal-flywheel/wiring/seams.js +612 -0
  77. package/dist/electron-build/agents/goal-flywheel/wiring/seams.js.map +1 -0
  78. package/dist/electron-build/agents/goal-flywheel/work-contract.js +488 -0
  79. package/dist/electron-build/agents/goal-flywheel/work-contract.js.map +1 -0
  80. package/dist/electron-build/agents/goal-flywheel/work-monitor.js +501 -0
  81. package/dist/electron-build/agents/goal-flywheel/work-monitor.js.map +1 -0
  82. package/dist/electron-build/agents/goal-flywheel-wiring.js +1962 -0
  83. package/dist/electron-build/agents/goal-flywheel-wiring.js.map +1 -0
  84. package/dist/electron-build/agents/goal-state-reducer.js +414 -0
  85. package/dist/electron-build/agents/goal-state-reducer.js.map +1 -0
  86. package/dist/electron-build/agents/goal-store.js +35 -1
  87. package/dist/electron-build/agents/goal-store.js.map +1 -1
  88. package/dist/electron-build/agents/network-pulse.js +307 -19
  89. package/dist/electron-build/agents/network-pulse.js.map +1 -1
  90. package/dist/electron-build/agents/pi-sdk.js +74 -19
  91. package/dist/electron-build/agents/pi-sdk.js.map +1 -1
  92. package/dist/electron-build/agents/run-store.js +20 -5
  93. package/dist/electron-build/agents/run-store.js.map +1 -1
  94. package/dist/electron-build/agents/skill-readiness.js +52 -9
  95. package/dist/electron-build/agents/skill-readiness.js.map +1 -1
  96. package/dist/electron-build/agents/task/task-runner.js +8 -2
  97. package/dist/electron-build/agents/task/task-runner.js.map +1 -1
  98. package/dist/electron-build/orbitdb/cid-database.js +83 -17
  99. package/dist/electron-build/orbitdb/cid-database.js.map +1 -1
  100. package/dist/electron-build/orbitdb/ipfs-node.js +31 -2
  101. package/dist/electron-build/orbitdb/ipfs-node.js.map +1 -1
  102. package/dist/electron-build/utils/dual-source.js +471 -0
  103. package/dist/electron-build/utils/dual-source.js.map +1 -0
  104. package/dist/electron-build/utils/update-manager.js +331 -57
  105. package/dist/electron-build/utils/update-manager.js.map +1 -1
  106. package/dist/electron-build/utils/update-state.js +16 -1
  107. package/dist/electron-build/utils/update-state.js.map +1 -1
  108. package/dist/electron-build/utils/version-info.js +82 -2
  109. package/dist/electron-build/utils/version-info.js.map +1 -1
  110. package/dist/index.js +103 -12
  111. package/dist/ios/chain-explorer.js +617 -0
  112. package/dist/ios/explorer.html +163 -0
  113. package/dist/ios/index.html +96 -0
  114. package/dist/ios/mobile-agent.js +2 -2
  115. package/dist/ios/mobile-contacts.js +329 -0
  116. package/dist/ios/mobile-core.js +1379 -226
  117. package/dist/ios/mobile-privacy.js +185 -0
  118. package/dist/ios/mobile.css +19 -0
  119. package/dist/ios/mobile.html +96 -0
  120. package/dist/ios/mobile.js +706 -7
  121. package/dist/ios/routes-chain-index.js +108 -0
  122. package/dist/ios/routes-contacts.js +420 -0
  123. package/dist/ios/routes-onchain-trade.js +99 -0
  124. package/dist/ios/routes-x402-info.js +1 -0
  125. package/dist/ios/server.js +762 -0
  126. package/dist/ios/task-frame-server.js +211 -0
  127. package/dist/orbitdb/cid-database.js +81 -16
  128. package/dist/orbitdb/ipfs-node.js +31 -2
  129. package/dist/utils/dual-source.js +426 -0
  130. package/dist/utils/update-cli.js +16 -21
  131. package/dist/utils/update-health.js +35 -5
  132. package/dist/utils/update-manager.js +329 -57
  133. package/dist/utils/update-state.js +16 -1
  134. package/dist/utils/version-info.js +75 -1
  135. package/dist/web/mobile-contacts.js +44 -11
  136. package/dist/web/mobile-core.js +1197 -513
  137. package/dist/web/mobile-tasks.js +339 -0
  138. package/dist/web/mobile.css +19 -0
  139. package/dist/web/mobile.html +64 -0
  140. package/dist/web/mobile.js +531 -1
  141. package/dist/web/routes-mobile-tasks.js +353 -0
  142. package/dist/web/server.js +163 -4
  143. package/package.json +3 -1
@@ -0,0 +1,108 @@
1
+ /**
2
+ * routes-chain-index.ts — 链上索引的**只读**HTTP 路由 (P5, ③)
3
+ * =========================================================================
4
+ * 刻意独立成文件, **不碰 src/web/server.ts**: 挂载方法见文件末尾注释。
5
+ * 本模块只暴露**读**接口 (读已落盘索引), 全都不发 RPC、不写盘、不签名:
6
+ *
7
+ * GET /api/chain/index/status
8
+ * → 当前索引高度 (lastSyncedBlock) / 最后同步时间 / head / 确认数门槛 / 页大小
9
+ * GET /api/chain/index/stats
10
+ * → 全量统计: tasks / created / proofSubmitted / released / refunded / disputed / expired
11
+ * + byFinality (observed|confirmed|finalized) + suspects (被回退的条数)
12
+ * GET /api/chain/index/timeline?taskKey=0x…(64hex)
13
+ * → 该 escrow 的事件时间线 (含块号/块哈希/logIndex/解码 args/确认数/finality/suspect)
14
+ * GET /api/chain/index/events?blockNumber=..&logIndex=..&limit=..
15
+ * → 按 cursor 增量拉取 (严格大于 cursor 的 (blockNumber, logIndex)); 无 cursor = 从头
16
+ *
17
+ * 挂载 (在 src/web/server.ts 里加两行即可, 本文件不改它):
18
+ * import { registerChainIndexRoutes } from './routes-chain-index.js';
19
+ * registerChainIndexRoutes(app); // app: express.Express (或等价 router 宿主)
20
+ *
21
+ * 挂载点可选前缀: registerChainIndexRoutes(app, { basePath: '/api/chain/index' })
22
+ * 读 SQLite/文件直接读 ~/.bolloon/chain/index.json; 换 HOME 用 opts.home (测试隔离)。
23
+ * 若还想在网页上触发同步: registerChainIndexRoutes(app, { allowSync: true })
24
+ * → 多一个 POST /api/chain/index/sync (body: { fromBlock?: number })。默认关 —— 写操作不默认开放。
25
+ */
26
+ import { getIndexStatus, getIndexStats, getEscrowTimeline, fetchIndexSince, } from '../agents/chain/chain-index-query.js';
27
+ const isTaskKey = (v) => typeof v === 'string' && /^0x[0-9a-fA-F]{64}$/.test(v);
28
+ export function registerChainIndexRoutes(app, opts = {}) {
29
+ const base = opts.basePath || '/api/chain/index';
30
+ const home = opts.home;
31
+ // ① 当前索引高度 + 最后同步时间
32
+ app.get(`${base}/status`, (_req, res) => {
33
+ try {
34
+ res.json({ ok: true, status: getIndexStatus({ home }) });
35
+ }
36
+ catch (e) {
37
+ res.status(500).json({ ok: false, error: String(e?.message || e) });
38
+ }
39
+ });
40
+ // ② 全量统计
41
+ app.get(`${base}/stats`, (_req, res) => {
42
+ try {
43
+ res.json({ ok: true, stats: getIndexStats({ home }) });
44
+ }
45
+ catch (e) {
46
+ res.status(500).json({ ok: false, error: String(e?.message || e) });
47
+ }
48
+ });
49
+ // ③ 按 taskKey 查 escrow 时间线
50
+ app.get(`${base}/timeline`, (req, res) => {
51
+ const taskKey = String(req.query?.taskKey || '');
52
+ if (!isTaskKey(taskKey)) {
53
+ return res.status(400).json({ ok: false, error: 'taskKey 必须是 0x + 64 hex' });
54
+ }
55
+ try {
56
+ const tl = getEscrowTimeline(taskKey, { home });
57
+ res.json({ ok: true, timeline: tl });
58
+ }
59
+ catch (e) {
60
+ res.status(500).json({ ok: false, error: String(e?.message || e) });
61
+ }
62
+ });
63
+ // ④ 按 cursor 增量拉取
64
+ app.get(`${base}/events`, (req, res) => {
65
+ const q = req.query || {};
66
+ const hasCursor = q.blockNumber !== undefined && q.blockNumber !== '';
67
+ let cursor = null;
68
+ if (hasCursor) {
69
+ const bn = Number(q.blockNumber);
70
+ const li = q.logIndex === undefined || q.logIndex === '' ? 0 : Number(q.logIndex);
71
+ if (!Number.isInteger(bn) || bn < 0 || !Number.isInteger(li) || li < 0) {
72
+ return res.status(400).json({ ok: false, error: 'cursor 非法: 需要 blockNumber>=0, logIndex>=0' });
73
+ }
74
+ cursor = { blockNumber: bn, logIndex: li };
75
+ }
76
+ const limit = q.limit === undefined || q.limit === '' ? 200 : Number(q.limit);
77
+ if (!Number.isInteger(limit) || limit < 1) {
78
+ return res.status(400).json({ ok: false, error: 'limit 必须 >= 1 的整数' });
79
+ }
80
+ try {
81
+ res.json({ ok: true, page: fetchIndexSince(cursor, { home, limit }) });
82
+ }
83
+ catch (e) {
84
+ res.status(500).json({ ok: false, error: String(e?.message || e) });
85
+ }
86
+ });
87
+ // 可选: 触发一次同步 (显式 opt-in)
88
+ if (opts.allowSync) {
89
+ app.post(`${base}/sync`, async (req, res) => {
90
+ if (!opts.sync)
91
+ return res.status(501).json({ ok: false, error: '未注入 sync 实现' });
92
+ const fromBlockRaw = req.body?.fromBlock;
93
+ let fromBlock;
94
+ if (fromBlockRaw !== undefined && fromBlockRaw !== null && fromBlockRaw !== '') {
95
+ const n = Number(fromBlockRaw);
96
+ if (!Number.isInteger(n) || n < 0)
97
+ return res.status(400).json({ ok: false, error: 'fromBlock 必须 >= 0 的整数' });
98
+ fromBlock = n;
99
+ }
100
+ try {
101
+ res.json({ ok: true, result: await opts.sync(fromBlock) });
102
+ }
103
+ catch (e) {
104
+ res.status(500).json({ ok: false, error: String(e?.message || e) });
105
+ }
106
+ });
107
+ }
108
+ }
@@ -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
+ }
@@ -0,0 +1,99 @@
1
+ /**
2
+ * routes-onchain-trade.ts — 链上任务交易闭环的**只读**HTTP 路由 (P4, ②③)
3
+ * =========================================================================
4
+ * 刻意独立成文件, **不碰 src/web/server.ts** (另一个并行任务在动它)。
5
+ * 只暴露**读**接口 —— 全都不发交易、不签名、不改链、不写盘:
6
+ *
7
+ * GET /api/chain/trade/state?taskKey=0x…(64hex) | taskId=…
8
+ * → 该任务在 chain-state.json 里的全部链上记录 (方法/状态/确认数/是否可疑/结果哈希)
9
+ * GET /api/chain/trade/recovery?taskKey=… | taskId=…&pendingResultDigest=sha256:…
10
+ * → ★ 重启恢复决策: nextAction / mustNotRepay / verified / 为什么 (纯读盘, 不联网)
11
+ * GET /api/chain/trade/budget?amount=0.02&taskBudget=0.05
12
+ * → M1 预算硬约束预检 (单任务 0.05 / 单次 0.02): 放行 / 哪一层拦下
13
+ *
14
+ * 挂载 (在 src/web/server.ts 里加两行即可, 本文件不改它):
15
+ * import { registerOnchainTradeRoutes } from './routes-onchain-trade.js';
16
+ * registerOnchainTradeRoutes(app); // app: express.Express
17
+ *
18
+ * 挂载点可选: registerOnchainTradeRoutes(app, { basePath: '/api/chain/trade' })
19
+ * 换 HOME 用 opts.home (测试隔离 / 多 profile)。**没有**任何写接口 ——
20
+ * 链上写操作必须走 CLI/任务链路 (那里有签名放行闸 + 审计), 不通过 HTTP 裸开。
21
+ */
22
+ import * as os from 'os';
23
+ import { recoverOnchainTrade, checkOnchainAmount, onchainTaskKey, } from '../agents/chain/onchain-trade.js';
24
+ import { loadChainState, chainStatePath } from '../agents/chain/chain-state-store.js';
25
+ const isTaskKey = (v) => typeof v === 'string' && /^0x[0-9a-fA-F]{64}$/.test(v);
26
+ function homeOf(opts) {
27
+ return opts.home || process.env.HOME || os.homedir();
28
+ }
29
+ export function registerOnchainTradeRoutes(app, opts = {}) {
30
+ const base = opts.basePath || '/api/chain/trade';
31
+ // ① 链上记录 (按 taskKey / taskId 过滤; 不过滤 = 全部)
32
+ app.get(`${base}/state`, (req, res) => {
33
+ try {
34
+ const home = homeOf(opts);
35
+ const taskKey = String(req.query?.taskKey || '');
36
+ const taskId = String(req.query?.taskId || '');
37
+ if (taskKey && !isTaskKey(taskKey))
38
+ return res.status(400).json({ ok: false, error: 'taskKey 必须是 0x + 64 hex' });
39
+ const want = (taskKey || (taskId ? onchainTaskKey(taskId) : '')).toLowerCase();
40
+ const all = Object.values(loadChainState(home).records);
41
+ const records = want ? all.filter((r) => String(r.taskKey).toLowerCase() === want) : all;
42
+ res.json({
43
+ ok: true,
44
+ statePath: chainStatePath(home),
45
+ taskKey: want || null,
46
+ count: records.length,
47
+ records,
48
+ });
49
+ }
50
+ catch (e) {
51
+ res.status(500).json({ ok: false, error: String(e?.message || e) });
52
+ }
53
+ });
54
+ // ② 重启恢复决策 (纯读盘; 不联网 → 不会把"读不到 RPC"当成"没付款")
55
+ app.get(`${base}/recovery`, (req, res) => {
56
+ try {
57
+ const home = homeOf(opts);
58
+ const q = req.query || {};
59
+ const taskKey = String(q.taskKey || '');
60
+ const taskId = String(q.taskId || '');
61
+ if (taskKey && !isTaskKey(taskKey))
62
+ return res.status(400).json({ ok: false, error: 'taskKey 必须是 0x + 64 hex' });
63
+ if (!taskKey && !taskId)
64
+ return res.status(400).json({ ok: false, error: '要 taskKey 或 taskId' });
65
+ const recovery = recoverOnchainTrade({
66
+ home,
67
+ ...(taskKey ? { taskKey } : {}),
68
+ ...(taskId ? { taskId } : {}),
69
+ ...(q.pendingResultDigest ? { pendingResultDigest: String(q.pendingResultDigest) } : {}),
70
+ });
71
+ res.json({ ok: true, recovery });
72
+ }
73
+ catch (e) {
74
+ res.status(500).json({ ok: false, error: String(e?.message || e) });
75
+ }
76
+ });
77
+ // ③ M1 预算硬约束预检 (付款前必须能看得到; 纯函数, 不碰钱)
78
+ app.get(`${base}/budget`, (req, res) => {
79
+ try {
80
+ const q = req.query || {};
81
+ const amount = Number(q.amount);
82
+ if (!Number.isFinite(amount) || amount <= 0)
83
+ return res.status(400).json({ ok: false, error: 'amount 必须是正数 (USDC)' });
84
+ const decimals = Number.isFinite(Number(q.decimals)) ? Number(q.decimals) : 6;
85
+ const atomic = BigInt(Math.round(amount * 10 ** decimals));
86
+ const d = checkOnchainAmount({
87
+ amountAtomic: atomic, decimals,
88
+ budget: {
89
+ ...(q.taskBudget !== undefined ? { taskBudget: Number(q.taskBudget) } : {}),
90
+ ...(q.perPurchase !== undefined ? { perPurchase: Number(q.perPurchase) } : {}),
91
+ },
92
+ });
93
+ res.json({ ok: true, allowed: d.ok, layer: d.layer ?? null, reason: d.reason, amountUsdc: d.amountUsdc, budget: d.budget, why: d.why });
94
+ }
95
+ catch (e) {
96
+ res.status(500).json({ ok: false, error: String(e?.message || e) });
97
+ }
98
+ });
99
+ }
@@ -107,6 +107,7 @@ export function registerX402InfoRoutes(app) {
107
107
  const pay = await checkAndSettlePayment({
108
108
  paymentHeader: String(paymentHeader),
109
109
  requirements,
110
+ expectedItemId: stored.item.id, // 凭据必须绑定这条资源 (防跨资源复用)
110
111
  });
111
112
  if (!pay.ok) {
112
113
  return res.status(402).json({ ...requirements, error: pay.error });