@wacando/agent 2.3.0 → 2.4.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/STATUS.md +53 -0
- package/bin/wacando-agent.mjs +28 -11
- package/docs/AGENT_TOOLS.md +45 -11
- package/docs/WACANDO_PACKAGES.md +22 -1
- package/package.json +2 -1
- package/packages/onboarding/service.mjs +25 -1
- package/packages/onboarding/version.mjs +1 -1
- package/packages/runtime/conversations.mjs +9 -4
- package/packages/runtime/files.mjs +154 -38
- package/packages/runtime/runtime.mjs +8 -5
- package/packages/runtime/session.mjs +21 -6
- package/packages/wacando/chunks/{chunk-PZDQXGA6.mjs → chunk-4Z6TL6JY.mjs} +1 -1
- package/packages/wacando/chunks/chunk-5Q4655F4.mjs +811 -0
- package/packages/wacando/chunks/{chunk-PHGOH4AU.mjs → chunk-L6IDYNL5.mjs} +2 -2
- 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 +22 -18
- package/packages/wacando/chunks/chunk-53E7SSTF.mjs +0 -423
|
@@ -29,8 +29,10 @@ import { protectedDirectory, SafeError, storageError } from '../onboarding/stora
|
|
|
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
31
|
//
|
|
32
|
-
// Local retention (lbframe/wacando-agent#21): sweep() runs
|
|
33
|
-
//
|
|
32
|
+
// Local retention (lbframe/wacando-agent#21, #24): sweep() runs before the
|
|
33
|
+
// first file operation of each connection (at every connection for the
|
|
34
|
+
// daemon, never from a read-only tool), before every listing and before a
|
|
35
|
+
// full queue or quarantine refuses; it deletes the local ciphertext, keys and
|
|
34
36
|
// quarantined descriptors of transfers that can never continue (STOP or any
|
|
35
37
|
// other closed conversation, rejected, expired or unknown on the server, or
|
|
36
38
|
// past their 30-day expiry by the local clock). A RECEIVED file, written to
|
|
@@ -40,11 +42,15 @@ import { protectedDirectory, SafeError, storageError } from '../onboarding/stora
|
|
|
40
42
|
export const FILE_POLL_MS = 2000;
|
|
41
43
|
export const MAX_PENDING_OUTGOING = 10;
|
|
42
44
|
export const MAX_QUARANTINED_INCOMING = 100;
|
|
45
|
+
// One conversation (one peer) can fill only part of the quarantine (lbframe/wacando-agent#24).
|
|
46
|
+
export const MAX_QUARANTINED_PER_CONVERSATION = 20;
|
|
43
47
|
export const FILE_RETENTION_MS = 30 * 24 * 3600 * 1000;
|
|
44
|
-
// Leftovers of an interrupted write (staged ciphertext, .part
|
|
48
|
+
// Leftovers of an interrupted write (staged ciphertext, .part, a ciphertext
|
|
49
|
+
// without its record) older than this are removed by the sweep.
|
|
45
50
|
const STALE_LEFTOVER_MS = 3600 * 1000;
|
|
46
51
|
const STAGED = /^staged-[0-9a-f-]{36}\.bin$/;
|
|
47
52
|
const PART = /^\.[0-9a-f-]{36}\.part$/;
|
|
53
|
+
const COMMITTED = /^(fil_[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\.bin$/;
|
|
48
54
|
const FILE_ID = /^fil_[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
|
|
49
55
|
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}$/;
|
|
50
56
|
const LOOPBACK = new Set(['localhost', '127.0.0.1', '[::1]']);
|
|
@@ -66,6 +72,7 @@ export const fileErrors = {
|
|
|
66
72
|
download: 'The encrypted file could not be downloaded from the configured Wacando upload origin. Retry later.',
|
|
67
73
|
plainText: 'A message body may not be a file share; use wacando_send_file.',
|
|
68
74
|
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.`,
|
|
75
|
+
tooManyInConversation: `Too many received files of this conversation are waiting in quarantine (at most ${MAX_QUARANTINED_PER_CONVERSATION} per conversation). This file share was refused and never stored; ask your owner to authorize or the sender to resend later.`,
|
|
69
76
|
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.',
|
|
70
77
|
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.',
|
|
71
78
|
reviewRetry: 'The review copy for your owner could not be deposited yet; call wacando_file_status to retry. The file stays pending.',
|
|
@@ -161,23 +168,67 @@ export function ownOwnerId(view, agentId) {
|
|
|
161
168
|
}
|
|
162
169
|
|
|
163
170
|
/**
|
|
164
|
-
* Pure: whether a message body is a Wacando file descriptor
|
|
165
|
-
*
|
|
166
|
-
*
|
|
167
|
-
*
|
|
171
|
+
* Pure: whether a message body is, or carries, a Wacando file descriptor:
|
|
172
|
+
* 'share' (file share, `wacandoFile`), 'review' (owner review descriptor,
|
|
173
|
+
* `wacandoFileReview`, never meant for a room) or null. Such a body is never
|
|
174
|
+
* shown nor sent as text: it carries a key (lbframe/wacando-agent#21, #24).
|
|
175
|
+
* Detected whatever the whitespace, key order or JSON escaping, behind
|
|
176
|
+
* invisible format characters (ZWSP, BOM, word joiner...) or compatibility
|
|
177
|
+
* forms (fullwidth braces):
|
|
178
|
+
* - a JSON object with such a key at its top level (strict: whatever its
|
|
179
|
+
* values), or a body that opens like one but is not valid JSON;
|
|
180
|
+
* - a descriptor anywhere else (nested in JSON, in an array, in prose, a code
|
|
181
|
+
* block or a JSON string), only when its key material is there too: an
|
|
182
|
+
* `aesgcm://...#<88 hex>` URL for a share, a `"secret"` of 88 hex for a
|
|
183
|
+
* review. Text that merely names the format (e.g. a nested
|
|
184
|
+
* `{"wacandoFile":false}` setting) carries no key and stays text.
|
|
185
|
+
* Linear in the body size (a hostile body cannot stall the event loop).
|
|
168
186
|
*/
|
|
169
187
|
export function fileBodyKind(text) {
|
|
170
188
|
if (typeof text !== 'string') return null;
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
189
|
+
// Detection only: the body itself is never rewritten.
|
|
190
|
+
const plain = text.normalize('NFKC').replace(/\p{Cf}/gu, '').trim();
|
|
191
|
+
if (plain.startsWith('{') || plain.startsWith('[')) {
|
|
192
|
+
let value;
|
|
193
|
+
let parsed = true;
|
|
194
|
+
try { value = JSON.parse(plain); } catch { parsed = false; }
|
|
195
|
+
if (parsed) {
|
|
196
|
+
if (value && typeof value === 'object' && !Array.isArray(value)) {
|
|
197
|
+
if (Object.hasOwn(value, 'wacandoFileReview')) return 'review';
|
|
198
|
+
if (Object.hasOwn(value, 'wacandoFile')) return 'share';
|
|
199
|
+
}
|
|
200
|
+
} else if (/^[[\s]*\{\s*"wacandoFileReview"\s*:/.test(plain)) return 'review';
|
|
201
|
+
else if (/^[[\s]*\{\s*"wacandoFile"\s*:/.test(plain)) return 'share';
|
|
202
|
+
}
|
|
203
|
+
let unescaped = plain;
|
|
204
|
+
for (let i = 0; i < 3; i++) {
|
|
205
|
+
const next = unescaped.replace(/\\u([0-9a-fA-F]{4})/g, (_, code) => String.fromCharCode(Number.parseInt(code, 16))).replace(/\\(["/\\])/g, '$1');
|
|
206
|
+
if (next === unescaped) break;
|
|
207
|
+
unescaped = next;
|
|
208
|
+
}
|
|
209
|
+
if (/"wacandoFileReview"\s*:/.test(unescaped) && /"secret"\s*:\s*"[0-9a-fA-F]{88}"/.test(unescaped)) return 'review';
|
|
210
|
+
if (/"wacandoFile"\s*:/.test(unescaped) && hasAesgcmKey(unescaped)) return 'share';
|
|
211
|
+
return null;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
const URL_END = /[\s"#]/;
|
|
215
|
+
const KEY_HEX = /^[0-9a-fA-F]{88}/;
|
|
216
|
+
/**
|
|
217
|
+
* Pure: whether the text holds an `aesgcm://<no space, quote or #>#<88 hex>`
|
|
218
|
+
* URL, in one linear pass (the equivalent regular expression is quadratic on
|
|
219
|
+
* `aesgcm://` repeated without a `#`, #24 review LOW-2): every later
|
|
220
|
+
* `aesgcm://` inside the same URL body ends at the same character.
|
|
221
|
+
*/
|
|
222
|
+
function hasAesgcmKey(text) {
|
|
223
|
+
let from = 0;
|
|
224
|
+
for (;;) {
|
|
225
|
+
const start = text.indexOf('aesgcm://', from);
|
|
226
|
+
if (start < 0) return false;
|
|
227
|
+
let end = start + 'aesgcm://'.length;
|
|
228
|
+
while (end < text.length && !URL_END.test(text[end])) end++;
|
|
229
|
+
if (text[end] === '#' && KEY_HEX.test(text.slice(end + 1, end + 89))) return true;
|
|
230
|
+
from = end;
|
|
231
|
+
}
|
|
181
232
|
}
|
|
182
233
|
|
|
183
234
|
const BARE_JID = /^[^\s/@]+@[^\s/@]+$/;
|
|
@@ -269,15 +320,24 @@ export class FileVault {
|
|
|
269
320
|
try { await rename(join(dir, staged), join(dir, `${FileVault.id(fileId)}.bin`)); } catch { throw storageError(); }
|
|
270
321
|
}
|
|
271
322
|
async dropStaged(staged) { if (STAGED.test(staged ?? '')) await unlink(join(await this.dir('outgoing'), staged)).catch(() => {}); }
|
|
272
|
-
/**
|
|
323
|
+
/**
|
|
324
|
+
* Remove interrupted-write leftovers older than `before` (ms): staged
|
|
325
|
+
* ciphertext, .part files, and a committed ciphertext whose record was never
|
|
326
|
+
* written (a crash between the rename and the record, lbframe/wacando-agent#24;
|
|
327
|
+
* without its record it has no key and can never be used). Returns the count.
|
|
328
|
+
*/
|
|
273
329
|
async dropLeftovers(before) {
|
|
274
330
|
let count = 0;
|
|
275
331
|
for (const kind of ['outgoing', 'incoming']) {
|
|
276
332
|
const dir = await this.dir(kind);
|
|
277
333
|
for (const name of await readdir(dir)) {
|
|
278
|
-
|
|
334
|
+
const orphan = kind === 'outgoing' ? COMMITTED.exec(name)?.[1] : null;
|
|
335
|
+
if (!STAGED.test(name) && !PART.test(name) && !orphan) continue;
|
|
279
336
|
const info = await stat(join(dir, name)).catch(() => null);
|
|
280
|
-
if (info?.isFile()
|
|
337
|
+
if (!info?.isFile() || info.mtimeMs >= before) continue;
|
|
338
|
+
// Its record may have been written since the directory was read.
|
|
339
|
+
if (orphan && await lstat(join(dir, `${orphan}.json`)).then(() => true, error => error.code !== 'ENOENT')) continue;
|
|
340
|
+
if (await unlink(join(dir, name)).then(() => true, () => false)) count++;
|
|
281
341
|
}
|
|
282
342
|
}
|
|
283
343
|
return count;
|
|
@@ -326,6 +386,7 @@ export class FileTransfers {
|
|
|
326
386
|
this.pollMs = pollMs;
|
|
327
387
|
this.fetch = fetch;
|
|
328
388
|
this.busy = new Map(); // fileId -> in-flight advance (one at a time per file)
|
|
389
|
+
this.claims = new WeakSet(); // busy entries that are a sweep removal
|
|
329
390
|
}
|
|
330
391
|
|
|
331
392
|
get pin() { return this.pinSetting ?? (this.pinSetting = uploadPin(this.c.agent.origin)); }
|
|
@@ -489,15 +550,24 @@ export class FileTransfers {
|
|
|
489
550
|
async status(fileId, { timeoutMs = 0 } = {}) {
|
|
490
551
|
FileVault.id(fileId);
|
|
491
552
|
if (await this.vault.read('outgoing', fileId)) return this.once(fileId, () => this.advance(fileId, timeoutMs));
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
return this.
|
|
553
|
+
// Incoming: one step at a time per file, like receive (a record written by
|
|
554
|
+
// a receive in flight is never dropped by this step, #24 review LOW-1).
|
|
555
|
+
return this.once(fileId, async () => {
|
|
556
|
+
const incoming = await this.vault.read('incoming', fileId);
|
|
557
|
+
if (!incoming) throw new SafeError(fileErrors.unknown);
|
|
558
|
+
return this.incomingState(incoming, { timeoutMs, fetch: false });
|
|
559
|
+
});
|
|
495
560
|
}
|
|
496
561
|
|
|
562
|
+
/**
|
|
563
|
+
* One transfer step at a time per file in this process: a caller joins the
|
|
564
|
+
* step in flight; a removal by the sweep (a claim) is waited for first.
|
|
565
|
+
*/
|
|
497
566
|
once(fileId, run) {
|
|
498
567
|
const running = this.busy.get(fileId);
|
|
499
|
-
if (running) return running;
|
|
500
|
-
const task =
|
|
568
|
+
if (running && !this.claims.has(running)) return running;
|
|
569
|
+
const task = (running ? running.then(() => run(), () => run()) : run())
|
|
570
|
+
.finally(() => { if (this.busy.get(fileId) === task) this.busy.delete(fileId); });
|
|
501
571
|
this.busy.set(fileId, task);
|
|
502
572
|
return task;
|
|
503
573
|
}
|
|
@@ -617,19 +687,36 @@ export class FileTransfers {
|
|
|
617
687
|
}
|
|
618
688
|
return { ...publicIncoming(known), state: known.path ? 'received' : 'quarantined' };
|
|
619
689
|
}
|
|
620
|
-
|
|
621
|
-
|
|
690
|
+
// Bounded quarantine, and one conversation (one peer) fills at most part of
|
|
691
|
+
// it. Ended transfers do not count: clean them up before refusing (#24).
|
|
692
|
+
let full = await this.quarantineFull(conversationId);
|
|
693
|
+
if (full) {
|
|
694
|
+
await this.sweep().catch(() => null);
|
|
695
|
+
full = await this.quarantineFull(conversationId);
|
|
622
696
|
}
|
|
697
|
+
if (full) return { state: 'refused', reason: 'too_many_quarantined', note: full };
|
|
623
698
|
const record = { fileId, conversationId, messageId, share, ciphertextSha256: share.ciphertextSha256, receivedAt: new Date(this.now()).toISOString() };
|
|
624
699
|
await this.vault.write('incoming', fileId, record);
|
|
625
700
|
return { ...publicIncoming(record), state: 'quarantined', note: `In quarantine until your owner authorizes it; then call wacando_receive_file. ${UNTRUSTED}` };
|
|
626
701
|
}
|
|
627
702
|
|
|
703
|
+
/** The refusal note when the quarantine (or this conversation's share of it) is full, else null. */
|
|
704
|
+
async quarantineFull(conversationId) {
|
|
705
|
+
const waiting = (await this.vault.list('incoming')).filter(r => !r.path);
|
|
706
|
+
if (waiting.length >= MAX_QUARANTINED_INCOMING) return fileErrors.tooManyIncoming;
|
|
707
|
+
if (waiting.filter(r => r.conversationId === conversationId).length >= MAX_QUARANTINED_PER_CONVERSATION) return fileErrors.tooManyInConversation;
|
|
708
|
+
return null;
|
|
709
|
+
}
|
|
710
|
+
|
|
628
711
|
/** Fetch a released incoming file into the quarantine directory (bounded wait for the release). */
|
|
629
712
|
async receive(fileId, { timeoutMs = 0 } = {}) {
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
return this.once(fileId, () =>
|
|
713
|
+
FileVault.id(fileId);
|
|
714
|
+
// Read inside the step: a removal by the sweep is never overtaken.
|
|
715
|
+
return this.once(fileId, async () => {
|
|
716
|
+
const record = await this.vault.read('incoming', fileId);
|
|
717
|
+
if (!record) throw new SafeError(fileErrors.unknown);
|
|
718
|
+
return this.incomingState(record, { timeoutMs, fetch: true });
|
|
719
|
+
});
|
|
633
720
|
}
|
|
634
721
|
|
|
635
722
|
async incomingState(record, { timeoutMs, fetch }) {
|
|
@@ -640,17 +727,17 @@ export class FileTransfers {
|
|
|
640
727
|
const view = await this.c.conversation(record.conversationId);
|
|
641
728
|
if (view.state !== 'active') {
|
|
642
729
|
// STOP is terminal: this agent can never fetch it; drop the key.
|
|
643
|
-
await this.
|
|
730
|
+
await this.dropIncoming(record);
|
|
644
731
|
return { file: summary, state: 'refused', reason: 'conversation_closed', note: fileErrors.notActive(view.state) };
|
|
645
732
|
}
|
|
646
733
|
const dto = (await this.serverFiles(record.conversationId)).find(f => f.id === record.fileId);
|
|
647
734
|
if (!dto || dto.recipientAgentId !== this.c.agent.agentId || dto.ciphertextSha256 !== record.share.ciphertextSha256 ||
|
|
648
735
|
dto.sizeBytes !== record.share.ciphertextSize || dto.mediaType !== record.share.mediaType) {
|
|
649
|
-
await this.
|
|
736
|
+
await this.dropIncoming(record);
|
|
650
737
|
return { file: summary, state: 'refused', reason: 'not_approved_file', note: 'The server does not list this file as approved for this agent. It was refused and never fetched.' };
|
|
651
738
|
}
|
|
652
739
|
if (this.expired(dto)) {
|
|
653
|
-
await this.
|
|
740
|
+
await this.dropIncoming(record);
|
|
654
741
|
return { file: summary, state: 'expired', note: fileApiMessages.FILE_EXPIRED };
|
|
655
742
|
}
|
|
656
743
|
if (dto.status === 'released' && fetch) return this.fetchReleased(record, dto, summary);
|
|
@@ -663,6 +750,12 @@ export class FileTransfers {
|
|
|
663
750
|
}
|
|
664
751
|
}
|
|
665
752
|
|
|
753
|
+
/** Drop a quarantined descriptor (its key), never the record of a file received meanwhile. */
|
|
754
|
+
async dropIncoming(record) {
|
|
755
|
+
if ((await this.vault.read('incoming', record.fileId))?.path) return;
|
|
756
|
+
await this.vault.remove('incoming', record.fileId);
|
|
757
|
+
}
|
|
758
|
+
|
|
666
759
|
async fetchReleased(record, dto, summary) {
|
|
667
760
|
let file;
|
|
668
761
|
try {
|
|
@@ -690,16 +783,22 @@ export class FileTransfers {
|
|
|
690
783
|
|
|
691
784
|
/**
|
|
692
785
|
* Delete the local secrets of every transfer that can never continue
|
|
693
|
-
* (lbframe/wacando-agent#21),
|
|
786
|
+
* (lbframe/wacando-agent#21, #24), before the first file operation of a
|
|
787
|
+
* connection (at every connection for the daemon), before each listing and
|
|
788
|
+
* before a full queue or quarantine refuses:
|
|
694
789
|
* - pending/deposited sends (ciphertext + key) and quarantined descriptors
|
|
695
790
|
* (key): conversation no longer active (STOP is terminal) or unknown to
|
|
696
791
|
* the server, file unknown, different, rejected or expired on the server,
|
|
697
792
|
* or past its 30-day expiry by the local clock (even offline);
|
|
698
793
|
* - sent records (public metadata only, no key): past their expiry;
|
|
699
|
-
* - interrupted-write leftovers older than an hour
|
|
794
|
+
* - interrupted-write leftovers older than an hour, including a ciphertext
|
|
795
|
+
* whose record was never written.
|
|
700
796
|
* A received file (written to the quarantine directory) and its record are
|
|
701
797
|
* never touched. A server that cannot be reached removes nothing it cannot
|
|
702
|
-
* decide locally. Transfers in progress in this process are skipped
|
|
798
|
+
* decide locally. Transfers in progress in this process are skipped; a
|
|
799
|
+
* record that changed since it was listed (e.g. a file received meanwhile)
|
|
800
|
+
* is kept. Another process of the same identity may sweep at the same time:
|
|
801
|
+
* every step tolerates a file the other one already removed.
|
|
703
802
|
* Returns content-free counts.
|
|
704
803
|
*/
|
|
705
804
|
sweep() {
|
|
@@ -721,14 +820,31 @@ export class FileTransfers {
|
|
|
721
820
|
if (this.busy.has(record.fileId)) continue;
|
|
722
821
|
if (!(await this.ended(direction, record, now, server))) continue;
|
|
723
822
|
if (this.busy.has(record.fileId)) continue;
|
|
724
|
-
await this.
|
|
725
|
-
counts[direction]++;
|
|
823
|
+
if (await this.claim(direction, record)) counts[direction]++;
|
|
726
824
|
}
|
|
727
825
|
}
|
|
728
826
|
await this.vault.dropLeftovers(now - STALE_LEFTOVER_MS).catch(() => 0);
|
|
729
827
|
return counts;
|
|
730
828
|
}
|
|
731
829
|
|
|
830
|
+
/**
|
|
831
|
+
* Remove an ended record unless it changed since it was listed. Set as the
|
|
832
|
+
* file's busy step in the same tick as the caller's last busy check, so no
|
|
833
|
+
* step of this process (receive, status) interleaves with the removal.
|
|
834
|
+
*/
|
|
835
|
+
claim(direction, record) {
|
|
836
|
+
const removal = (async () => {
|
|
837
|
+
const fresh = await this.vault.read(direction, record.fileId);
|
|
838
|
+
if (!fresh || JSON.stringify(fresh) !== JSON.stringify(record)) return false;
|
|
839
|
+
await this.vault.remove(direction, record.fileId);
|
|
840
|
+
return true;
|
|
841
|
+
})();
|
|
842
|
+
const held = removal.finally(() => { if (this.busy.get(record.fileId) === held) this.busy.delete(record.fileId); });
|
|
843
|
+
this.claims.add(held);
|
|
844
|
+
this.busy.set(record.fileId, held);
|
|
845
|
+
return held;
|
|
846
|
+
}
|
|
847
|
+
|
|
732
848
|
/** Conversation state and files as the server lists them for this agent; 'gone' when unknown; null when unreachable. */
|
|
733
849
|
async serverState(conversationId) {
|
|
734
850
|
let state;
|
|
@@ -30,9 +30,12 @@ export function publicIdentity(agent, state) {
|
|
|
30
30
|
*/
|
|
31
31
|
export class AgentRuntime {
|
|
32
32
|
/**
|
|
33
|
-
* `files`: `{ uploadOrigin, quarantineDir }` (defaults:
|
|
34
|
-
* or the API origin; WACANDO_QUARANTINE_DIR or a
|
|
35
|
-
* protected runtime state
|
|
33
|
+
* `files`: `{ uploadOrigin, quarantineDir, sweepOnConnect }` (defaults:
|
|
34
|
+
* WACANDO_UPLOAD_ORIGIN or the API origin; WACANDO_QUARANTINE_DIR or a
|
|
35
|
+
* directory inside the protected runtime state; `sweepOnConnect: true`
|
|
36
|
+
* cleans up ended transfers at every connection, as the daemon does,
|
|
37
|
+
* otherwise before the first file operation of a connection) plus test
|
|
38
|
+
* hooks (`now`, `sleep`, `pollMs`, `fetch`).
|
|
36
39
|
*/
|
|
37
40
|
constructor({ host = 'cli', origin, store = new CredentialStore(), report, request, connect, onStanza, files = {} } = {}) {
|
|
38
41
|
if (!/^[a-z0-9][a-z0-9_-]{0,31}$/.test(host)) throw new SafeError('Invalid runtime host name.');
|
|
@@ -147,7 +150,7 @@ export class AgentRuntime {
|
|
|
147
150
|
if (client && client.session !== session) this.rebind(agent.agentId, session);
|
|
148
151
|
if (!client) {
|
|
149
152
|
const files = await this.fileTransfersFor(agent);
|
|
150
|
-
client = new ConversationClient({ runtime: this, agent, session, files });
|
|
153
|
+
client = new ConversationClient({ runtime: this, agent, session, files, sweepOnConnect: this.fileOptions.sweepOnConnect === true });
|
|
151
154
|
this.clients.set(agent.agentId, client);
|
|
152
155
|
for (const stanza of this.buffered.get(agent.agentId) ?? []) client.handle(stanza);
|
|
153
156
|
this.buffered.delete(agent.agentId);
|
|
@@ -220,7 +223,7 @@ export class AgentRuntime {
|
|
|
220
223
|
|
|
221
224
|
/** File transfer state of one identity: private directories, pinned upload origin. */
|
|
222
225
|
async fileTransfersFor(agent) {
|
|
223
|
-
const { uploadOrigin, quarantineDir = process.env.WACANDO_QUARANTINE_DIR, ...hooks } = this.fileOptions;
|
|
226
|
+
const { uploadOrigin, quarantineDir = process.env.WACANDO_QUARANTINE_DIR, sweepOnConnect: _sweep, ...hooks } = this.fileOptions;
|
|
224
227
|
const pin = uploadPin(agent.origin, uploadOrigin ?? process.env.WACANDO_UPLOAD_ORIGIN);
|
|
225
228
|
const base = join(await this.store.directory(agent.origin, this.host), `files-${digest(agent.agentId)}`);
|
|
226
229
|
const vault = new FileVault(base, quarantineDir ? join(quarantineDir, digest(`${agent.origin}\n${agent.agentId}`).slice(0, 32)) : undefined);
|
|
@@ -1,10 +1,13 @@
|
|
|
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
6
|
const unavailable = () => new SafeError('Secure messaging is unavailable. Retry the saved identity.');
|
|
7
7
|
|
|
8
|
+
/** SafeError for a ScramAuthError: the fixed message plus its local reason enum (no secret). */
|
|
9
|
+
const serverUnverified = error => Object.assign(new SafeError(XMPP_SERVER_UNVERIFIED), { code: SCRAM_REFUSED, reason: error.reason });
|
|
10
|
+
|
|
8
11
|
/**
|
|
9
12
|
* One XMPP c2s stream (WebSocket) + this identity's native OMEMO device.
|
|
10
13
|
*
|
|
@@ -13,12 +16,23 @@ const unavailable = () => new SafeError('Secure messaging is unavailable. Retry
|
|
|
13
16
|
* delivery addressed to the bare JID after that point reaches this resource
|
|
14
17
|
* and none can be missed between "online" and the caller subscribing.
|
|
15
18
|
* Raw XMPP errors, stanzas and credentials are never logged.
|
|
19
|
+
*
|
|
20
|
+
* The client is the embedded `scramClient` (lbframe/wacando#186): SCRAM only,
|
|
21
|
+
* SASL1 only, and authentication succeeds only once the server's
|
|
22
|
+
* ServerSignature (`v=`) was verified. A refusal closes the connection,
|
|
23
|
+
* stops @xmpp/client's reconnect and rejects with `code: 'SCRAM_REFUSED'`.
|
|
24
|
+
* Any failure (start, presence echo or deadline) stops the reconnect loop and
|
|
25
|
+
* the client before rejecting: the same guarantee as the embedded
|
|
26
|
+
* `startOrStop`, extended to the presence echo, so that helper is not used.
|
|
16
27
|
*/
|
|
17
28
|
export async function connectSession({ jid, password, service, stateBaseDir, onStanza, onReplaced, timeoutMs = 15000 }) {
|
|
18
29
|
const provider = await loadDevice(jid, stateBaseDir);
|
|
19
30
|
const at = jid.indexOf('@');
|
|
20
31
|
const domain = jid.slice(at + 1);
|
|
21
|
-
|
|
32
|
+
// scramClient applies the embedded guardStart (lbframe/wacando#200): an
|
|
33
|
+
// 'error' while the stream opens rejects start() (an ordinary, retried
|
|
34
|
+
// failure here), never leaves an unhandled rejection behind.
|
|
35
|
+
const xmpp = scramClient({ service, domain, username: jid.slice(0, at), password });
|
|
22
36
|
// One server-enforced session per agent (lbframe/wacando #15): a newer login
|
|
23
37
|
// ends this one with <conflict/>. Remember it so nothing reconnects in a loop.
|
|
24
38
|
let replaced = false;
|
|
@@ -31,15 +45,16 @@ export async function connectSession({ jid, password, service, stateBaseDir, onS
|
|
|
31
45
|
let timer;
|
|
32
46
|
const deadline = new Promise((_, reject) => { timer = setTimeout(() => reject(new Error('timeout')), timeoutMs); });
|
|
33
47
|
try {
|
|
34
|
-
await Promise.race([xmpp
|
|
48
|
+
await Promise.race([startScramClient(xmpp), deadline]);
|
|
35
49
|
xmpp.reconnect?.stop();
|
|
36
50
|
// The control plane owns the roster (#13): never approve a subscription.
|
|
37
51
|
refuseSubscriptionRequests(xmpp);
|
|
38
52
|
if (onStanza) xmpp.on('stanza', stanza => onStanza(stanza, xmpp));
|
|
39
53
|
await Promise.race([announceAvailable(xmpp, timeoutMs), deadline]);
|
|
40
|
-
} catch {
|
|
54
|
+
} catch (error) {
|
|
55
|
+
xmpp.reconnect?.stop();
|
|
41
56
|
await xmpp.stop().catch(() => {});
|
|
42
|
-
throw unavailable();
|
|
57
|
+
throw error instanceof ScramAuthError ? serverUnverified(error) : unavailable();
|
|
43
58
|
} finally { clearTimeout(timer); }
|
|
44
59
|
return {
|
|
45
60
|
client: xmpp, provider, bareJid: provider.bareJid, domain,
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// Embedded from lbframe/wacando@
|
|
1
|
+
// Embedded from lbframe/wacando@87b51a96aff0a13ca5ad6650c9c841bee72e1612 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 {
|