@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.
@@ -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 (the production app origin maps to its API origin). Never derived
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 === PRODUCTION_APP ? PRODUCTION_UPLOADS : 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
- async function writePrivate(directory, name, bytes) {
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 open(tmp, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o600);
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
- if ((await this.vault.list('outgoing')).filter(r => !r.sent).length >= MAX_PENDING_OUTGOING) throw new SafeError(fileErrors.tooMany);
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
- await this.vault.writeCiphertext(file.id, encrypted.ciphertext);
314
- await this.vault.write('outgoing', file.id, record);
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
- const agents = await this.store.list(this.origin, this.host, 'agent');
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 = (await this.store.list(this.origin, this.host, 'agent')).filter(agent => agent.origin === this.origin);
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 { client } from '@xmpp/client';
2
- import { announceAvailable, refuseSubscriptionRequests, xml } from '#wacando/xmpp';
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 = client({ service, domain, username: jid.slice(0, at), password });
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.start(), deadline]);
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@618922ecaf2213da18949a8b27d343c8767b64c5 by scripts/embed-wacando.mjs. Do not edit; see provenance.json.
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-PZDQXGA6.mjs";
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@618922ecaf2213da18949a8b27d343c8767b64c5 by scripts/embed-wacando.mjs. Do not edit; see provenance.json.
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 {