@wacando/agent 0.0.0-stage → 2.0.1

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 (62) hide show
  1. package/.agents/plugins/marketplace.json +10 -0
  2. package/.claude-plugin/marketplace.json +12 -0
  3. package/.cursor-plugin/marketplace.json +12 -0
  4. package/README.md +166 -2
  5. package/STATUS.md +121 -0
  6. package/THIRD-PARTY-NOTICES.txt +1928 -0
  7. package/adapters/README.md +12 -0
  8. package/adapters/claude/mcp.mjs +7 -0
  9. package/adapters/codex/mcp.mjs +7 -0
  10. package/adapters/cursor/mcp.mjs +7 -0
  11. package/bin/wacando-agent.mjs +243 -0
  12. package/docs/AGENT_TOOLS.md +147 -0
  13. package/docs/CURSOR_IDENTITY_RESUME.md +579 -0
  14. package/docs/MCP-AUDIT.md +355 -0
  15. package/docs/MCP-CONTRACT.md +138 -0
  16. package/docs/MIGRATION-2.0.md +36 -0
  17. package/docs/OMEMO_PREKEY_INTEGRATION.md +125 -0
  18. package/docs/ONBOARDING-VALIDATION.md +88 -0
  19. package/docs/ONBOARDING.md +212 -0
  20. package/docs/ONBOARDING_PLUGIN_CONTRACT.md +156 -0
  21. package/docs/PLUGIN_HANDOFF_FOLLOWUP.md +178 -0
  22. package/docs/PUBLIC_ONBOARDING_READINESS.md +156 -0
  23. package/docs/RELEASE.md +52 -0
  24. package/docs/WACANDO_PACKAGES.md +26 -0
  25. package/package.json +66 -4
  26. package/packages/core/limits.mjs +24 -0
  27. package/packages/core/store.mjs +92 -0
  28. package/packages/core/validation.mjs +25 -0
  29. package/packages/mcp/agent-tools.mjs +82 -0
  30. package/packages/mcp/server.mjs +161 -0
  31. package/packages/mcp/stdio.mjs +42 -0
  32. package/packages/onboarding/network.mjs +60 -0
  33. package/packages/onboarding/service.mjs +235 -0
  34. package/packages/onboarding/storage.mjs +99 -0
  35. package/packages/onboarding/version.mjs +2 -0
  36. package/packages/runtime/conversations.mjs +433 -0
  37. package/packages/runtime/device.mjs +34 -0
  38. package/packages/runtime/index.mjs +8 -0
  39. package/packages/runtime/pairing.mjs +110 -0
  40. package/packages/runtime/recovery.mjs +49 -0
  41. package/packages/runtime/runtime.mjs +213 -0
  42. package/packages/runtime/session.mjs +52 -0
  43. package/packages/server/cli.mjs +18 -0
  44. package/packages/server/server.mjs +110 -0
  45. package/packages/server/stream.mjs +42 -0
  46. package/packages/web/app.js +85 -0
  47. package/packages/web/history.js +48 -0
  48. package/packages/web/index.html +17 -0
  49. package/packages/web/style.css +1 -0
  50. package/plugins/wacando/.codex-plugin/plugin.json +18 -0
  51. package/plugins/wacando/.mcp.json +8 -0
  52. package/plugins/wacando/skills/wacando/SKILL.md +33 -0
  53. package/plugins/wacando-claude/.claude-plugin/plugin.json +10 -0
  54. package/plugins/wacando-claude/.mcp.json +8 -0
  55. package/plugins/wacando-claude/README.md +88 -0
  56. package/plugins/wacando-claude/skills/open/SKILL.md +66 -0
  57. package/plugins/wacando-cursor/.cursor-plugin/plugin.json +11 -0
  58. package/plugins/wacando-cursor/README.md +48 -0
  59. package/plugins/wacando-cursor/commands/wacando-open.md +10 -0
  60. package/plugins/wacando-cursor/mcp.json +8 -0
  61. package/plugins/wacando-cursor/skills/wacando/SKILL.md +50 -0
  62. package/provenance.json +503 -0
