@wacando/agent 0.0.0-stage → 2.0.0-rc.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.
- package/.agents/plugins/marketplace.json +10 -0
- package/.claude-plugin/marketplace.json +12 -0
- package/.cursor-plugin/marketplace.json +12 -0
- package/README.md +166 -2
- package/STATUS.md +121 -0
- package/THIRD-PARTY-NOTICES.txt +1928 -0
- package/adapters/README.md +12 -0
- package/adapters/claude/mcp.mjs +7 -0
- package/adapters/codex/mcp.mjs +7 -0
- package/adapters/cursor/mcp.mjs +7 -0
- package/bin/wacando-agent.mjs +243 -0
- package/docs/AGENT_TOOLS.md +129 -0
- package/docs/CURSOR_IDENTITY_RESUME.md +579 -0
- package/docs/MCP-AUDIT.md +355 -0
- package/docs/MCP-CONTRACT.md +138 -0
- package/docs/MIGRATION-2.0.md +36 -0
- package/docs/OMEMO_PREKEY_INTEGRATION.md +125 -0
- package/docs/ONBOARDING-VALIDATION.md +88 -0
- package/docs/ONBOARDING.md +212 -0
- package/docs/ONBOARDING_PLUGIN_CONTRACT.md +156 -0
- package/docs/PLUGIN_HANDOFF_FOLLOWUP.md +178 -0
- package/docs/PUBLIC_ONBOARDING_READINESS.md +156 -0
- package/docs/RELEASE.md +25 -0
- package/docs/WACANDO_PACKAGES.md +26 -0
- package/docs/evidence/issue-2/DOCUMENTARY_FOLLOWUP.md +107 -0
- package/docs/evidence/issue-2/QUALIFICATION_LINK_FOLLOWUP.md +105 -0
- package/docs/evidence/issue-2/REVIEW_FOLLOWUP.md +93 -0
- package/docs/evidence/issue-2/baseline.txt +76 -0
- package/docs/evidence/issue-2/check-mcp.txt +14 -0
- package/docs/evidence/issue-2/check.txt +376 -0
- package/docs/evidence/issue-2/codex-exec-help.json +11 -0
- package/docs/evidence/issue-2/codex-help.json +10 -0
- package/docs/evidence/issue-2/cursor-help.json +10 -0
- package/docs/evidence/issue-2/documentary-check.txt +376 -0
- package/docs/evidence/issue-2/documentary-validation.txt +7 -0
- package/docs/evidence/issue-2/local-build-api.txt +203 -0
- package/docs/evidence/issue-2/local-build-first.txt +87 -0
- package/docs/evidence/issue-2/local-build-policy-first.txt +34 -0
- package/docs/evidence/issue-2/local-build-policy.txt +153 -0
- package/docs/evidence/issue-2/local-build-web.txt +219 -0
- package/docs/evidence/issue-2/local-check-mcp.txt +14 -0
- package/docs/evidence/issue-2/local-check.txt +376 -0
- package/docs/evidence/issue-2/local-codex-probe.txt +3 -0
- package/docs/evidence/issue-2/local-codex-probe2.txt +3 -0
- package/docs/evidence/issue-2/local-codex.txt +2 -0
- package/docs/evidence/issue-2/local-cursor-probe.txt +1 -0
- package/docs/evidence/issue-2/local-cursor.txt +2 -0
- package/docs/evidence/issue-2/local-down.txt +32 -0
- package/docs/evidence/issue-2/local-health.txt +1 -0
- package/docs/evidence/issue-2/local-live.txt +28 -0
- package/docs/evidence/issue-2/local-seed.txt +26 -0
- package/docs/evidence/issue-2/local-status.txt +6 -0
- package/docs/evidence/issue-2/local-up.txt +18 -0
- package/docs/evidence/issue-2/prepare-local-stack.py +67 -0
- package/docs/evidence/issue-2/qualification-body-validation.txt +9 -0
- package/docs/evidence/issue-2/qualification-link-check.txt +376 -0
- package/docs/evidence/issue-2/qualification-link-validation.txt +20 -0
- package/docs/evidence/issue-2/review-check.txt +376 -0
- package/docs/evidence/issue-2/review-validation.txt +218 -0
- package/docs/evidence/issue-7/README.md +21 -0
- package/docs/evidence/issue-7/check-mcp.txt +9 -0
- package/docs/evidence/issue-7/check.txt +381 -0
- package/docs/evidence/issue-7/live.txt +23 -0
- package/docs/evidence/issue-8/README.md +56 -0
- package/docs/evidence/issue-8/check-mcp.txt +9 -0
- package/docs/evidence/issue-8/check.txt +465 -0
- package/docs/evidence/issue-8/e2e-desktop-observer-human-devices.txt +30 -0
- package/docs/evidence/issue-8/e2e-desktop-observer-main-without-109.txt +17 -0
- package/docs/evidence/issue-8/e2e-desktop-observer.txt +26 -0
- package/docs/evidence/issue-8/live-regression-human-devices.txt +24 -0
- package/docs/evidence/issue-8/live-regression.txt +23 -0
- package/docs/evidence/issue-8/review-fixes/README.md +30 -0
- package/docs/evidence/issue-8/review-fixes/check-mcp.txt +9 -0
- package/docs/evidence/issue-8/review-fixes/check.txt +484 -0
- package/docs/evidence/issue-8/review-fixes/e2e-desktop-observer.txt +28 -0
- package/docs/evidence/issue-8/review-fixes/onboarding-live.txt +15 -0
- package/docs/evidence/issue-8/review-fixes/session-replacement-live.txt +13 -0
- package/docs/evidence/npm-release/check-mcp.txt +9 -0
- package/docs/evidence/npm-release/check.txt +484 -0
- package/docs/evidence/npm-release/e2e-desktop-observer.txt +28 -0
- package/package.json +66 -4
- package/packages/core/limits.mjs +24 -0
- package/packages/core/store.mjs +92 -0
- package/packages/core/validation.mjs +25 -0
- package/packages/mcp/agent-tools.mjs +82 -0
- package/packages/mcp/server.mjs +161 -0
- package/packages/mcp/stdio.mjs +42 -0
- package/packages/onboarding/network.mjs +60 -0
- package/packages/onboarding/service.mjs +235 -0
- package/packages/onboarding/storage.mjs +99 -0
- package/packages/onboarding/version.mjs +2 -0
- package/packages/runtime/conversations.mjs +384 -0
- package/packages/runtime/device.mjs +34 -0
- package/packages/runtime/index.mjs +8 -0
- package/packages/runtime/pairing.mjs +110 -0
- package/packages/runtime/runtime.mjs +213 -0
- package/packages/runtime/session.mjs +52 -0
- package/packages/server/cli.mjs +18 -0
- package/packages/server/server.mjs +110 -0
- package/packages/server/stream.mjs +42 -0
- package/packages/web/app.js +85 -0
- package/packages/web/history.js +48 -0
- package/packages/web/index.html +17 -0
- package/packages/web/style.css +1 -0
- package/plugins/wacando/.codex-plugin/plugin.json +18 -0
- package/plugins/wacando/.mcp.json +8 -0
- package/plugins/wacando/skills/wacando/SKILL.md +33 -0
- package/plugins/wacando-claude/.claude-plugin/plugin.json +10 -0
- package/plugins/wacando-claude/.mcp.json +8 -0
- package/plugins/wacando-claude/README.md +88 -0
- package/plugins/wacando-claude/skills/open/SKILL.md +66 -0
- package/plugins/wacando-cursor/.cursor-plugin/plugin.json +11 -0
- package/plugins/wacando-cursor/README.md +48 -0
- package/plugins/wacando-cursor/commands/wacando-open.md +10 -0
- package/plugins/wacando-cursor/mcp.json +8 -0
- package/plugins/wacando-cursor/skills/wacando/SKILL.md +50 -0
- package/provenance.json +503 -0
|
@@ -0,0 +1,384 @@
|
|
|
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
|
+
|
|
9
|
+
// Consent-aware conversations (lbframe/wacando docs/CONVERSATION_CONSENT.md):
|
|
10
|
+
// - a request's first message and every message of a gated (`service`) agent
|
|
11
|
+
// go to the policy component, which holds them for the owner's approval;
|
|
12
|
+
// - an accepted `human` agent is a room member and speaks freely;
|
|
13
|
+
// - only ACCEPTED conversations are ever read; direct 1:1 chat is never used.
|
|
14
|
+
// Message text is never logged; decrypt failures yield `text: null`.
|
|
15
|
+
|
|
16
|
+
export const HOLD_TTL_MS = 10 * 60 * 1000;
|
|
17
|
+
const SUBMISSION_TIMEOUT_MS = 15000;
|
|
18
|
+
const MAM_PAGE = 100;
|
|
19
|
+
const MAM_MAX_PAGES = 20;
|
|
20
|
+
const MAX_ENTRIES = 1000;
|
|
21
|
+
const DELETED = 'deleted_account';
|
|
22
|
+
|
|
23
|
+
export const consentErrors = {
|
|
24
|
+
notContact: 'This address is not an accepted contact of this agent. Ask its owner to accept a contact invitation first.',
|
|
25
|
+
unknown: 'Unknown or unreachable address. Check the handle (@owner/agent) with your owner.',
|
|
26
|
+
notAccepted: 'Conversation not found or not accepted. Only accepted conversations can be read.',
|
|
27
|
+
closed: state => `Conversation is ${state}; nothing was sent. A stopped or refused conversation never reopens.`,
|
|
28
|
+
waitingOther: 'This request waits for the other owner; only its requester sends the first message.',
|
|
29
|
+
firstSent: 'The first message of this request was already approved and delivered; wait for the recipient owner to accept.',
|
|
30
|
+
noDiscovery: 'Secure messaging is unavailable: this Wacando server does not provide recipient discovery (per-device observers). Nothing was sent; the server must be updated.',
|
|
31
|
+
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.',
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
export function apiFailure(error) {
|
|
35
|
+
if (!(error instanceof ApiError)) return error;
|
|
36
|
+
const known = {
|
|
37
|
+
CONTACT_NOT_ACCEPTED: consentErrors.notContact,
|
|
38
|
+
CONTACT_REVOKED: 'The contact relationship was revoked. Nothing was sent.',
|
|
39
|
+
AGENT_NOT_REACHABLE: consentErrors.unknown,
|
|
40
|
+
AGENT_REVOKED: 'One of the agents is revoked. Nothing was sent.',
|
|
41
|
+
AGENT_OFFLINE: 'The other agent is offline. Retry when it is connected.',
|
|
42
|
+
CONVERSATION_CLOSED: 'Conversation is closed (stopped, refused or expired). Nothing was sent.',
|
|
43
|
+
CONVERSATION_NOT_ACTIVE: 'Conversation is not active. Nothing was sent.',
|
|
44
|
+
NOT_FOUND: consentErrors.notAccepted,
|
|
45
|
+
FORBIDDEN: 'Refused: the other agent is not reachable for new requests, or this agent may not act here. Nothing was sent.',
|
|
46
|
+
};
|
|
47
|
+
return new SafeError(known[error.code] ?? `Wacando API refused the request (${error.code ?? `HTTP ${error.status}`}).`);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Pure: which side of a conversation this agent is, and who must receive the ciphertext. */
|
|
51
|
+
export function conversationView(conversation, selfAgentId, domain) {
|
|
52
|
+
const self = conversation.agentAId === selfAgentId ? 'A' : conversation.agentBId === selfAgentId ? 'B' : null;
|
|
53
|
+
if (!self) throw new SafeError(consentErrors.notAccepted);
|
|
54
|
+
const peer = self === 'A' ? 'B' : 'A';
|
|
55
|
+
const peerAgentId = conversation[`agent${peer}Id`];
|
|
56
|
+
const recipients = [];
|
|
57
|
+
if (peerAgentId && peerAgentId !== DELETED) recipients.push(`agt_${peerAgentId}@${domain}`);
|
|
58
|
+
for (const owner of [conversation.ownerAId, conversation.ownerBId]) {
|
|
59
|
+
if (owner && owner !== DELETED) recipients.push(`usr_${owner}@${domain}`);
|
|
60
|
+
}
|
|
61
|
+
return {
|
|
62
|
+
id: conversation.id,
|
|
63
|
+
state: conversation.status,
|
|
64
|
+
accepted: conversation.activatedAt != null,
|
|
65
|
+
role: conversation.requestedByAgentId == null ? 'participant' : conversation.requestedByAgentId === selfAgentId ? 'requester' : 'recipient',
|
|
66
|
+
peerAgentId: peerAgentId === DELETED ? null : peerAgentId,
|
|
67
|
+
firstMessageDelivered: conversation.requestSentAt != null,
|
|
68
|
+
requestExpiresAt: conversation.requestExpiresAt ?? null,
|
|
69
|
+
recipients: [...new Set(recipients)],
|
|
70
|
+
agentIds: [conversation.agentAId, conversation.agentBId],
|
|
71
|
+
ownerIds: [conversation.ownerAId, conversation.ownerBId],
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const BARE_JID = /^[^\s/@]+@[^\s/@]+$/;
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Pure: validate recipient discovery (lbframe/wacando #35/#108,
|
|
79
|
+
* GET /v1/conversations/:id/recipients v1) and return every OMEMO recipient
|
|
80
|
+
* JID: both agents and every active installation of both owners. Anything
|
|
81
|
+
* unexpected fails closed; there is never a smaller fallback set.
|
|
82
|
+
*/
|
|
83
|
+
export function discoveredRecipients(body, view) {
|
|
84
|
+
const r = body?.recipients;
|
|
85
|
+
const ownersPresent = owners => Array.isArray(r?.observers) &&
|
|
86
|
+
owners.every(owner => r.observers.some(o => o?.ownerUserId === owner));
|
|
87
|
+
const ok = r && r.version === 1 && r.conversationId === view.id && typeof r.revision === 'string' &&
|
|
88
|
+
Array.isArray(r.agents) && r.agents.length === 2 && r.agents.every(a => BARE_JID.test(a?.jid ?? '')) &&
|
|
89
|
+
[...view.agentIds].sort().join('|') === r.agents.map(a => a?.agentId).sort().join('|') &&
|
|
90
|
+
Array.isArray(r.observers) && r.observers.length > 0 && r.observers.every(o => BARE_JID.test(o?.jid ?? '')) &&
|
|
91
|
+
ownersPresent([...new Set(view.ownerIds)]);
|
|
92
|
+
if (!ok) throw new SafeError('Secure messaging is unavailable: the recipient list is malformed or misses a participant. Nothing was sent.');
|
|
93
|
+
return { revision: r.revision, jids: [...new Set([...r.agents, ...r.observers].map(x => x.jid))] };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Pure: public summary of a conversation for agents (no owner identifiers). */
|
|
97
|
+
export function publicConversation(view, extra = {}) {
|
|
98
|
+
const { recipients: _recipients, agentIds: _agents, ownerIds: _owners, ...rest } = view;
|
|
99
|
+
return { ...rest, ...extra };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Messaging for ONE connected identity. Created by AgentRuntime.conversations(). */
|
|
103
|
+
export class ConversationClient {
|
|
104
|
+
constructor({ runtime, agent, session }) {
|
|
105
|
+
this.runtime = runtime;
|
|
106
|
+
this.agent = agent;
|
|
107
|
+
this.session = session;
|
|
108
|
+
this.domain = agent.xmpp.domain;
|
|
109
|
+
this.selfJid = session.bareJid;
|
|
110
|
+
this.policyJid = `policy.${this.domain}`;
|
|
111
|
+
this.mucDomain = `conference.${this.domain}`;
|
|
112
|
+
this.nick = `agt-${agent.agentId.replace(/[^a-zA-Z0-9]/g, '').slice(-10)}`;
|
|
113
|
+
this.tracker = new MucOriginTracker(session.client, { trustedRelayJid: this.policyJid });
|
|
114
|
+
this.rooms = new Map(); // conversationId → 'member' | 'gated'
|
|
115
|
+
this.transcripts = new Map(); // conversationId → Map(stanzaId → entry)
|
|
116
|
+
this.claimed = new Set(); // stanza ids already decrypted once (Signal keys are single use)
|
|
117
|
+
this.sent = new Map(); // own stanza id → text, this process only (Signal cannot self-decrypt)
|
|
118
|
+
this.held = new Map(); // stanza id → submission status
|
|
119
|
+
this.submissions = new Map(); // stanza id → resolver for the component's status
|
|
120
|
+
this.waiters = new Set();
|
|
121
|
+
this.cancels = new Set();
|
|
122
|
+
this.chain = Promise.resolve();
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** Own sent text, kept in memory only and bounded (Signal cannot self-decrypt). */
|
|
126
|
+
remember(id, text) {
|
|
127
|
+
this.sent.set(id, text);
|
|
128
|
+
while (this.sent.size > MAX_ENTRIES) this.sent.delete(this.sent.keys().next().value);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** Continue on a new session of the same identity (after a reconnection). */
|
|
132
|
+
rebind(session) {
|
|
133
|
+
this.tracker.close();
|
|
134
|
+
this.session = session;
|
|
135
|
+
this.tracker = new MucOriginTracker(session.client, { trustedRelayJid: this.policyJid });
|
|
136
|
+
this.rooms.clear();
|
|
137
|
+
for (const resolve of this.submissions.values()) resolve({ state: 'refused', reason: 'connection-lost' });
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
room(conversationId) { return `c_${conversationId}@${this.mucDomain}`; }
|
|
141
|
+
conversationOf(room) {
|
|
142
|
+
const m = /^c_(.+)@(.+)$/.exec(room ?? '');
|
|
143
|
+
return m && m[2] === this.mucDomain ? m[1] : null;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** Called for every stanza of this identity (attached before the presence echo). */
|
|
147
|
+
handle(stanza) {
|
|
148
|
+
if (!stanza.is('message') || stanza.getAttr('type') === 'chat') return; // never a 1:1 channel
|
|
149
|
+
const from = stanza.getAttr('from');
|
|
150
|
+
const status = from === this.policyJid ? stanza.getChild('submission', NS_WACANDO_POLICY) : undefined;
|
|
151
|
+
if (status) {
|
|
152
|
+
this.submissions.get(status.getAttr('id'))?.(status.attrs);
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
const delivered = unwrapPolicyDelivery(stanza, this.policyJid);
|
|
156
|
+
if (delivered) { this.ingest(delivered, 'delivery'); return; }
|
|
157
|
+
if (stanza.getAttr('type') === 'groupchat' && stanza.getChild('encrypted', NS_OMEMO) &&
|
|
158
|
+
!stanza.getChild('delay', 'urn:xmpp:delay')) {
|
|
159
|
+
// Let the origin tracker (a later listener) record provenance first.
|
|
160
|
+
setImmediate(() => this.ingest(stanza, 'live'));
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
ingest(stanza, source, stamp) {
|
|
165
|
+
const origin = authenticatedMessageOrigin(stanza);
|
|
166
|
+
const conversationId = this.conversationOf(origin?.roomJid);
|
|
167
|
+
const id = stanza.getAttr('id');
|
|
168
|
+
if (!origin || !conversationId || typeof id !== 'string') return Promise.resolve();
|
|
169
|
+
const task = this.chain.then(async () => {
|
|
170
|
+
const transcript = this.transcripts.get(conversationId) ?? new Map();
|
|
171
|
+
this.transcripts.set(conversationId, transcript);
|
|
172
|
+
if (transcript.has(id)) return;
|
|
173
|
+
const own = origin.senderBareJid === this.selfJid;
|
|
174
|
+
let text = null;
|
|
175
|
+
if (own) text = this.sent.get(id) ?? null;
|
|
176
|
+
else if (!this.claimed.has(id)) {
|
|
177
|
+
this.claimed.add(id);
|
|
178
|
+
text = await this.session.provider.decryptStanza(stanza, origin).catch(() => null);
|
|
179
|
+
}
|
|
180
|
+
if (this.held.has(id)) this.held.get(id).state = 'delivered';
|
|
181
|
+
const entry = { id, conversationId, sender: own ? 'self' : 'peer', at: stamp ?? new Date().toISOString(), text,
|
|
182
|
+
...(text === null && !own ? { undecryptable: true } : {}), source };
|
|
183
|
+
transcript.set(id, entry);
|
|
184
|
+
while (transcript.size > MAX_ENTRIES) transcript.delete(transcript.keys().next().value);
|
|
185
|
+
if (!own) for (const waiter of [...this.waiters]) waiter(entry);
|
|
186
|
+
});
|
|
187
|
+
this.chain = task.catch(() => {});
|
|
188
|
+
return task;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
async conversation(conversationId) {
|
|
192
|
+
try {
|
|
193
|
+
const { conversation } = await this.runtime.api(this.agent, 'GET', `/v1/conversations/${encodeURIComponent(conversationId)}`);
|
|
194
|
+
return conversationView(conversation, this.agent.agentId, this.domain);
|
|
195
|
+
} catch (error) { throw apiFailure(error); }
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** Accepted conversations, plus this agent's own pending requests. */
|
|
199
|
+
async list() {
|
|
200
|
+
let conversations;
|
|
201
|
+
try { ({ conversations } = await this.runtime.api(this.agent, 'GET', '/v1/conversations')); }
|
|
202
|
+
catch (error) { throw apiFailure(error); }
|
|
203
|
+
const views = conversations.map(c => conversationView(c, this.agent.agentId, this.domain));
|
|
204
|
+
return {
|
|
205
|
+
accepted: views.filter(v => v.accepted).map(v => publicConversation(v, { mode: this.rooms.get(v.id) ?? 'unknown' })),
|
|
206
|
+
requests: views.filter(v => !v.accepted && v.role === 'requester' && v.state === 'requested').map(v => publicConversation(v)),
|
|
207
|
+
};
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/** Accepted contacts, resolved to handles where the directory shows them. */
|
|
211
|
+
async contacts() {
|
|
212
|
+
try {
|
|
213
|
+
const { contacts } = await this.runtime.api(this.agent, 'GET', '/v1/contacts');
|
|
214
|
+
return contacts.filter(c => c.status === 'accepted').map(c => ({ contactId: c.id, agentId: c.agentAId === this.agent.agentId ? c.agentBId : c.agentAId }));
|
|
215
|
+
} catch (error) { throw apiFailure(error); }
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
async resolve(handle) {
|
|
219
|
+
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.');
|
|
220
|
+
let agent;
|
|
221
|
+
try { ({ agent } = await this.runtime.api(this.agent, 'GET', `/v1/directory/agents?handle=${encodeURIComponent(handle)}`)); }
|
|
222
|
+
catch (error) { throw error instanceof ApiError && error.status === 404 ? new SafeError(consentErrors.unknown) : apiFailure(error); }
|
|
223
|
+
if (!(await this.contacts()).some(c => c.agentId === agent.id)) throw new SafeError(consentErrors.notContact);
|
|
224
|
+
return agent;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Propose a conversation: create the request, then submit the first message
|
|
229
|
+
* to the policy component, held for this agent's owner. The recipient
|
|
230
|
+
* agent receives nothing before its owner accepts.
|
|
231
|
+
*/
|
|
232
|
+
async propose(handle, text) {
|
|
233
|
+
const peer = await this.resolve(handle);
|
|
234
|
+
let conversation;
|
|
235
|
+
try {
|
|
236
|
+
({ conversation } = await this.runtime.api(this.agent, 'POST', '/v1/conversations', { fromAgentId: this.agent.agentId, toAgentId: peer.id }));
|
|
237
|
+
} catch (error) { throw apiFailure(error); }
|
|
238
|
+
const view = conversationView(conversation, this.agent.agentId, this.domain);
|
|
239
|
+
try {
|
|
240
|
+
const submission = await this.submit(view, text, 'first_message');
|
|
241
|
+
return { conversation: publicConversation(view), to: handle, firstMessage: submission };
|
|
242
|
+
} catch (error) {
|
|
243
|
+
// The request exists; its first message can be sent later with send().
|
|
244
|
+
return { conversation: publicConversation(view), to: handle, firstMessage: { state: 'not_sent', reason: error.message } };
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/** Send in a conversation, respecting consent and the agent's mode. */
|
|
249
|
+
async send(conversationId, text) {
|
|
250
|
+
const view = await this.conversation(conversationId);
|
|
251
|
+
if (view.state === 'requested') {
|
|
252
|
+
if (view.role !== 'requester') throw new SafeError(consentErrors.waitingOther);
|
|
253
|
+
if (view.firstMessageDelivered) throw new SafeError(consentErrors.firstSent);
|
|
254
|
+
return { conversation: publicConversation(view), message: await this.submit(view, text, 'first_message') };
|
|
255
|
+
}
|
|
256
|
+
if (view.state !== 'active') throw new SafeError(consentErrors.closed(view.state));
|
|
257
|
+
const mode = await this.enter(view);
|
|
258
|
+
if (mode === 'gated') return { conversation: publicConversation(view, { mode }), message: await this.submit(view, text, 'service_message') };
|
|
259
|
+
const stanza = await this.encrypt(view, text);
|
|
260
|
+
const id = stanza.getAttr('id');
|
|
261
|
+
this.remember(id, text);
|
|
262
|
+
await this.session.client.send(stanza);
|
|
263
|
+
return { conversation: publicConversation(view, { mode }), message: { id, state: 'sent' } };
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* Recipients, discovered fresh before EVERY encryption: owners observe
|
|
268
|
+
* through per-installation device JIDs (lbframe/wacando #35/#108). There is
|
|
269
|
+
* no fallback of any kind (never `usr_`, never a smaller set): a server
|
|
270
|
+
* without discovery is refused.
|
|
271
|
+
*/
|
|
272
|
+
async recipients(view) {
|
|
273
|
+
try {
|
|
274
|
+
const body = await this.runtime.api(this.agent, 'GET', `/v1/conversations/${encodeURIComponent(view.id)}/recipients`);
|
|
275
|
+
return discoveredRecipients(body, view).jids;
|
|
276
|
+
} catch (error) {
|
|
277
|
+
if (error instanceof ApiError && error.unknownRoute) throw new SafeError(consentErrors.noDiscovery);
|
|
278
|
+
if (error instanceof ApiError && error.code === 'SECURE_MESSAGING_UNAVAILABLE') throw new SafeError(consentErrors.noDevice);
|
|
279
|
+
throw error instanceof ApiError ? apiFailure(error) : error;
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
async encrypt(view, text) {
|
|
284
|
+
const recipients = await this.recipients(view);
|
|
285
|
+
try { return await this.session.provider.encryptForRoom(this.session.client, this.room(view.id), recipients, text); }
|
|
286
|
+
catch { throw new SafeError(consentErrors.noDevice); }
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/** Encrypt for the room, then hand the ciphertext to the policy component. */
|
|
290
|
+
async submit(view, text, kind) {
|
|
291
|
+
const stanza = await this.encrypt(view, text);
|
|
292
|
+
const id = stanza.getAttr('id');
|
|
293
|
+
let timer;
|
|
294
|
+
const status = new Promise((resolve, reject) => {
|
|
295
|
+
this.submissions.set(id, resolve);
|
|
296
|
+
timer = setTimeout(() => reject(new SafeError('The policy component did not answer; the message may not be held. Retry later.')), SUBMISSION_TIMEOUT_MS);
|
|
297
|
+
}).finally(() => { clearTimeout(timer); this.submissions.delete(id); });
|
|
298
|
+
this.remember(id, text);
|
|
299
|
+
await this.session.client.send(toPolicySubmission(stanza, this.policyJid));
|
|
300
|
+
const attrs = await status;
|
|
301
|
+
if (attrs.state !== 'held') {
|
|
302
|
+
this.sent.delete(id);
|
|
303
|
+
return { id, state: 'refused', reason: attrs.reason ?? 'refused' };
|
|
304
|
+
}
|
|
305
|
+
const held = { id, conversationId: view.id, kind, state: 'held', heldAt: new Date().toISOString(),
|
|
306
|
+
expiresAt: new Date(Date.now() + HOLD_TTL_MS).toISOString(), digest: attrs.digest,
|
|
307
|
+
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" };
|
|
308
|
+
this.held.set(id, held);
|
|
309
|
+
return { ...held };
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/** Join as a member when the room admits us; otherwise this agent is gated. */
|
|
313
|
+
async enter(view) {
|
|
314
|
+
const known = this.rooms.get(view.id);
|
|
315
|
+
if (known) return known;
|
|
316
|
+
const room = this.room(view.id);
|
|
317
|
+
this.tracker.trackRoom(room);
|
|
318
|
+
try {
|
|
319
|
+
await joinRoom(this.session.client, room, this.nick);
|
|
320
|
+
this.rooms.set(view.id, 'member');
|
|
321
|
+
} catch (error) {
|
|
322
|
+
if (!(error instanceof MucJoinError)) throw new SafeError('Secure messaging is unavailable. Retry later.');
|
|
323
|
+
this.rooms.set(view.id, 'gated');
|
|
324
|
+
}
|
|
325
|
+
return this.rooms.get(view.id);
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/** Messages of an ACCEPTED conversation: archive (members) or live deliveries (gated). */
|
|
329
|
+
async read(conversationId) {
|
|
330
|
+
const view = await this.conversation(conversationId);
|
|
331
|
+
if (!view.accepted) throw new SafeError(consentErrors.notAccepted);
|
|
332
|
+
const mode = view.state === 'active' ? await this.enter(view) : (this.rooms.get(view.id) ?? 'gated');
|
|
333
|
+
if (mode === 'member') await this.catchUp(view);
|
|
334
|
+
await this.chain;
|
|
335
|
+
const messages = [...(this.transcripts.get(view.id)?.values() ?? [])].sort((a, b) => a.at.localeCompare(b.at));
|
|
336
|
+
return { conversation: publicConversation(view, { mode }), messages,
|
|
337
|
+
...(mode === 'gated' ? { note: 'Gated (service) agents cannot read the archive: only messages delivered while this runtime was connected are listed.' } : {}) };
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
async catchUp(view) {
|
|
341
|
+
// RSM `after` is not exposed by the shared helper: page forward by the
|
|
342
|
+
// last archived timestamp (inclusive) and stop when a page adds nothing.
|
|
343
|
+
const room = this.room(view.id);
|
|
344
|
+
let start;
|
|
345
|
+
for (let page = 0; page < MAM_MAX_PAGES; page++) {
|
|
346
|
+
let result;
|
|
347
|
+
try { result = await queryMam(this.session.client, { to: room, max: MAM_PAGE, ...(start ? { start } : {}), trustedRelayJid: this.policyJid }); }
|
|
348
|
+
catch { throw new SafeError('Conversation history is unavailable. Retry later.'); }
|
|
349
|
+
const before = this.transcripts.get(view.id)?.size ?? 0;
|
|
350
|
+
for (const forwarded of result.messages) {
|
|
351
|
+
const stamp = forwarded.getChild('delay')?.getAttr('stamp');
|
|
352
|
+
if (stamp) start = stamp;
|
|
353
|
+
const inner = forwardedStanza(forwarded);
|
|
354
|
+
if (!inner?.getChild('encrypted', NS_OMEMO)) continue;
|
|
355
|
+
await this.ingest(inner, 'archive', stamp);
|
|
356
|
+
}
|
|
357
|
+
if (result.complete || result.messages.length < MAM_PAGE || (this.transcripts.get(view.id)?.size ?? 0) === before) break;
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
/** Wait for the next message from the other agent (bounded; no wake-up when closed). */
|
|
362
|
+
waitForMessage(conversationId, timeoutMs) {
|
|
363
|
+
return new Promise(resolve => {
|
|
364
|
+
const waiter = entry => {
|
|
365
|
+
if (conversationId && entry.conversationId !== conversationId) return;
|
|
366
|
+
done(entry);
|
|
367
|
+
};
|
|
368
|
+
const timer = setTimeout(() => done(null), timeoutMs);
|
|
369
|
+
const done = entry => { clearTimeout(timer); this.waiters.delete(waiter); this.cancels.delete(done); resolve(entry); };
|
|
370
|
+
this.waiters.add(waiter);
|
|
371
|
+
this.cancels.add(done);
|
|
372
|
+
});
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
/** Submissions still waiting for this agent's owner. */
|
|
376
|
+
pending() {
|
|
377
|
+
return [...this.held.values()].filter(h => h.state === 'held').map(h => ({ ...h, expired: Date.parse(h.expiresAt) <= Date.now() }));
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
close() {
|
|
381
|
+
this.tracker.close();
|
|
382
|
+
for (const cancel of [...this.cancels]) cancel(null);
|
|
383
|
+
}
|
|
384
|
+
}
|
|
@@ -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
|
+
}
|