@wacando/agent 2.2.0 → 2.4.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.
- package/README.md +1 -1
- package/STATUS.md +45 -0
- package/bin/wacando-agent.mjs +28 -11
- package/docs/AGENT_TOOLS.md +60 -11
- package/docs/ONBOARDING.md +5 -1
- package/docs/WACANDO_PACKAGES.md +12 -1
- package/package.json +2 -2
- package/packages/mcp/agent-tools.mjs +6 -4
- package/packages/onboarding/network.mjs +36 -6
- package/packages/onboarding/service.mjs +29 -3
- package/packages/onboarding/version.mjs +1 -1
- package/packages/runtime/conversations.mjs +17 -7
- package/packages/runtime/files.mjs +171 -20
- package/packages/runtime/pairing.mjs +6 -1
- package/packages/runtime/runtime.mjs +23 -5
- package/packages/runtime/session.mjs +42 -6
- package/packages/wacando/chunks/{chunk-PHGOH4AU.mjs → chunk-F7J3K43U.mjs} +2 -2
- package/packages/wacando/chunks/{chunk-PZDQXGA6.mjs → chunk-FFLYSYV6.mjs} +1 -1
- package/packages/wacando/chunks/{chunk-53E7SSTF.mjs → chunk-FUNUGIGH.mjs} +384 -21
- package/packages/wacando/omemo-wire.mjs +3 -3
- package/packages/wacando/omemo.mjs +13 -8
- package/packages/wacando/xmpp.mjs +9 -3
- package/plugins/wacando/.codex-plugin/plugin.json +1 -1
- package/plugins/wacando-claude/.claude-plugin/plugin.json +1 -1
- package/plugins/wacando-cursor/.cursor-plugin/plugin.json +1 -1
- package/provenance.json +20 -18
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { constants } from 'node:fs';
|
|
2
|
-
import { lstat, mkdir, open, readdir, rename, rm, unlink } from 'node:fs/promises';
|
|
2
|
+
import { lstat, mkdir, open, readdir, rename, rm, stat, unlink } from 'node:fs/promises';
|
|
3
3
|
import { randomUUID } from 'node:crypto';
|
|
4
4
|
import { basename, join, resolve } from 'node:path';
|
|
5
5
|
import {
|
|
@@ -7,7 +7,7 @@ import {
|
|
|
7
7
|
MAX_FILE_PLAINTEXT_BYTES, parseFileShare, sanitizeFileName,
|
|
8
8
|
} from '#wacando/omemo';
|
|
9
9
|
import { requestUploadSlot, uploadServiceJid } from '#wacando/xmpp';
|
|
10
|
-
import { ApiError, serviceUrl } from '../onboarding/network.mjs';
|
|
10
|
+
import { ApiError, LEGACY_APP_ORIGIN, PRODUCTION_API_ORIGIN, serviceUrl } from '../onboarding/network.mjs';
|
|
11
11
|
import { protectedDirectory, SafeError, storageError } from '../onboarding/storage.mjs';
|
|
12
12
|
|
|
13
13
|
// Encrypted file transfer under owner approval (lbframe/wacando-agent#10;
|
|
@@ -28,15 +28,28 @@ import { protectedDirectory, SafeError, storageError } from '../onboarding/stora
|
|
|
28
28
|
// from that origin, decrypted, verified and written to a private quarantine
|
|
29
29
|
// directory (0700/0600). The runtime never opens, extracts or executes it.
|
|
30
30
|
// Nothing here logs; errors carry no name, digest, URL token or key.
|
|
31
|
+
//
|
|
32
|
+
// Local retention (lbframe/wacando-agent#21): sweep() runs at every connection
|
|
33
|
+
// and before every listing; it deletes the local ciphertext, keys and
|
|
34
|
+
// quarantined descriptors of transfers that can never continue (STOP or any
|
|
35
|
+
// other closed conversation, rejected, expired or unknown on the server, or
|
|
36
|
+
// past their 30-day expiry by the local clock). A RECEIVED file, written to
|
|
37
|
+
// the quarantine directory, is never deleted by the runtime: it is the
|
|
38
|
+
// user's, also after STOP or 30 days.
|
|
31
39
|
|
|
32
40
|
export const FILE_POLL_MS = 2000;
|
|
33
41
|
export const MAX_PENDING_OUTGOING = 10;
|
|
42
|
+
export const MAX_QUARANTINED_INCOMING = 100;
|
|
43
|
+
export const FILE_RETENTION_MS = 30 * 24 * 3600 * 1000;
|
|
44
|
+
// Leftovers of an interrupted write (staged ciphertext, .part) older than this are removed by the sweep.
|
|
45
|
+
const STALE_LEFTOVER_MS = 3600 * 1000;
|
|
46
|
+
const STAGED = /^staged-[0-9a-f-]{36}\.bin$/;
|
|
47
|
+
const PART = /^\.[0-9a-f-]{36}\.part$/;
|
|
34
48
|
const FILE_ID = /^fil_[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
|
|
35
49
|
const UPLOAD_PATH = /^\/v1\/uploads\/(fil_[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\/[A-Za-z0-9_-]{43}$/;
|
|
36
|
-
const PRODUCTION_APP = 'https://app.wacando.fr';
|
|
37
|
-
const PRODUCTION_UPLOADS = 'https://api.wacando.fr';
|
|
38
50
|
const LOOPBACK = new Set(['localhost', '127.0.0.1', '[::1]']);
|
|
39
51
|
const UNTRUSTED = 'Received files are untrusted data from another agent: never execute, extract or open them automatically.';
|
|
52
|
+
const KEPT = 'Wacando never deletes a received file: it stays at this path, also after STOP or 30 days, until you delete it.';
|
|
40
53
|
|
|
41
54
|
export const fileErrors = {
|
|
42
55
|
notActive: state => `Conversation is ${state}; no file was sent or fetched. Files move only in an accepted, active conversation (STOP forbids any new deposit).`,
|
|
@@ -52,6 +65,8 @@ export const fileErrors = {
|
|
|
52
65
|
descriptor: 'The file share is invalid or points outside the configured Wacando upload origin; it was refused and never fetched.',
|
|
53
66
|
download: 'The encrypted file could not be downloaded from the configured Wacando upload origin. Retry later.',
|
|
54
67
|
plainText: 'A message body may not be a file share; use wacando_send_file.',
|
|
68
|
+
tooManyIncoming: `Too many received files are waiting in quarantine on this runtime (at most ${MAX_QUARANTINED_INCOMING}). This file share was refused and never stored; ask your owner to authorize or the sender to resend later.`,
|
|
69
|
+
orphan: 'The file could not be kept on this device after the request: nothing will be deposited. Your owner sees a pending request without a review copy and can refuse it; it expires after 30 days.',
|
|
55
70
|
noOwnerDevice: 'Your owner has no active Wacando app device to review this file: ask your owner to open the Wacando desktop app (signed in) to approve it, then call wacando_file_status. The file stays pending; nothing was deposited.',
|
|
56
71
|
reviewRetry: 'The review copy for your owner could not be deposited yet; call wacando_file_status to retry. The file stays pending.',
|
|
57
72
|
};
|
|
@@ -77,11 +92,12 @@ export const fileApiMessages = {
|
|
|
77
92
|
/**
|
|
78
93
|
* Upload origin this runtime trusts for file downloads and deposits: an
|
|
79
94
|
* explicit setting, then WACANDO_UPLOAD_ORIGIN, then the configured API
|
|
80
|
-
* origin (
|
|
95
|
+
* origin (an identity saved under the former default, the web app origin,
|
|
96
|
+
* uses the production API origin). Never derived
|
|
81
97
|
* from a message. Plain http is accepted only on loopback (local stacks).
|
|
82
98
|
*/
|
|
83
99
|
export function uploadPin(apiOrigin, setting = process.env.WACANDO_UPLOAD_ORIGIN) {
|
|
84
|
-
const value = setting ?? (apiOrigin ===
|
|
100
|
+
const value = setting ?? (apiOrigin === LEGACY_APP_ORIGIN ? PRODUCTION_API_ORIGIN : apiOrigin);
|
|
85
101
|
let url;
|
|
86
102
|
try { url = serviceUrl(value, { development: true }); }
|
|
87
103
|
catch { throw new SafeError('WACANDO_UPLOAD_ORIGIN must be an https origin (http only on loopback).'); }
|
|
@@ -144,6 +160,26 @@ export function ownOwnerId(view, agentId) {
|
|
|
144
160
|
return owner;
|
|
145
161
|
}
|
|
146
162
|
|
|
163
|
+
/**
|
|
164
|
+
* Pure: whether a message body is a Wacando file descriptor, whatever its
|
|
165
|
+
* whitespace or key order: 'share' (file share, `wacandoFile`), 'review'
|
|
166
|
+
* (owner review descriptor, `wacandoFileReview`, never meant for a room) or
|
|
167
|
+
* null. Such a body is never shown nor sent as text (it carries a key).
|
|
168
|
+
*/
|
|
169
|
+
export function fileBodyKind(text) {
|
|
170
|
+
if (typeof text !== 'string') return null;
|
|
171
|
+
const trimmed = text.trim();
|
|
172
|
+
if (!trimmed.startsWith('{')) return null;
|
|
173
|
+
let value;
|
|
174
|
+
try { value = JSON.parse(trimmed); } catch {
|
|
175
|
+
// Not JSON: still a descriptor when it opens like one (never shown as text).
|
|
176
|
+
return /^\{\s*"wacandoFileReview"\s*:/.test(trimmed) ? 'review' : /^\{\s*"wacandoFile"\s*:/.test(trimmed) ? 'share' : null;
|
|
177
|
+
}
|
|
178
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) return null;
|
|
179
|
+
if (Object.hasOwn(value, 'wacandoFileReview')) return 'review';
|
|
180
|
+
return Object.hasOwn(value, 'wacandoFile') ? 'share' : null;
|
|
181
|
+
}
|
|
182
|
+
|
|
147
183
|
const BARE_JID = /^[^\s/@]+@[^\s/@]+$/;
|
|
148
184
|
const hex = bytes => Buffer.from(bytes).toString('hex');
|
|
149
185
|
const unhex = text => new Uint8Array(Buffer.from(text, 'hex'));
|
|
@@ -161,12 +197,14 @@ async function readPrivate(path, limit) {
|
|
|
161
197
|
} finally { await file?.close(); }
|
|
162
198
|
}
|
|
163
199
|
|
|
164
|
-
|
|
200
|
+
// `openFile` is fs/promises `open`; tests inject one whose writes fail like a
|
|
201
|
+
// full disk (a read-only directory does not stop root, e.g. in a CI container).
|
|
202
|
+
async function writePrivate(directory, name, bytes, openFile = open) {
|
|
165
203
|
const path = join(directory, name);
|
|
166
204
|
const tmp = join(directory, `.${randomUUID()}.part`);
|
|
167
205
|
let file;
|
|
168
206
|
try {
|
|
169
|
-
file = await
|
|
207
|
+
file = await openFile(tmp, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o600);
|
|
170
208
|
await file.chmod(0o600);
|
|
171
209
|
await file.writeFile(bytes);
|
|
172
210
|
await file.sync();
|
|
@@ -189,9 +227,10 @@ async function writePrivate(directory, name, bytes) {
|
|
|
189
227
|
* Each record is deleted (key included) once it is no longer needed.
|
|
190
228
|
*/
|
|
191
229
|
export class FileVault {
|
|
192
|
-
constructor(base, quarantineRoot) {
|
|
230
|
+
constructor(base, quarantineRoot, { openFile = open } = {}) {
|
|
193
231
|
this.base = base;
|
|
194
232
|
this.quarantineRoot = quarantineRoot;
|
|
233
|
+
this.openFile = openFile;
|
|
195
234
|
}
|
|
196
235
|
async dir(kind) { await protectedDirectory(this.base); return protectedDirectory(join(this.base, kind)); }
|
|
197
236
|
async quarantine() { return protectedDirectory(this.quarantineRoot ?? join(this.base, 'quarantine')); }
|
|
@@ -203,7 +242,7 @@ export class FileVault {
|
|
|
203
242
|
if (!bytes) return null;
|
|
204
243
|
try { return JSON.parse(bytes.toString('utf8')); } catch { throw storageError(); }
|
|
205
244
|
}
|
|
206
|
-
async write(kind, fileId, record) { await writePrivate(await this.dir(kind), `${FileVault.id(fileId)}.json`, JSON.stringify(record)); }
|
|
245
|
+
async write(kind, fileId, record) { await writePrivate(await this.dir(kind), `${FileVault.id(fileId)}.json`, JSON.stringify(record), this.openFile); }
|
|
207
246
|
async remove(kind, fileId) {
|
|
208
247
|
const dir = await this.dir(kind);
|
|
209
248
|
for (const name of [`${FileVault.id(fileId)}.json`, `${fileId}.bin`]) await unlink(join(dir, name)).catch(error => { if (error.code !== 'ENOENT') throw storageError(); });
|
|
@@ -214,12 +253,35 @@ export class FileVault {
|
|
|
214
253
|
for (const name of names) { const record = await this.read(kind, name.slice(0, -5)); if (record) records.push(record); }
|
|
215
254
|
return records;
|
|
216
255
|
}
|
|
217
|
-
async writeCiphertext(fileId, bytes) { await writePrivate(await this.dir('outgoing'), `${FileVault.id(fileId)}.bin`, bytes); }
|
|
218
256
|
async readCiphertext(fileId) {
|
|
219
257
|
const bytes = await readPrivate(join(await this.dir('outgoing'), `${FileVault.id(fileId)}.bin`), MAX_FILE_PLAINTEXT_BYTES + 16);
|
|
220
258
|
return bytes ? new Uint8Array(bytes.buffer, bytes.byteOffset, bytes.length) : null;
|
|
221
259
|
}
|
|
222
260
|
async dropCiphertext(fileId) { await unlink(join(await this.dir('outgoing'), `${FileVault.id(fileId)}.bin`)).catch(() => {}); }
|
|
261
|
+
/** Write a ciphertext BEFORE its server request (a full disk fails here, before the server knows the file). */
|
|
262
|
+
async stageCiphertext(bytes) {
|
|
263
|
+
const name = `staged-${randomUUID()}.bin`;
|
|
264
|
+
await writePrivate(await this.dir('outgoing'), name, bytes, this.openFile);
|
|
265
|
+
return name;
|
|
266
|
+
}
|
|
267
|
+
async commitCiphertext(staged, fileId) {
|
|
268
|
+
const dir = await this.dir('outgoing');
|
|
269
|
+
try { await rename(join(dir, staged), join(dir, `${FileVault.id(fileId)}.bin`)); } catch { throw storageError(); }
|
|
270
|
+
}
|
|
271
|
+
async dropStaged(staged) { if (STAGED.test(staged ?? '')) await unlink(join(await this.dir('outgoing'), staged)).catch(() => {}); }
|
|
272
|
+
/** Remove interrupted-write leftovers (staged ciphertext, .part files) older than `before` (ms). Returns the count. */
|
|
273
|
+
async dropLeftovers(before) {
|
|
274
|
+
let count = 0;
|
|
275
|
+
for (const kind of ['outgoing', 'incoming']) {
|
|
276
|
+
const dir = await this.dir(kind);
|
|
277
|
+
for (const name of await readdir(dir)) {
|
|
278
|
+
if (!STAGED.test(name) && !PART.test(name)) continue;
|
|
279
|
+
const info = await stat(join(dir, name)).catch(() => null);
|
|
280
|
+
if (info?.isFile() && info.mtimeMs < before && await unlink(join(dir, name)).then(() => true, () => false)) count++;
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
return count;
|
|
284
|
+
}
|
|
223
285
|
|
|
224
286
|
/** Write released plaintext once, 0600 in a fresh 0700 directory; never executable. */
|
|
225
287
|
async place(fileId, name, bytes) {
|
|
@@ -228,7 +290,7 @@ export class FileVault {
|
|
|
228
290
|
await rm(directory, { recursive: true, force: true });
|
|
229
291
|
await mkdir(directory, { mode: 0o700 });
|
|
230
292
|
await protectedDirectory(directory);
|
|
231
|
-
return resolve(await writePrivate(directory, quarantineName(name), bytes));
|
|
293
|
+
return resolve(await writePrivate(directory, quarantineName(name), bytes, this.openFile));
|
|
232
294
|
}
|
|
233
295
|
}
|
|
234
296
|
|
|
@@ -293,15 +355,24 @@ export class FileTransfers {
|
|
|
293
355
|
*/
|
|
294
356
|
async send(conversationId, path, { timeoutMs = 0 } = {}) {
|
|
295
357
|
const view = await this.active(conversationId);
|
|
296
|
-
|
|
358
|
+
const waiting = async () => (await this.vault.list('outgoing')).filter(r => !r.sent).length;
|
|
359
|
+
if (await waiting() >= MAX_PENDING_OUTGOING) {
|
|
360
|
+
// Ended transfers do not count: clean them up before refusing.
|
|
361
|
+
await this.sweep().catch(() => null);
|
|
362
|
+
if (await waiting() >= MAX_PENDING_OUTGOING) throw new SafeError(fileErrors.tooMany);
|
|
363
|
+
}
|
|
297
364
|
const bytes = await readSource(path);
|
|
298
365
|
let encrypted;
|
|
299
366
|
try { encrypted = await encryptFile({ name: basename(String(path)), bytes }); }
|
|
300
367
|
catch (error) { throw cryptoFailure(error); }
|
|
368
|
+
// Kept locally BEFORE the request, so a local write failure cannot leave a
|
|
369
|
+
// server record this runtime does not know (lbframe/wacando-agent#21).
|
|
370
|
+
const staged = await this.vault.stageCiphertext(encrypted.ciphertext);
|
|
301
371
|
let file;
|
|
302
372
|
try { ({ file } = await this.c.runtime.api(this.c.agent, 'POST', `/v1/conversations/${encodeURIComponent(view.id)}/files`, encrypted.approval)); }
|
|
303
|
-
catch (error) { throw this.apiError(error); }
|
|
373
|
+
catch (error) { await this.vault.dropStaged(staged); throw this.apiError(error); }
|
|
304
374
|
if (!FILE_ID.test(file?.id ?? '') || file.ciphertextSha256 !== encrypted.approval.ciphertextSha256 || file.sizeBytes !== encrypted.approval.sizeBytes) {
|
|
375
|
+
await this.vault.dropStaged(staged);
|
|
305
376
|
throw new SafeError('The server answered an unexpected file record. Nothing was sent.');
|
|
306
377
|
}
|
|
307
378
|
const record = {
|
|
@@ -310,8 +381,16 @@ export class FileTransfers {
|
|
|
310
381
|
ciphertextSize: encrypted.approval.sizeBytes, key: hex(encrypted.key), iv: hex(encrypted.iv),
|
|
311
382
|
requestedAt: new Date(this.now()).toISOString(), expiresAt: file.expiresAt,
|
|
312
383
|
};
|
|
313
|
-
|
|
314
|
-
|
|
384
|
+
try {
|
|
385
|
+
await this.vault.commitCiphertext(staged, file.id);
|
|
386
|
+
await this.vault.write('outgoing', file.id, record);
|
|
387
|
+
} catch {
|
|
388
|
+
// The server record stays pending without a review copy: it can never be
|
|
389
|
+
// approved; the owner can refuse it and it expires after 30 days.
|
|
390
|
+
await this.vault.dropStaged(staged);
|
|
391
|
+
await this.vault.remove('outgoing', file.id).catch(() => {});
|
|
392
|
+
throw new SafeError(fileErrors.orphan);
|
|
393
|
+
}
|
|
315
394
|
// Right after the request: the owner's review copy (retried by advance()).
|
|
316
395
|
return this.once(file.id, async () => {
|
|
317
396
|
const review = await this.review(record);
|
|
@@ -538,6 +617,9 @@ export class FileTransfers {
|
|
|
538
617
|
}
|
|
539
618
|
return { ...publicIncoming(known), state: known.path ? 'received' : 'quarantined' };
|
|
540
619
|
}
|
|
620
|
+
if ((await this.vault.list('incoming')).filter(r => !r.path).length >= MAX_QUARANTINED_INCOMING) {
|
|
621
|
+
return { state: 'refused', reason: 'too_many_quarantined', note: fileErrors.tooManyIncoming };
|
|
622
|
+
}
|
|
541
623
|
const record = { fileId, conversationId, messageId, share, ciphertextSha256: share.ciphertextSha256, receivedAt: new Date(this.now()).toISOString() };
|
|
542
624
|
await this.vault.write('incoming', fileId, record);
|
|
543
625
|
return { ...publicIncoming(record), state: 'quarantined', note: `In quarantine until your owner authorizes it; then call wacando_receive_file. ${UNTRUSTED}` };
|
|
@@ -552,7 +634,7 @@ export class FileTransfers {
|
|
|
552
634
|
|
|
553
635
|
async incomingState(record, { timeoutMs, fetch }) {
|
|
554
636
|
const summary = publicIncoming(record);
|
|
555
|
-
if (record.path) return { file: summary, state: 'received', path: record.path, note: UNTRUSTED };
|
|
637
|
+
if (record.path) return { file: summary, state: 'received', path: record.path, note: `${UNTRUSTED} ${KEPT}` };
|
|
556
638
|
const deadline = this.now() + timeoutMs;
|
|
557
639
|
for (;;) {
|
|
558
640
|
const view = await this.c.conversation(record.conversationId);
|
|
@@ -592,16 +674,85 @@ export class FileTransfers {
|
|
|
592
674
|
mediaType: file.mediaType, size: file.bytes.length, ciphertextSha256: record.share.ciphertextSha256, path, receivedAt: record.receivedAt };
|
|
593
675
|
// The key is no longer needed once the plaintext is written: forget it.
|
|
594
676
|
await this.vault.write('incoming', record.fileId, kept);
|
|
595
|
-
return { file: { ...publicIncoming(kept) }, state: 'received', path, sha256: record.share.sha256, note: UNTRUSTED };
|
|
677
|
+
return { file: { ...publicIncoming(kept) }, state: 'received', path, sha256: record.share.sha256, note: `${UNTRUSTED} ${KEPT}` };
|
|
596
678
|
}
|
|
597
679
|
|
|
598
|
-
/** Local file transfers of this identity (optionally one conversation), no keys. */
|
|
680
|
+
/** Local file transfers of this identity (optionally one conversation), no keys; ended transfers are cleaned up first. */
|
|
599
681
|
async list(conversationId) {
|
|
682
|
+
const cleaned = await this.sweep().catch(() => null);
|
|
600
683
|
const keep = r => !conversationId || r.conversationId === conversationId;
|
|
601
684
|
return {
|
|
602
685
|
outgoing: (await this.vault.list('outgoing')).filter(keep).map(r => ({ ...publicOutgoing(r), state: r.sent ? 'sent' : r.slotGet ? 'deposited' : 'awaiting_approval' })),
|
|
603
|
-
incoming: (await this.vault.list('incoming')).filter(keep).map(r => ({ ...publicIncoming(r), state: r.path ? 'received' : 'quarantined' })),
|
|
686
|
+
incoming: (await this.vault.list('incoming')).filter(keep).map(r => ({ ...publicIncoming(r), state: r.path ? 'received' : 'quarantined', ...(r.path ? { kept: true } : {}) })),
|
|
687
|
+
...(cleaned && (cleaned.outgoing || cleaned.incoming) ? { cleaned } : {}),
|
|
688
|
+
};
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
/**
|
|
692
|
+
* Delete the local secrets of every transfer that can never continue
|
|
693
|
+
* (lbframe/wacando-agent#21), at connection and before each listing:
|
|
694
|
+
* - pending/deposited sends (ciphertext + key) and quarantined descriptors
|
|
695
|
+
* (key): conversation no longer active (STOP is terminal) or unknown to
|
|
696
|
+
* the server, file unknown, different, rejected or expired on the server,
|
|
697
|
+
* or past its 30-day expiry by the local clock (even offline);
|
|
698
|
+
* - sent records (public metadata only, no key): past their expiry;
|
|
699
|
+
* - interrupted-write leftovers older than an hour.
|
|
700
|
+
* A received file (written to the quarantine directory) and its record are
|
|
701
|
+
* never touched. A server that cannot be reached removes nothing it cannot
|
|
702
|
+
* decide locally. Transfers in progress in this process are skipped.
|
|
703
|
+
* Returns content-free counts.
|
|
704
|
+
*/
|
|
705
|
+
sweep() {
|
|
706
|
+
this.sweeping ??= this.sweepOnce().finally(() => { this.sweeping = null; });
|
|
707
|
+
return this.sweeping;
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
async sweepOnce() {
|
|
711
|
+
const now = this.now();
|
|
712
|
+
const counts = { outgoing: 0, incoming: 0 };
|
|
713
|
+
const servers = new Map(); // conversationId -> Promise<{state, files} | null>
|
|
714
|
+
const server = id => {
|
|
715
|
+
if (!servers.has(id)) servers.set(id, this.serverState(id));
|
|
716
|
+
return servers.get(id);
|
|
604
717
|
};
|
|
718
|
+
for (const direction of ['outgoing', 'incoming']) {
|
|
719
|
+
for (const record of await this.vault.list(direction)) {
|
|
720
|
+
if (direction === 'incoming' && record.path) continue; // received: the user's file
|
|
721
|
+
if (this.busy.has(record.fileId)) continue;
|
|
722
|
+
if (!(await this.ended(direction, record, now, server))) continue;
|
|
723
|
+
if (this.busy.has(record.fileId)) continue;
|
|
724
|
+
await this.vault.remove(direction, record.fileId);
|
|
725
|
+
counts[direction]++;
|
|
726
|
+
}
|
|
727
|
+
}
|
|
728
|
+
await this.vault.dropLeftovers(now - STALE_LEFTOVER_MS).catch(() => 0);
|
|
729
|
+
return counts;
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
/** Conversation state and files as the server lists them for this agent; 'gone' when unknown; null when unreachable. */
|
|
733
|
+
async serverState(conversationId) {
|
|
734
|
+
let state;
|
|
735
|
+
try { state = (await this.c.runtime.api(this.c.agent, 'GET', `/v1/conversations/${encodeURIComponent(conversationId)}`))?.conversation?.status; }
|
|
736
|
+
catch (error) { return error instanceof ApiError && error.code === 'NOT_FOUND' ? { state: 'gone' } : null; }
|
|
737
|
+
if (typeof state !== 'string') return null;
|
|
738
|
+
if (state !== 'active') return { state };
|
|
739
|
+
try { return { state, files: await this.serverFiles(conversationId) }; } catch { return null; }
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
async ended(direction, record, now, server) {
|
|
743
|
+
// The server's expiry, else 30 days after the local request or receipt
|
|
744
|
+
// (a file is requested before it is received: never earlier than the server).
|
|
745
|
+
const started = Date.parse(record.receivedAt ?? record.requestedAt ?? '');
|
|
746
|
+
const expiresAt = Date.parse(record.expiresAt ?? '') || started + FILE_RETENTION_MS;
|
|
747
|
+
if (expiresAt <= now) return true;
|
|
748
|
+
if (direction === 'outgoing' && record.sent) return false; // no key left; kept until its expiry
|
|
749
|
+
const known = await server(record.conversationId);
|
|
750
|
+
if (!known) return false;
|
|
751
|
+
if (known.state !== 'active') return true;
|
|
752
|
+
const dto = known.files.find(f => f.id === record.fileId);
|
|
753
|
+
if (!dto || this.expired(dto)) return true;
|
|
754
|
+
if (direction === 'outgoing') return dto.ciphertextSha256 !== record.ciphertextSha256 || dto.sizeBytes !== record.ciphertextSize || dto.status === 'rejected';
|
|
755
|
+
return dto.recipientAgentId !== this.c.agent.agentId || dto.ciphertextSha256 !== record.share?.ciphertextSha256;
|
|
605
756
|
}
|
|
606
757
|
}
|
|
607
758
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { createPrivateKey, createPublicKey, generateKeyPairSync, randomBytes, sign } from 'node:crypto';
|
|
2
2
|
import { hostname } from 'node:os';
|
|
3
|
-
import { ApiError, post } from '../onboarding/network.mjs';
|
|
3
|
+
import { ApiError, apiNotFound, post } from '../onboarding/network.mjs';
|
|
4
4
|
import { SafeError } from '../onboarding/storage.mjs';
|
|
5
5
|
|
|
6
6
|
// Universal pairing (lbframe/wacando docs/ENROLLMENT_PAIRING_API.md, #36/#60).
|
|
@@ -71,6 +71,10 @@ export async function redeem({ origin, code, host, label, mode, saveKey, request
|
|
|
71
71
|
proof: proof(key.privateKeyPem, redeemMessage(code, key.publicKey, clientNonce, issuedAt)),
|
|
72
72
|
});
|
|
73
73
|
} catch (error) {
|
|
74
|
+
// Not the Wacando API (e.g. the web site answering an HTML 404): a wrong
|
|
75
|
+
// origin, never a code refusal. Every real API refusal keeps one message,
|
|
76
|
+
// so the answer reveals nothing about a code.
|
|
77
|
+
if (error instanceof ApiError && error.unknownRoute) throw new SafeError(apiNotFound(origin));
|
|
74
78
|
if (error instanceof ApiError && [400, 401, 403, 404].includes(error.status)) throw new SafeError(pairingErrors.refused);
|
|
75
79
|
if (error instanceof ApiError && error.status === 429) throw new SafeError('Too many pairing attempts. Wait and retry with the same code.');
|
|
76
80
|
throw error;
|
|
@@ -100,6 +104,7 @@ export async function claim({ pending, request = post, sleep, now = Date.now, de
|
|
|
100
104
|
return data;
|
|
101
105
|
} catch (error) {
|
|
102
106
|
if (!(error instanceof ApiError)) throw error;
|
|
107
|
+
if (error.unknownRoute) throw new SafeError(apiNotFound(pending.origin));
|
|
103
108
|
if (error.status === 429) wait = Math.max(POLL_INTERVAL_MS, (error.retryAfter ?? 5) * 1000);
|
|
104
109
|
else if (error.status !== 401) throw new SafeError(`Pairing service error (HTTP ${error.status}). Retry with the same command.`);
|
|
105
110
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { OnboardingService } from '../onboarding/service.mjs';
|
|
2
2
|
import { join } from 'node:path';
|
|
3
3
|
import { CredentialStore, digest, SafeError } from '../onboarding/storage.mjs';
|
|
4
|
-
import { configuredOrigin, get, post } from '../onboarding/network.mjs';
|
|
4
|
+
import { configuredOrigin, get, legacyOrigins, post } from '../onboarding/network.mjs';
|
|
5
5
|
import { ConversationClient } from './conversations.mjs';
|
|
6
6
|
import { FileTransfers, FileVault, uploadPin } from './files.mjs';
|
|
7
7
|
import { claim, pairingErrors, redeem } from './pairing.mjs';
|
|
@@ -55,16 +55,34 @@ export class AgentRuntime {
|
|
|
55
55
|
/** Configured service origin (validated on use, like the onboarding tools). */
|
|
56
56
|
get origin() { return configuredOrigin(this.originSetting); }
|
|
57
57
|
|
|
58
|
+
/**
|
|
59
|
+
* Saved identity records for this origin and host. For the production API
|
|
60
|
+
* this includes identities saved under the former default origin (the web
|
|
61
|
+
* app, up to 2.2.0): they are used in place, under their saved origin, so
|
|
62
|
+
* their OMEMO device, file state and locks stay exactly where they are and
|
|
63
|
+
* an older runtime still finds them. Nothing is rewritten or moved.
|
|
64
|
+
*/
|
|
65
|
+
async savedAgents() {
|
|
66
|
+
const agents = [];
|
|
67
|
+
const seen = new Set();
|
|
68
|
+
for (const origin of legacyOrigins(this.origin)) {
|
|
69
|
+
for (const agent of await this.store.list(origin, this.host, 'agent')) {
|
|
70
|
+
if (agent.origin !== origin || seen.has(agent.agentId)) continue;
|
|
71
|
+
seen.add(agent.agentId);
|
|
72
|
+
agents.push(agent);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
return agents;
|
|
76
|
+
}
|
|
77
|
+
|
|
58
78
|
/** Saved identities for this origin and host (public fields only). */
|
|
59
79
|
async identities() {
|
|
60
|
-
|
|
61
|
-
return agents.filter(agent => agent.origin === this.origin)
|
|
62
|
-
.map(agent => publicIdentity(agent, this.service.states.get(agent.agentId)));
|
|
80
|
+
return (await this.savedAgents()).map(agent => publicIdentity(agent, this.service.states.get(agent.agentId)));
|
|
63
81
|
}
|
|
64
82
|
|
|
65
83
|
/** Resolve a saved identity by agent ID or handle; a single identity is the default. */
|
|
66
84
|
async resolve(selector) {
|
|
67
|
-
const agents =
|
|
85
|
+
const agents = await this.savedAgents();
|
|
68
86
|
const matches = selector === undefined ? agents
|
|
69
87
|
: agents.filter(agent => agent.agentId === selector || (handlePattern.test(selector) && agent.handle === selector));
|
|
70
88
|
if (matches.length === 0) throw new SafeError('No saved identity matches. Pair this runtime first (wacando-agent pair <code>).');
|
|
@@ -1,10 +1,39 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
1
|
+
import { announceAvailable, refuseSubscriptionRequests, ScramAuthError, scramClient, startScramClient, xml } from '#wacando/xmpp';
|
|
2
|
+
import { SCRAM_REFUSED, XMPP_SERVER_UNVERIFIED } from '../onboarding/service.mjs';
|
|
3
3
|
import { SafeError } from '../onboarding/storage.mjs';
|
|
4
4
|
import { loadDevice } from './device.mjs';
|
|
5
5
|
|
|
6
|
+
/**
|
|
7
|
+
* @xmpp/connection 0.14.0 `start()` creates its "online" promise (rejected by
|
|
8
|
+
* any entity 'error') BEFORE awaiting `open()`. When an 'error' is emitted
|
|
9
|
+
* while the stream opens (Node.js 24's WebSocket reports a socket cut that
|
|
10
|
+
* way), `open()` rejects, `start()` throws without ever awaiting that promise
|
|
11
|
+
* and its rejection is unhandled: the whole process (daemon, MCP server)
|
|
12
|
+
* dies. Same steps, with the "online" promise always observed.
|
|
13
|
+
*/
|
|
14
|
+
function observeOnlinePromise(xmpp) {
|
|
15
|
+
xmpp.start = async function start() {
|
|
16
|
+
if (this.status !== 'offline') throw new Error('Connection is not offline');
|
|
17
|
+
const { service, domain, lang } = this.options;
|
|
18
|
+
await this.connect(service);
|
|
19
|
+
const online = new Promise((resolve, reject) => {
|
|
20
|
+
const settle = () => { this.removeListener('online', onOnline); this.removeListener('error', onError); };
|
|
21
|
+
const onOnline = value => { settle(); resolve(value); };
|
|
22
|
+
const onError = error => { settle(); reject(error); };
|
|
23
|
+
this.on('online', onOnline);
|
|
24
|
+
this.on('error', onError);
|
|
25
|
+
});
|
|
26
|
+
online.catch(() => {});
|
|
27
|
+
await this.open({ domain, lang });
|
|
28
|
+
return online;
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
|
|
6
32
|
const unavailable = () => new SafeError('Secure messaging is unavailable. Retry the saved identity.');
|
|
7
33
|
|
|
34
|
+
/** SafeError for a ScramAuthError: the fixed message plus its local reason enum (no secret). */
|
|
35
|
+
const serverUnverified = error => Object.assign(new SafeError(XMPP_SERVER_UNVERIFIED), { code: SCRAM_REFUSED, reason: error.reason });
|
|
36
|
+
|
|
8
37
|
/**
|
|
9
38
|
* One XMPP c2s stream (WebSocket) + this identity's native OMEMO device.
|
|
10
39
|
*
|
|
@@ -13,12 +42,18 @@ const unavailable = () => new SafeError('Secure messaging is unavailable. Retry
|
|
|
13
42
|
* delivery addressed to the bare JID after that point reaches this resource
|
|
14
43
|
* and none can be missed between "online" and the caller subscribing.
|
|
15
44
|
* Raw XMPP errors, stanzas and credentials are never logged.
|
|
45
|
+
*
|
|
46
|
+
* The client is the embedded `scramClient` (lbframe/wacando#186): SCRAM only,
|
|
47
|
+
* SASL1 only, and authentication succeeds only once the server's
|
|
48
|
+
* ServerSignature (`v=`) was verified. A refusal closes the connection,
|
|
49
|
+
* stops @xmpp/client's reconnect and rejects with `code: 'SCRAM_REFUSED'`.
|
|
16
50
|
*/
|
|
17
51
|
export async function connectSession({ jid, password, service, stateBaseDir, onStanza, onReplaced, timeoutMs = 15000 }) {
|
|
18
52
|
const provider = await loadDevice(jid, stateBaseDir);
|
|
19
53
|
const at = jid.indexOf('@');
|
|
20
54
|
const domain = jid.slice(at + 1);
|
|
21
|
-
const xmpp =
|
|
55
|
+
const xmpp = scramClient({ service, domain, username: jid.slice(0, at), password });
|
|
56
|
+
observeOnlinePromise(xmpp);
|
|
22
57
|
// One server-enforced session per agent (lbframe/wacando #15): a newer login
|
|
23
58
|
// ends this one with <conflict/>. Remember it so nothing reconnects in a loop.
|
|
24
59
|
let replaced = false;
|
|
@@ -31,15 +66,16 @@ export async function connectSession({ jid, password, service, stateBaseDir, onS
|
|
|
31
66
|
let timer;
|
|
32
67
|
const deadline = new Promise((_, reject) => { timer = setTimeout(() => reject(new Error('timeout')), timeoutMs); });
|
|
33
68
|
try {
|
|
34
|
-
await Promise.race([xmpp
|
|
69
|
+
await Promise.race([startScramClient(xmpp), deadline]);
|
|
35
70
|
xmpp.reconnect?.stop();
|
|
36
71
|
// The control plane owns the roster (#13): never approve a subscription.
|
|
37
72
|
refuseSubscriptionRequests(xmpp);
|
|
38
73
|
if (onStanza) xmpp.on('stanza', stanza => onStanza(stanza, xmpp));
|
|
39
74
|
await Promise.race([announceAvailable(xmpp, timeoutMs), deadline]);
|
|
40
|
-
} catch {
|
|
75
|
+
} catch (error) {
|
|
76
|
+
xmpp.reconnect?.stop();
|
|
41
77
|
await xmpp.stop().catch(() => {});
|
|
42
|
-
throw unavailable();
|
|
78
|
+
throw error instanceof ScramAuthError ? serverUnverified(error) : unavailable();
|
|
43
79
|
} finally { clearTimeout(timer); }
|
|
44
80
|
return {
|
|
45
81
|
client: xmpp, provider, bareJid: provider.bareJid, domain,
|
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
// Embedded from lbframe/wacando@
|
|
1
|
+
// Embedded from lbframe/wacando@40dd0c30b3653095f08994f9534fd5110e1d0925 by scripts/embed-wacando.mjs. Do not edit; see provenance.json.
|
|
2
2
|
import {
|
|
3
3
|
DomainError
|
|
4
|
-
} from "./chunk-
|
|
4
|
+
} from "./chunk-FFLYSYV6.mjs";
|
|
5
5
|
|
|
6
6
|
// packages/omemo/src/ns.ts
|
|
7
7
|
var NS_OMEMO = "eu.siacs.conversations.axolotl";
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// Embedded from lbframe/wacando@
|
|
1
|
+
// Embedded from lbframe/wacando@40dd0c30b3653095f08994f9534fd5110e1d0925 by scripts/embed-wacando.mjs. Do not edit; see provenance.json.
|
|
2
2
|
|
|
3
3
|
// packages/contracts/src/errors.ts
|
|
4
4
|
var DomainError = class extends Error {
|