@@ -0,0 +1,433 @@
1
+ import { NS_OMEMO } from '@wacando/omemo';
2
+ import { NS_WACANDO_POLICY, toPolicySubmission } from '@wacando/omemo/wire';
3
+ import {
4
+ authenticatedMessageOrigin, forwardedStanza, joinRoom, MucJoinError, MucOriginTracker, queryMam, unwrapPolicyDelivery,
5
+ } from '@wacando/xmpp';
6
+ import { ApiError } from '../onboarding/network.mjs';
7
+ import { SafeError } from '../onboarding/storage.mjs';
8
+ import { attachRecovery, recoveryLifecycle, recoveryView } from './recovery.mjs';
9
+
10
+ // Consent-aware conversations (lbframe/wacando docs/CONVERSATION_CONSENT.md):
11
+ // - a request's first message and every message of a gated (`service`) agent
12
+ // go to the policy component, which holds them for the owner's approval;
13
+ // - an accepted `human` agent is a room member and speaks freely;
14
+ // - only ACCEPTED conversations are ever read; direct 1:1 chat is never used.
15
+ // Message text is never logged; decrypt failures yield `text: null`.
16
+
17
+ export const HOLD_TTL_MS = 10 * 60 * 1000;
18
+ const SUBMISSION_TIMEOUT_MS = 15000;
19
+ const MAM_PAGE = 100;
20
+ const MAM_MAX_PAGES = 20;
21
+ const MAX_ENTRIES = 1000;
22
+ const DELETED = 'deleted_account';
23
+
24
+ export const consentErrors = {
25
+ notContact: 'This address is not an accepted contact of this agent. Ask its owner to accept a contact invitation first.',
26
+ unknown: 'Unknown or unreachable address. Check the handle (@owner/agent) with your owner.',
27
+ notAccepted: 'Conversation not found or not accepted. Only accepted conversations can be read.',
28
+ closed: state => `Conversation is ${state}; nothing was sent. A stopped or refused conversation never reopens.`,
29
+ waitingOther: 'This request waits for the other owner; only its requester sends the first message.',
30
+ firstSent: 'The first message of this request was already approved and delivered; wait for the recipient owner to accept.',
31
+ noDiscovery: 'Secure messaging is unavailable: this Wacando server does not provide recipient discovery (per-device observers). Nothing was sent; the server must be updated.',
32
+ noDevice: 'Secure messaging is unavailable: an owner or the other agent has no published secure device yet (the owner must open the Wacando desktop app). Nothing was sent; retry later.',
33
+ };
34
+
35
+ export function apiFailure(error) {
36
+ if (!(error instanceof ApiError)) return error;
37
+ const known = {
38
+ CONTACT_NOT_ACCEPTED: consentErrors.notContact,
39
+ CONTACT_REVOKED: 'The contact relationship was revoked. Nothing was sent.',
40
+ AGENT_NOT_REACHABLE: consentErrors.unknown,
41
+ AGENT_REVOKED: 'One of the agents is revoked. Nothing was sent.',
42
+ AGENT_OFFLINE: 'The other agent is offline. Retry when it is connected.',
43
+ CONVERSATION_CLOSED: 'Conversation is closed (stopped, refused or expired). Nothing was sent.',
44
+ CONVERSATION_NOT_ACTIVE: 'Conversation is not active. Nothing was sent.',
45
+ NOT_FOUND: consentErrors.notAccepted,
46
+ FORBIDDEN: 'Refused: the other agent is not reachable for new requests, or this agent may not act here. Nothing was sent.',
47
+ };
48
+ return new SafeError(known[error.code] ?? `Wacando API refused the request (${error.code ?? `HTTP ${error.status}`}).`);
49
+ }
50
+
51
+ /** Pure: which side of a conversation this agent is, and who must receive the ciphertext. */
52
+ export function conversationView(conversation, selfAgentId, domain) {
53
+ const self = conversation.agentAId === selfAgentId ? 'A' : conversation.agentBId === selfAgentId ? 'B' : null;
54
+ if (!self) throw new SafeError(consentErrors.notAccepted);
55
+ const peer = self === 'A' ? 'B' : 'A';
56
+ const peerAgentId = conversation[`agent${peer}Id`];
57
+ const recipients = [];
58
+ if (peerAgentId && peerAgentId !== DELETED) recipients.push(`agt_${peerAgentId}@${domain}`);
59
+ for (const owner of [conversation.ownerAId, conversation.ownerBId]) {
60
+ if (owner && owner !== DELETED) recipients.push(`usr_${owner}@${domain}`);
61
+ }
62
+ return {
63
+ id: conversation.id,
64
+ state: conversation.status,
65
+ accepted: conversation.activatedAt != null,
66
+ role: conversation.requestedByAgentId == null ? 'participant' : conversation.requestedByAgentId === selfAgentId ? 'requester' : 'recipient',
67
+ peerAgentId: peerAgentId === DELETED ? null : peerAgentId,
68
+ firstMessageDelivered: conversation.requestSentAt != null,
69
+ requestExpiresAt: conversation.requestExpiresAt ?? null,
70
+ recipients: [...new Set(recipients)],
71
+ agentIds: [conversation.agentAId, conversation.agentBId],
72
+ ownerIds: [conversation.ownerAId, conversation.ownerBId],
73
+ };
74
+ }
75
+
76
+ const BARE_JID = /^[^\s/@]+@[^\s/@]+$/;
77
+
78
+ /**
79
+ * Pure: validate recipient discovery (lbframe/wacando #35/#108,
80
+ * GET /v1/conversations/:id/recipients v1) and return every OMEMO recipient
81
+ * JID: both agents and every active installation of both owners. Anything
82
+ * unexpected fails closed; there is never a smaller fallback set.
83
+ */
84
+ export function discoveredRecipients(body, view) {
85
+ const r = body?.recipients;
86
+ const ownersPresent = owners => Array.isArray(r?.observers) &&
87
+ owners.every(owner => r.observers.some(o => o?.ownerUserId === owner));
88
+ const ok = r && r.version === 1 && r.conversationId === view.id && typeof r.revision === 'string' &&
89
+ Array.isArray(r.agents) && r.agents.length === 2 && r.agents.every(a => BARE_JID.test(a?.jid ?? '')) &&
90
+ [...view.agentIds].sort().join('|') === r.agents.map(a => a?.agentId).sort().join('|') &&
91
+ Array.isArray(r.observers) && r.observers.length > 0 && r.observers.every(o => BARE_JID.test(o?.jid ?? '')) &&
92
+ ownersPresent([...new Set(view.ownerIds)]);
93
+ if (!ok) throw new SafeError('Secure messaging is unavailable: the recipient list is malformed or misses a participant. Nothing was sent.');
94
+ return { revision: r.revision, jids: [...new Set([...r.agents, ...r.observers].map(x => x.jid))] };
95
+ }
96
+
97
+ /** Pure: public summary of a conversation for agents (no owner identifiers). */
98
+ export function publicConversation(view, extra = {}) {
99
+ const { recipients: _recipients, agentIds: _agents, ownerIds: _owners, ...rest } = view;
100
+ return { ...rest, ...extra };
101
+ }
102
+
103
+ /** Messaging for ONE connected identity. Created by AgentRuntime.conversations(). */
104
+ export class ConversationClient {
105
+ constructor({ runtime, agent, session }) {
106
+ this.runtime = runtime;
107
+ this.agent = agent;
108
+ this.session = session;
109
+ this.domain = agent.xmpp.domain;
110
+ this.selfJid = session.bareJid;
111
+ this.policyJid = `policy.${this.domain}`;
112
+ this.mucDomain = `conference.${this.domain}`;
113
+ this.nick = `agt-${agent.agentId.replace(/[^a-zA-Z0-9]/g, '').slice(-10)}`;
114
+ this.tracker = new MucOriginTracker(session.client, { trustedRelayJid: this.policyJid });
115
+ this.rooms = new Map(); // conversationId → 'member' | 'gated'
116
+ this.transcripts = new Map(); // conversationId → Map(stanzaId → entry)
117
+ this.claimed = new Set(); // stanza ids already decrypted once (Signal keys are single use)
118
+ this.sent = new Map(); // own stanza id → text, this process only (Signal cannot self-decrypt)
119
+ this.held = new Map(); // stanza id → submission status
120
+ this.submissions = new Map(); // stanza id → resolver for the component's status
121
+ this.waiters = new Set();
122
+ this.cancels = new Set();
123
+ this.chain = Promise.resolve();
124
+ // lbframe/wacando#50: consumed-prekey session recovery, gated by the
125
+ // conversation lifecycle seen through the API (and by STOP on the server).
126
+ this.lifecycles = new Map(); // conversationId → 'active' | 'closed' | 'unknown'
127
+ this.recoveryStates = new Map(); // conversationId → recovery view
128
+ this.recovery = this.attachRecovery(session);
129
+ this.lifecyclesLoaded = this.loadLifecycles();
130
+ }
131
+
132
+ /**
133
+ * Learn every conversation's state at start-up (and after a reconnection),
134
+ * so recovery is not refused just because no tool call happened yet.
135
+ * Failures leave the state unknown, which denies recovery (fail closed).
136
+ */
137
+ async loadLifecycles() {
138
+ if (typeof this.runtime?.api !== 'function') return;
139
+ try {
140
+ const { conversations } = await this.runtime.api(this.agent, 'GET', '/v1/conversations');
141
+ for (const c of conversations ?? []) {
142
+ try { this.observe(conversationView(c, this.agent.agentId, this.domain)); } catch { /* not ours */ }
143
+ }
144
+ } catch { /* unknown stays unknown */ }
145
+ }
146
+
147
+ attachRecovery(session) {
148
+ return attachRecovery({
149
+ session,
150
+ policyJid: this.policyJid,
151
+ lifecycle: conversationId => this.lifecycles.get(conversationId) ?? 'unknown',
152
+ onStatus: event => this.recoveryStates.set(event.conversationId, recoveryView(event)),
153
+ });
154
+ }
155
+
156
+ /** Record what the API says about a conversation; STOP is terminal for recovery too. */
157
+ observe(view) {
158
+ const lifecycle = recoveryLifecycle(view.state);
159
+ this.lifecycles.set(view.id, lifecycle);
160
+ if (lifecycle === 'closed') this.recovery?.cancelConversation(view.id);
161
+ return view;
162
+ }
163
+
164
+ /** Recovery state of a conversation, when there is one to report. */
165
+ recoveryOf(conversationId) {
166
+ const state = this.recoveryStates.get(conversationId);
167
+ return state ? { recovery: { ...state } } : {};
168
+ }
169
+
170
+ /** Own sent text, kept in memory only and bounded (Signal cannot self-decrypt). */
171
+ remember(id, text) {
172
+ this.sent.set(id, text);
173
+ while (this.sent.size > MAX_ENTRIES) this.sent.delete(this.sent.keys().next().value);
174
+ }
175
+
176
+ /** Continue on a new session of the same identity (after a reconnection). */
177
+ rebind(session) {
178
+ this.tracker.close();
179
+ this.recovery?.detach();
180
+ this.session = session;
181
+ this.recovery = this.attachRecovery(session);
182
+ this.lifecyclesLoaded = this.loadLifecycles();
183
+ this.tracker = new MucOriginTracker(session.client, { trustedRelayJid: this.policyJid });
184
+ this.rooms.clear();
185
+ for (const resolve of this.submissions.values()) resolve({ state: 'refused', reason: 'connection-lost' });
186
+ }
187
+
188
+ room(conversationId) { return `c_${conversationId}@${this.mucDomain}`; }
189
+ conversationOf(room) {
190
+ const m = /^c_(.+)@(.+)$/.exec(room ?? '');
191
+ return m && m[2] === this.mucDomain ? m[1] : null;
192
+ }
193
+
194
+ /** Called for every stanza of this identity (attached before the presence echo). */
195
+ handle(stanza) {
196
+ if (!stanza.is('message') || stanza.getAttr('type') === 'chat') return; // never a 1:1 channel
197
+ const from = stanza.getAttr('from');
198
+ const status = from === this.policyJid ? stanza.getChild('submission', NS_WACANDO_POLICY) : undefined;
199
+ if (status) {
200
+ this.submissions.get(status.getAttr('id'))?.(status.attrs);
201
+ return;
202
+ }
203
+ const delivered = unwrapPolicyDelivery(stanza, this.policyJid);
204
+ if (delivered) { this.ingest(delivered, 'delivery'); return; }
205
+ if (stanza.getAttr('type') === 'groupchat' && stanza.getChild('encrypted', NS_OMEMO) &&
206
+ !stanza.getChild('delay', 'urn:xmpp:delay')) {
207
+ // Let the origin tracker (a later listener) record provenance first.
208
+ setImmediate(() => this.ingest(stanza, 'live'));
209
+ }
210
+ }
211
+
212
+ ingest(stanza, source, stamp) {
213
+ const origin = authenticatedMessageOrigin(stanza);
214
+ const conversationId = this.conversationOf(origin?.roomJid);
215
+ const id = stanza.getAttr('id');
216
+ if (!origin || !conversationId || typeof id !== 'string') return Promise.resolve();
217
+ const task = this.chain.then(async () => {
218
+ const transcript = this.transcripts.get(conversationId) ?? new Map();
219
+ this.transcripts.set(conversationId, transcript);
220
+ if (transcript.has(id)) return;
221
+ const own = origin.senderBareJid === this.selfJid;
222
+ let text = null;
223
+ if (own) text = this.sent.get(id) ?? null;
224
+ else if (!this.claimed.has(id)) {
225
+ this.claimed.add(id);
226
+ text = await this.session.provider.decryptStanza(stanza, origin).catch(() => null);
227
+ }
228
+ if (this.held.has(id)) this.held.get(id).state = 'delivered';
229
+ const entry = { id, conversationId, sender: own ? 'self' : 'peer', at: stamp ?? new Date().toISOString(), text,
230
+ ...(text === null && !own ? { undecryptable: true } : {}), source };
231
+ transcript.set(id, entry);
232
+ while (transcript.size > MAX_ENTRIES) transcript.delete(transcript.keys().next().value);
233
+ if (!own) for (const waiter of [...this.waiters]) waiter(entry);
234
+ });
235
+ this.chain = task.catch(() => {});
236
+ return task;
237
+ }
238
+
239
+ async conversation(conversationId) {
240
+ try {
241
+ const { conversation } = await this.runtime.api(this.agent, 'GET', `/v1/conversations/${encodeURIComponent(conversationId)}`);
242
+ return this.observe(conversationView(conversation, this.agent.agentId, this.domain));
243
+ } catch (error) { throw apiFailure(error); }
244
+ }
245
+
246
+ /** Accepted conversations, plus this agent's own pending requests. */
247
+ async list() {
248
+ let conversations;
249
+ try { ({ conversations } = await this.runtime.api(this.agent, 'GET', '/v1/conversations')); }
250
+ catch (error) { throw apiFailure(error); }
251
+ const views = conversations.map(c => this.observe(conversationView(c, this.agent.agentId, this.domain)));
252
+ return {
253
+ accepted: views.filter(v => v.accepted).map(v => publicConversation(v, { mode: this.rooms.get(v.id) ?? 'unknown', ...this.recoveryOf(v.id) })),
254
+ requests: views.filter(v => !v.accepted && v.role === 'requester' && v.state === 'requested').map(v => publicConversation(v)),
255
+ };
256
+ }
257
+
258
+ /** Accepted contacts, resolved to handles where the directory shows them. */
259
+ async contacts() {
260
+ try {
261
+ const { contacts } = await this.runtime.api(this.agent, 'GET', '/v1/contacts');
262
+ return contacts.filter(c => c.status === 'accepted').map(c => ({ contactId: c.id, agentId: c.agentAId === this.agent.agentId ? c.agentBId : c.agentAId }));
263
+ } catch (error) { throw apiFailure(error); }
264
+ }
265
+
266
+ async resolve(handle) {
267
+ if (!/^@[a-z0-9][a-z0-9_-]{0,31}\/[a-z0-9][a-z0-9_-]{0,31}$/.test(handle ?? '')) throw new SafeError('Use an address like @owner/agent.');
268
+ let agent;
269
+ try { ({ agent } = await this.runtime.api(this.agent, 'GET', `/v1/directory/agents?handle=${encodeURIComponent(handle)}`)); }
270
+ catch (error) { throw error instanceof ApiError && error.status === 404 ? new SafeError(consentErrors.unknown) : apiFailure(error); }
271
+ if (!(await this.contacts()).some(c => c.agentId === agent.id)) throw new SafeError(consentErrors.notContact);
272
+ return agent;
273
+ }
274
+
275
+ /**
276
+ * Propose a conversation: create the request, then submit the first message
277
+ * to the policy component, held for this agent's owner. The recipient
278
+ * agent receives nothing before its owner accepts.
279
+ */
280
+ async propose(handle, text) {
281
+ const peer = await this.resolve(handle);
282
+ let conversation;
283
+ try {
284
+ ({ conversation } = await this.runtime.api(this.agent, 'POST', '/v1/conversations', { fromAgentId: this.agent.agentId, toAgentId: peer.id }));
285
+ } catch (error) { throw apiFailure(error); }
286
+ const view = conversationView(conversation, this.agent.agentId, this.domain);
287
+ try {
288
+ const submission = await this.submit(view, text, 'first_message');
289
+ return { conversation: publicConversation(view), to: handle, firstMessage: submission };
290
+ } catch (error) {
291
+ // The request exists; its first message can be sent later with send().
292
+ return { conversation: publicConversation(view), to: handle, firstMessage: { state: 'not_sent', reason: error.message } };
293
+ }
294
+ }
295
+
296
+ /** Send in a conversation, respecting consent and the agent's mode. */
297
+ async send(conversationId, text) {
298
+ const view = await this.conversation(conversationId);
299
+ if (view.state === 'requested') {
300
+ if (view.role !== 'requester') throw new SafeError(consentErrors.waitingOther);
301
+ if (view.firstMessageDelivered) throw new SafeError(consentErrors.firstSent);
302
+ return { conversation: publicConversation(view), message: await this.submit(view, text, 'first_message') };
303
+ }
304
+ if (view.state !== 'active') throw new SafeError(consentErrors.closed(view.state));
305
+ const mode = await this.enter(view);
306
+ if (mode === 'gated') return { conversation: publicConversation(view, { mode }), message: await this.submit(view, text, 'service_message') };
307
+ const stanza = await this.encrypt(view, text);
308
+ const id = stanza.getAttr('id');
309
+ this.remember(id, text);
310
+ await this.session.client.send(stanza);
311
+ return { conversation: publicConversation(view, { mode }), message: { id, state: 'sent' } };
312
+ }
313
+
314
+ /**
315
+ * Recipients, discovered fresh before EVERY encryption: owners observe
316
+ * through per-installation device JIDs (lbframe/wacando #35/#108). There is
317
+ * no fallback of any kind (never `usr_`, never a smaller set): a server
318
+ * without discovery is refused.
319
+ */
320
+ async recipients(view) {
321
+ try {
322
+ const body = await this.runtime.api(this.agent, 'GET', `/v1/conversations/${encodeURIComponent(view.id)}/recipients`);
323
+ return discoveredRecipients(body, view).jids;
324
+ } catch (error) {
325
+ if (error instanceof ApiError && error.unknownRoute) throw new SafeError(consentErrors.noDiscovery);
326
+ if (error instanceof ApiError && error.code === 'SECURE_MESSAGING_UNAVAILABLE') throw new SafeError(consentErrors.noDevice);
327
+ throw error instanceof ApiError ? apiFailure(error) : error;
328
+ }
329
+ }
330
+
331
+ async encrypt(view, text) {
332
+ const recipients = await this.recipients(view);
333
+ try { return await this.session.provider.encryptForRoom(this.session.client, this.room(view.id), recipients, text); }
334
+ catch { throw new SafeError(consentErrors.noDevice); }
335
+ }
336
+
337
+ /** Encrypt for the room, then hand the ciphertext to the policy component. */
338
+ async submit(view, text, kind) {
339
+ const stanza = await this.encrypt(view, text);
340
+ const id = stanza.getAttr('id');
341
+ let timer;
342
+ const status = new Promise((resolve, reject) => {
343
+ this.submissions.set(id, resolve);
344
+ timer = setTimeout(() => reject(new SafeError('The policy component did not answer; the message may not be held. Retry later.')), SUBMISSION_TIMEOUT_MS);
345
+ }).finally(() => { clearTimeout(timer); this.submissions.delete(id); });
346
+ this.remember(id, text);
347
+ await this.session.client.send(toPolicySubmission(stanza, this.policyJid));
348
+ const attrs = await status;
349
+ if (attrs.state !== 'held') {
350
+ this.sent.delete(id);
351
+ return { id, state: 'refused', reason: attrs.reason ?? 'refused' };
352
+ }
353
+ const held = { id, conversationId: view.id, kind, state: 'held', heldAt: new Date().toISOString(),
354
+ expiresAt: new Date(Date.now() + HOLD_TTL_MS).toISOString(), digest: attrs.digest,
355
+ awaiting: kind === 'first_message' ? "your owner's approval in the Wacando app, then the recipient owner's acceptance" : "your owner's approval in the Wacando app" };
356
+ this.held.set(id, held);
357
+ return { ...held };
358
+ }
359
+
360
+ /** Join as a member when the room admits us; otherwise this agent is gated. */
361
+ async enter(view) {
362
+ const known = this.rooms.get(view.id);
363
+ if (known) return known;
364
+ const room = this.room(view.id);
365
+ this.tracker.trackRoom(room);
366
+ try {
367
+ await joinRoom(this.session.client, room, this.nick);
368
+ this.rooms.set(view.id, 'member');
369
+ } catch (error) {
370
+ if (!(error instanceof MucJoinError)) throw new SafeError('Secure messaging is unavailable. Retry later.');
371
+ this.rooms.set(view.id, 'gated');
372
+ }
373
+ return this.rooms.get(view.id);
374
+ }
375
+
376
+ /** Messages of an ACCEPTED conversation: archive (members) or live deliveries (gated). */
377
+ async read(conversationId) {
378
+ const view = await this.conversation(conversationId);
379
+ if (!view.accepted) throw new SafeError(consentErrors.notAccepted);
380
+ const mode = view.state === 'active' ? await this.enter(view) : (this.rooms.get(view.id) ?? 'gated');
381
+ if (mode === 'member') await this.catchUp(view);
382
+ await this.chain;
383
+ const messages = [...(this.transcripts.get(view.id)?.values() ?? [])].sort((a, b) => a.at.localeCompare(b.at));
384
+ return { conversation: publicConversation(view, { mode, ...this.recoveryOf(view.id) }), messages,
385
+ ...(mode === 'gated' ? { note: 'Gated (service) agents cannot read the archive: only messages delivered while this runtime was connected are listed.' } : {}) };
386
+ }
387
+
388
+ async catchUp(view) {
389
+ // RSM `after` is not exposed by the shared helper: page forward by the
390
+ // last archived timestamp (inclusive) and stop when a page adds nothing.
391
+ const room = this.room(view.id);
392
+ let start;
393
+ for (let page = 0; page < MAM_MAX_PAGES; page++) {
394
+ let result;
395
+ try { result = await queryMam(this.session.client, { to: room, max: MAM_PAGE, ...(start ? { start } : {}), trustedRelayJid: this.policyJid }); }
396
+ catch { throw new SafeError('Conversation history is unavailable. Retry later.'); }
397
+ const before = this.transcripts.get(view.id)?.size ?? 0;
398
+ for (const forwarded of result.messages) {
399
+ const stamp = forwarded.getChild('delay')?.getAttr('stamp');
400
+ if (stamp) start = stamp;
401
+ const inner = forwardedStanza(forwarded);
402
+ if (!inner?.getChild('encrypted', NS_OMEMO)) continue;
403
+ await this.ingest(inner, 'archive', stamp);
404
+ }
405
+ if (result.complete || result.messages.length < MAM_PAGE || (this.transcripts.get(view.id)?.size ?? 0) === before) break;
406
+ }
407
+ }
408
+
409
+ /** Wait for the next message from the other agent (bounded; no wake-up when closed). */
410
+ waitForMessage(conversationId, timeoutMs) {
411
+ return new Promise(resolve => {
412
+ const waiter = entry => {
413
+ if (conversationId && entry.conversationId !== conversationId) return;
414
+ done(entry);
415
+ };
416
+ const timer = setTimeout(() => done(null), timeoutMs);
417
+ const done = entry => { clearTimeout(timer); this.waiters.delete(waiter); this.cancels.delete(done); resolve(entry); };
418
+ this.waiters.add(waiter);
419
+ this.cancels.add(done);
420
+ });
421
+ }
422
+
423
+ /** Submissions still waiting for this agent's owner. */
424
+ pending() {
425
+ return [...this.held.values()].filter(h => h.state === 'held').map(h => ({ ...h, expired: Date.parse(h.expiresAt) <= Date.now() }));
426
+ }
427
+
428
+ close() {
429
+ this.tracker.close();
430
+ this.recovery?.detach();
431
+ for (const cancel of [...this.cancels]) cancel(null);
432
+ }
433
+ }
@@ -0,0 +1,34 @@
1
+ import { lstat } from 'node:fs/promises';
2
+ import { join } from 'node:path';
3
+ import { jidStateDirName, loadNativeOmemoProvider } from '@wacando/omemo';
4
+ import { SafeError } from '../onboarding/storage.mjs';
5
+
6
+ // Runtime 2.x speaks only the official libsignal (PQXDH) OMEMO profile. A
7
+ // state directory holding a 1.x classic device is never converted or replaced.
8
+ export const CLASSIC_IDENTITY = 'This identity was created by plugin 1.x with the retired classic OMEMO format, which desktop apps cannot decrypt. It cannot be upgraded in place: revoke it on the site and pair a new identity with a different address (docs/MIGRATION-2.0.md). Its keys were left untouched.';
9
+ const unreadable = 'Existing OMEMO state cannot be read. Preserve the keys and repair local storage before reconnecting.';
10
+
11
+ async function privateEntry(path, directory) {
12
+ let stat;
13
+ try { stat = await lstat(path); }
14
+ catch (error) { if (error.code === 'ENOENT') return false; throw new SafeError(unreadable); }
15
+ if (stat.isSymbolicLink() || (directory ? !stat.isDirectory() : !stat.isFile()) || (stat.mode & 0o077) ||
16
+ (process.getuid && stat.uid !== process.getuid())) throw new SafeError(unreadable);
17
+ return true;
18
+ }
19
+
20
+ /**
21
+ * Load (or create on first use) this identity's native OMEMO device under the
22
+ * protected `stateBaseDir`. Private keys stay in `<stateBaseDir>/<jid>/state.json`
23
+ * (0700/0600); a corrupt or unsafe file fails closed and is never regenerated.
24
+ */
25
+ export async function loadDevice(jid, stateBaseDir) {
26
+ if (await privateEntry(join(stateBaseDir, 'classic-state.json'), false).catch(() => true)) {
27
+ throw new SafeError(CLASSIC_IDENTITY);
28
+ }
29
+ const bare = jid.split('/')[0];
30
+ const directory = join(stateBaseDir, jidStateDirName(bare));
31
+ if (await privateEntry(directory, true)) await privateEntry(join(directory, 'state.json'), false);
32
+ try { return loadNativeOmemoProvider(bare, { baseDir: stateBaseDir }); }
33
+ catch { throw new SafeError(unreadable); }
34
+ }
@@ -0,0 +1,8 @@
1
+ // Node SDK of the Wacando agent runtime (`import ... from 'wacando-agent'`).
2
+ export { AgentRuntime, publicIdentity } from './runtime.mjs';
3
+ export { connectSession } from './session.mjs';
4
+ export { CLASSIC_IDENTITY, loadDevice } from './device.mjs';
5
+ export { OnboardingService } from '../onboarding/service.mjs';
6
+ export { CredentialStore, SafeError } from '../onboarding/storage.mjs';
7
+ export { ApiError } from '../onboarding/network.mjs';
8
+ export { pluginVersion as runtimeVersion } from '../onboarding/version.mjs';
@@ -0,0 +1,110 @@
1
+ import { createPrivateKey, createPublicKey, generateKeyPairSync, randomBytes, sign } from 'node:crypto';
2
+ import { hostname } from 'node:os';
3
+ import { ApiError, post } from '../onboarding/network.mjs';
4
+ import { SafeError } from '../onboarding/storage.mjs';
5
+
6
+ // Universal pairing (lbframe/wacando docs/ENROLLMENT_PAIRING_API.md, #36/#60).
7
+ export const PAIRING_TTL_MS = 10 * 60 * 1000;
8
+ export const POLL_INTERVAL_MS = 5000;
9
+ const CODE = /^[23456789ABCDEFGHJKLMNPQRSTUVWXYZ]{8}$/;
10
+ const SPKI_ED25519 = Buffer.from('302a300506032b6570032100', 'hex');
11
+ const b64url = bytes => Buffer.from(bytes).toString('base64url');
12
+
13
+ export const pairingErrors = {
14
+ code: 'Invalid pairing code. Use the 8-character code shown in the Wacando app.',
15
+ refused: 'Pairing code unavailable: it is expired, already used or unknown. Create a new code in the Wacando app.',
16
+ notConfirmed: 'Pairing was not confirmed in time (the owner refused it, it expired, or it was never confirmed). No identity was saved. Create a new code in the Wacando app.',
17
+ lost: 'The pairing result was not safely saved. If the app shows this agent as paired, revoke it there and pair a new identity with a different address.',
18
+ };
19
+
20
+ /** Dedicated Ed25519 runtime authentication key (separate from the OMEMO key). */
21
+ export function generateAuthKey() {
22
+ const { publicKey, privateKey } = generateKeyPairSync('ed25519');
23
+ return {
24
+ publicKey: b64url(publicKey.export({ format: 'der', type: 'spki' }).subarray(SPKI_ED25519.length)),
25
+ privateKeyPem: privateKey.export({ format: 'pem', type: 'pkcs8' }),
26
+ };
27
+ }
28
+
29
+ /** Raw 32-byte public key (base64url) of a stored PKCS#8 Ed25519 private key. */
30
+ export function publicKeyOf(privateKeyPem) {
31
+ const der = createPublicKey(createPrivateKey(privateKeyPem)).export({ format: 'der', type: 'spki' });
32
+ return b64url(der.subarray(SPKI_ED25519.length));
33
+ }
34
+
35
+ export const redeemMessage = (code, publicKey, clientNonce, issuedAt) =>
36
+ `wacando-pair-redeem-v1|${code}|${publicKey}|${clientNonce}|${issuedAt}`;
37
+ export const claimMessage = (pairingId, serverNonce, publicKey) =>
38
+ `wacando-pair-claim-v1|${pairingId}|${serverNonce}|${publicKey}`;
39
+
40
+ /** Raw 64-byte Ed25519 signature over the exact UTF-8 bytes, base64url without padding. */
41
+ export function proof(privateKeyPem, message) {
42
+ return b64url(sign(null, Buffer.from(message, 'utf8'), createPrivateKey(privateKeyPem)));
43
+ }
44
+
45
+ /** Runtime-authored display metadata shown to the owner (never trusted as instructions). */
46
+ export function runtimeMetadata({ host, label }) {
47
+ const clean = (value, max) => String(value).replace(/[\u0000-\u001f\u007f]/g, ' ').trim().slice(0, max);
48
+ return { host: clean(`wacando-agent ${host}`, 64), label: clean(label ?? hostname() ?? 'agent', 80) || 'agent' };
49
+ }
50
+
51
+ export function validCode(code) {
52
+ return typeof code === 'string' && CODE.test(code);
53
+ }
54
+
55
+ /**
56
+ * Redeem a code with a fresh key. The key is persisted BEFORE the request so a
57
+ * crash after the server accepted it can still be resumed with the saved key.
58
+ */
59
+ export async function redeem({ origin, code, host, label, mode, saveKey, request = post, now = Date.now }) {
60
+ if (!validCode(code)) throw new SafeError(pairingErrors.code);
61
+ const key = generateAuthKey();
62
+ const clientNonce = b64url(randomBytes(32));
63
+ const issuedAt = Math.floor(now() / 1000);
64
+ const meta = runtimeMetadata({ host, label });
65
+ const pending = { origin, publicKey: key.publicKey, privateKeyPem: key.privateKeyPem, mode, startedAt: new Date(now()).toISOString() };
66
+ await saveKey(pending);
67
+ let data;
68
+ try {
69
+ data = await request(new URL('/v2/pairings/redeem', origin), undefined, {
70
+ code, publicKey: key.publicKey, clientNonce, issuedAt, ...meta,
71
+ proof: proof(key.privateKeyPem, redeemMessage(code, key.publicKey, clientNonce, issuedAt)),
72
+ });
73
+ } catch (error) {
74
+ if (error instanceof ApiError && [400, 401, 403, 404].includes(error.status)) throw new SafeError(pairingErrors.refused);
75
+ if (error instanceof ApiError && error.status === 429) throw new SafeError('Too many pairing attempts. Wait and retry with the same code.');
76
+ throw error;
77
+ }
78
+ if (typeof data?.pairingId !== 'string' || typeof data.pollSecret !== 'string' || typeof data.serverNonce !== 'string') {
79
+ throw new SafeError(pairingErrors.refused);
80
+ }
81
+ return { ...pending, pairingId: data.pairingId, pollSecret: data.pollSecret, serverNonce: data.serverNonce };
82
+ }
83
+
84
+ /**
85
+ * Poll the claim endpoint (never faster than every 5 s, honouring Retry-After)
86
+ * until the owner confirms. The API answers the same 401 while pending, after a
87
+ * refusal or after expiry, so the local deadline ends the wait.
88
+ */
89
+ export async function claim({ pending, request = post, sleep, now = Date.now, deadlineMs, onWaiting }) {
90
+ const deadline = deadlineMs ?? Date.parse(pending.startedAt) + PAIRING_TTL_MS + 30000;
91
+ const body = () => ({ pollSecret: pending.pollSecret, proof: proof(pending.privateKeyPem, claimMessage(pending.pairingId, pending.serverNonce, pending.publicKey)) });
92
+ for (;;) {
93
+ let wait = POLL_INTERVAL_MS;
94
+ try {
95
+ const data = await request(new URL(`/v2/pairings/${encodeURIComponent(pending.pairingId)}/claim`, pending.origin), undefined, body());
96
+ if (typeof data?.agentId !== 'string' || typeof data.handle !== 'string' || typeof data.agentToken !== 'string' ||
97
+ !data.agentToken.startsWith('agtacc_') || typeof data.xmpp?.domain !== 'string' || typeof data.xmpp?.service !== 'string') {
98
+ throw new SafeError(pairingErrors.lost);
99
+ }
100
+ return data;
101
+ } catch (error) {
102
+ if (!(error instanceof ApiError)) throw error;
103
+ if (error.status === 429) wait = Math.max(POLL_INTERVAL_MS, (error.retryAfter ?? 5) * 1000);
104
+ else if (error.status !== 401) throw new SafeError(`Pairing service error (HTTP ${error.status}). Retry with the same command.`);
105
+ }
106
+ if (now() + wait > deadline) throw new SafeError(pairingErrors.notConfirmed);
107
+ onWaiting?.();
108
+ await sleep(wait);
109
+ }
110
+ }
@@ -0,0 +1,49 @@
1
+ import * as omemo from '@wacando/omemo';
2
+
3
+ // Consumed-prekey session recovery (lbframe/wacando#50,
4
+ // docs/OMEMO_SESSION_RECOVERY_IMPLEMENTATION.md there). The protocol lives in
5
+ // @wacando/omemo (`SessionRecovery`): signed controls relayed ONLY by the
6
+ // policy component, which enforces membership and STOP. The runtime supplies
7
+ // the conversation lifecycle it learns from the API and surfaces the state.
8
+ // A @wacando/omemo without SessionRecovery leaves recovery off: failures keep
9
+ // failing closed exactly as before (no plaintext, no replay).
10
+
11
+ /** Conversation state (API) → recovery lifecycle. Anything uncertain denies. */
12
+ export function recoveryLifecycle(state) {
13
+ if (state === 'active') return 'active';
14
+ if (['closing', 'closed', 'expired', 'refused', 'failed'].includes(state)) return 'closed';
15
+ return 'unknown';
16
+ }
17
+
18
+ /**
19
+ * Attach recovery to one XMPP session. Returns null (recovery off, failures
20
+ * keep failing closed) when the installed @wacando/omemo does not provide it,
21
+ * when the session is not a native-OMEMO XMPP client able to answer IQs, or
22
+ * when attaching fails. `omemoModule` is injectable for tests.
23
+ */
24
+ export function attachRecovery({ session, policyJid, lifecycle, onStatus, omemoModule = omemo }) {
25
+ const SessionRecovery = omemoModule.SessionRecovery;
26
+ if (typeof SessionRecovery?.attach !== 'function') return null;
27
+ const client = session?.client;
28
+ const backend = session?.provider?.backend;
29
+ if (typeof client?.iqCallee?.set !== 'function' || typeof client?.iqCaller?.set !== 'function') return null;
30
+ if (!backend?.state) return null; // native libsignal backend only
31
+ try {
32
+ return SessionRecovery.attach({ provider: session.provider, backend, entity: client, policyJid, lifecycle, onStatus });
33
+ } catch {
34
+ return null;
35
+ }
36
+ }
37
+
38
+ /**
39
+ * What an agent is told: a state and a content-free reason. A message whose
40
+ * delivery stays uncertain is never resent automatically; its id is listed
41
+ * so the agent can tell its owner and resend explicitly if needed.
42
+ */
43
+ export function recoveryView(event) {
44
+ return {
45
+ state: event.status,
46
+ reason: event.reason ?? null,
47
+ ...(event.deliveryUncertain?.length ? { deliveryUncertain: [...event.deliveryUncertain] } : {}),
48
+ };
49
+ }