@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
package/STATUS.md
CHANGED
|
@@ -1,5 +1,58 @@
|
|
|
1
1
|
# Handoff — October 15, 2026
|
|
2
2
|
|
|
3
|
+
## Runtime 2.4.1 — October 17, 2026
|
|
4
|
+
|
|
5
|
+
File runtime follow-ups (lbframe/wacando-agent#24, review of #23):
|
|
6
|
+
- A ciphertext left without its record (crash between the two writes) is
|
|
7
|
+
removed by the cleanup once it is more than an hour old.
|
|
8
|
+
- The quarantine keeps at most 20 descriptors per conversation (100 in all),
|
|
9
|
+
and a full quarantine is cleaned up before a share is refused, so a peer's
|
|
10
|
+
forged descriptors cannot block real shares.
|
|
11
|
+
- A file descriptor is also detected behind a zero-width space, BOM or word
|
|
12
|
+
joiner, with fullwidth braces, and nested, inside an array or inside prose
|
|
13
|
+
together with its key; text that only names the format (also a nested
|
|
14
|
+
`{"wacandoFile": false}`) stays text. Detection is linear in the body size.
|
|
15
|
+
- `fileStatus` of an incoming file runs one step at a time with
|
|
16
|
+
`receiveFile`, so it never drops the record of a file being received.
|
|
17
|
+
- The cleanup no longer runs when a read-only tool (`wacando_read`,
|
|
18
|
+
`wacando_conversations`, ...) connects: it runs before the first file
|
|
19
|
+
operation of a connection, and at every connection only for
|
|
20
|
+
`wacando-agent daemon` (SDK `files.sweepOnConnect`). A record that changed
|
|
21
|
+
while the cleanup consulted the server is kept (closes the STOP race that
|
|
22
|
+
could unlist a received file), and two processes of one identity can clean
|
|
23
|
+
up at the same time.
|
|
24
|
+
See [agent tools](docs/AGENT_TOOLS.md#files-10); evidence
|
|
25
|
+
`docs/evidence/issue-24/`.
|
|
26
|
+
|
|
27
|
+
Embedded Wacando code rebuilt from lbframe/wacando main `87b51a9`, which
|
|
28
|
+
brings lbframe/wacando#200: the embedded `scramClient` now guards its own
|
|
29
|
+
`start()` (`guardStart`), so the runtime's local copy of that guard (added in
|
|
30
|
+
2.4.0) is removed; an error while the stream opens is still an ordinary,
|
|
31
|
+
retried connection failure. `@xmpp/events` 0.14.0 becomes an exact dependency
|
|
32
|
+
([embedded Wacando code](docs/WACANDO_PACKAGES.md)); evidence
|
|
33
|
+
`docs/evidence/release-2.4.1/`.
|
|
34
|
+
|
|
35
|
+
## Runtime 2.4.0 — October 17, 2026
|
|
36
|
+
|
|
37
|
+
Verified XMPP server (lbframe/wacando-agent#26): every XMPP connection of
|
|
38
|
+
the runtime now goes through the embedded `scramClient` of lbframe/wacando#186.
|
|
39
|
+
Only SCRAM-SHA-256/SHA-1 over SASL1 is used (no PLAIN, no SASL2/FAST,
|
|
40
|
+
iteration count between 4096 and 100000), and the session is bound only after
|
|
41
|
+
the server's ServerSignature (`v=`) was verified. A forged or missing `v=`, a
|
|
42
|
+
SASL2 offer or a server without SCRAM closes the connection before any
|
|
43
|
+
session: state `refused`, error `XMPP_SERVER_UNVERIFIED`, no retry and no
|
|
44
|
+
reconnection in the process; the daemon exits with code 4 (code 3 for
|
|
45
|
+
`SESSION_REPLACED`), at the first connection as at a reconnection. An error while
|
|
46
|
+
the XMPP stream opens (a WebSocket cut on Node.js 24, a TCP reset) no longer
|
|
47
|
+
kills the process with an unhandled rejection: it is an ordinary, retried
|
|
48
|
+
connection failure. A repository-wide
|
|
49
|
+
test forbids any raw `@xmpp/client` `client()` outside the embedded code. See
|
|
50
|
+
[agent tools](docs/AGENT_TOOLS.md); evidence `docs/evidence/issue-26/`.
|
|
51
|
+
|
|
52
|
+
Embedded Wacando code rebuilt from lbframe/wacando main `40dd0c3`, which also
|
|
53
|
+
brings lbframe/wacando#166: `downloadFile` refuses more than 10 MiB and fails
|
|
54
|
+
closed on size ([embedded Wacando code](docs/WACANDO_PACKAGES.md)).
|
|
55
|
+
|
|
3
56
|
## Runtime 2.3.0 — October 17, 2026
|
|
4
57
|
|
|
5
58
|
Default origin (fix): the runtime now defaults to the production API
|
package/bin/wacando-agent.mjs
CHANGED
|
@@ -7,7 +7,7 @@ import { fileURLToPath } from 'node:url';
|
|
|
7
7
|
import { pluginVersion } from '../packages/onboarding/version.mjs';
|
|
8
8
|
import { configuredOrigin } from '../packages/onboarding/network.mjs';
|
|
9
9
|
import { SafeError } from '../packages/onboarding/storage.mjs';
|
|
10
|
-
import { SESSION_REPLACED } from '../packages/onboarding/service.mjs';
|
|
10
|
+
import { SESSION_REPLACED, XMPP_SERVER_UNVERIFIED } from '../packages/onboarding/service.mjs';
|
|
11
11
|
|
|
12
12
|
const root = fileURLToPath(new URL('../', import.meta.url));
|
|
13
13
|
const self = fileURLToPath(import.meta.url);
|
|
@@ -70,10 +70,10 @@ export function parseFlags(flags, allowed) {
|
|
|
70
70
|
class UsageError extends Error {}
|
|
71
71
|
const print = value => console.log(JSON.stringify(value, null, 2));
|
|
72
72
|
|
|
73
|
-
async function runtime(options, report) {
|
|
73
|
+
async function runtime(options, report, extra = {}) {
|
|
74
74
|
const { AgentRuntime } = await import('../packages/runtime/runtime.mjs');
|
|
75
75
|
const { CredentialStore } = await import('../packages/onboarding/storage.mjs');
|
|
76
|
-
return new AgentRuntime({ host: options.host ?? 'cli', origin: options.origin, store: new CredentialStore(), ...(report ? { report } : {}) });
|
|
76
|
+
return new AgentRuntime({ host: options.host ?? 'cli', origin: options.origin, store: new CredentialStore(), ...(report ? { report } : {}), ...extra });
|
|
77
77
|
}
|
|
78
78
|
|
|
79
79
|
commands.set('status', async flags => {
|
|
@@ -174,28 +174,41 @@ commands.set('files', async flags => {
|
|
|
174
174
|
await withConversations(parseFlags(rest, conversationFlags), c => c.listFiles(id));
|
|
175
175
|
});
|
|
176
176
|
|
|
177
|
+
/** Daemon exit code of a terminal connection error: 3 replaced, 4 unverified server. */
|
|
178
|
+
function terminalExitCode(error) {
|
|
179
|
+
if (!(error instanceof SafeError)) return undefined;
|
|
180
|
+
if (error.message === SESSION_REPLACED) return 3;
|
|
181
|
+
if (error.message === XMPP_SERVER_UNVERIFIED) return 4;
|
|
182
|
+
return undefined;
|
|
183
|
+
}
|
|
184
|
+
|
|
177
185
|
commands.set('daemon', async flags => {
|
|
178
186
|
const options = parseFlags(flags, ['--agent', '--host', '--origin']);
|
|
179
187
|
// Keep the process alive between heartbeats/retries until a stop signal, or
|
|
180
|
-
// until the server hands this identity to a newer session
|
|
188
|
+
// until the server hands this identity to a newer session or fails SCRAM
|
|
189
|
+
// verification (both terminal).
|
|
181
190
|
let keepAlive;
|
|
182
|
-
let
|
|
191
|
+
let terminal;
|
|
183
192
|
const stop = new Promise(resolve => {
|
|
184
193
|
keepAlive = setInterval(() => {}, 3600000);
|
|
185
|
-
|
|
194
|
+
terminal = reason => resolve(reason);
|
|
186
195
|
process.once('SIGINT', resolve); process.once('SIGTERM', resolve);
|
|
187
196
|
});
|
|
188
197
|
const agent = await runtime(options, event => {
|
|
189
198
|
// Diagnostics carry only local enums and public ids (never content).
|
|
190
199
|
process.stderr.write(`${JSON.stringify({ component: 'wacando-connection', ...event })}\n`);
|
|
191
|
-
if (event.state === 'replaced')
|
|
192
|
-
});
|
|
200
|
+
if (event.state === 'replaced' || event.state === 'refused') terminal(event.state);
|
|
201
|
+
}, { files: { sweepOnConnect: true } }); // the daemon cleans up ended file transfers at every connection (#24)
|
|
193
202
|
try {
|
|
194
203
|
try { print(await agent.connect(options.agent)); }
|
|
195
204
|
catch (error) {
|
|
196
205
|
// A retry stays scheduled unless the identity was rejected for good or
|
|
197
206
|
// another process already runs it (identity lock).
|
|
198
|
-
|
|
207
|
+
// The terminal exit codes hold whether the first connection or a later
|
|
208
|
+
// reconnection hits them (a supervisor must not restart into a loop).
|
|
209
|
+
const exitCode = terminalExitCode(error);
|
|
210
|
+
if (exitCode) throw Object.assign(error, { exitCode });
|
|
211
|
+
if (!(error instanceof SafeError) || /revoked|classic OMEMO|No saved identity|Several saved/.test(error.message)) throw error;
|
|
199
212
|
if (/Failed step: local identity/.test(error.message)) throw new SafeError('This identity is already running in another process (identity lock). Stop it first.');
|
|
200
213
|
console.error(error.message);
|
|
201
214
|
}
|
|
@@ -207,9 +220,13 @@ commands.set('daemon', async flags => {
|
|
|
207
220
|
decrypted: entry.text !== null || Boolean(entry.file), ...(entry.file ? { file: { fileId: entry.file.fileId ?? null, state: entry.file.state } } : {}) })));
|
|
208
221
|
} catch (error) { if (!(error instanceof SafeError)) throw error; console.error(error.message); }
|
|
209
222
|
console.error('Daemon running; stop with Ctrl-C or SIGTERM.');
|
|
210
|
-
|
|
223
|
+
const reason = await stop;
|
|
224
|
+
if (reason === 'replaced') {
|
|
211
225
|
console.error(SESSION_REPLACED);
|
|
212
226
|
process.exitCode = 3;
|
|
227
|
+
} else if (reason === 'refused') {
|
|
228
|
+
console.error(XMPP_SERVER_UNVERIFIED);
|
|
229
|
+
process.exitCode = 4;
|
|
213
230
|
}
|
|
214
231
|
} finally { clearInterval(keepAlive); await agent.close(); }
|
|
215
232
|
});
|
|
@@ -283,6 +300,6 @@ if (invoked === self) {
|
|
|
283
300
|
if (error instanceof UsageError) console.error(usage);
|
|
284
301
|
else if (error instanceof SafeError) console.error(error.message);
|
|
285
302
|
else console.error('Wacando command failed. Check arguments, local state permissions and service availability.');
|
|
286
|
-
process.exitCode = error instanceof UsageError ? 2 : 1;
|
|
303
|
+
process.exitCode = error instanceof UsageError ? 2 : (Number.isInteger(error?.exitCode) ? error.exitCode : 1);
|
|
287
304
|
});
|
|
288
305
|
}
|
package/docs/AGENT_TOOLS.md
CHANGED
|
@@ -96,6 +96,18 @@ Rules enforced by the runtime (and, independently, by the server):
|
|
|
96
96
|
identity back. After an ordinary network loss the conversation client moves
|
|
97
97
|
to the new session (one OMEMO provider per identity) and re-joins rooms on
|
|
98
98
|
demand.
|
|
99
|
+
- **Verified XMPP server** (lbframe/wacando-agent#26, lbframe/wacando#186):
|
|
100
|
+
every XMPP login of the runtime uses the embedded `scramClient`: SCRAM-SHA-256
|
|
101
|
+
or SCRAM-SHA-1 over SASL1 only (no PLAIN, no SASL2/FAST), iteration count
|
|
102
|
+
between 4096 and 100000, and the session is bound only after the server's
|
|
103
|
+
ServerSignature (`v=`) was verified. A forged or missing `v=`, a SCRAM `e=`
|
|
104
|
+
error, a SASL2 offer or a server without SCRAM closes the connection before
|
|
105
|
+
any session: state `refused`, error `XMPP_SERVER_UNVERIFIED`, no retry and no
|
|
106
|
+
reconnection in the process (a further connect is refused). The daemon exits
|
|
107
|
+
with code 4, whether the first connection or a reconnection is refused (and
|
|
108
|
+
with code 3 for `SESSION_REPLACED`, likewise). Ordinary failures (wrong or
|
|
109
|
+
expired grant, network cut, timeout) stay `retrying`. Check the configured
|
|
110
|
+
service, then restart the runtime.
|
|
99
111
|
- **Message text on the CLI**: pass `-` instead of the text to read it from
|
|
100
112
|
stdin, so it stays out of `ps` and shell history.
|
|
101
113
|
- **Presence**: after connecting, the runtime waits for the server's echo of
|
|
@@ -176,16 +188,27 @@ Code: `packages/runtime/files.mjs`.
|
|
|
176
188
|
The URL in a message never chooses where the runtime connects nor where the
|
|
177
189
|
agent credential goes; the fetch URL is rebuilt from the pin. https only,
|
|
178
190
|
except loopback http for local stacks.
|
|
179
|
-
- **Receive**: a decrypted body that is a file descriptor is never
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
191
|
+
- **Receive**: a decrypted body that is or carries a file descriptor is never
|
|
192
|
+
shown as text (`fileBodyKind`, lbframe/wacando-agent#21, #24): a JSON object
|
|
193
|
+
with a `wacandoFile` key at its top level, whatever its whitespace, key order,
|
|
194
|
+
escaping or values; a body that opens like one but is not valid JSON; the
|
|
195
|
+
same behind invisible format characters (zero-width space, BOM, word joiner)
|
|
196
|
+
or written with fullwidth braces; and a descriptor anywhere else (nested in
|
|
197
|
+
JSON, inside an array, in prose, a code block or a JSON string) when its key
|
|
198
|
+
is there too (an `aesgcm://…#<88 hex>` URL, or a review `secret`). Text
|
|
199
|
+
that only names the format without a key (e.g. a nested
|
|
200
|
+
`{"wacandoFile": false}` setting) stays text. Detection is linear in the
|
|
201
|
+
body size. A review descriptor (`wacandoFileReview`) in the room is
|
|
183
202
|
`file: { state: 'refused', reason: 'review_descriptor' }`. It is parsed strictly against
|
|
184
203
|
the pin: valid -> `file: { fileId, name, mediaType, size, state:
|
|
185
204
|
'quarantined' }` in the transcript entry (and `wacando_wait`), otherwise
|
|
186
205
|
`file: { state: 'refused', reason: 'invalid_descriptor' }`, nothing stored or
|
|
187
|
-
fetched. At most 100 descriptors wait in quarantine on one runtime
|
|
188
|
-
|
|
206
|
+
fetched. At most 100 descriptors wait in quarantine on one runtime, and at
|
|
207
|
+
most 20 of one conversation (one peer cannot fill the quarantine for the
|
|
208
|
+
others). When either limit is reached the runtime first cleans up ended
|
|
209
|
+
transfers (a peer's forged descriptors, which the server never lists, go
|
|
210
|
+
then); if it is still full, the new share is `refused`
|
|
211
|
+
(`too_many_quarantined`) and never stored. `wacando_receive_file` checks that the server lists the file for
|
|
189
212
|
this agent with the same digest, size and type, waits (bounded) while it is
|
|
190
213
|
quarantined (`uploaded`), and only once the recipient owner released it
|
|
191
214
|
downloads (redirects refused, size capped), verifies and decrypts
|
|
@@ -202,17 +225,28 @@ Code: `packages/runtime/files.mjs`.
|
|
|
202
225
|
in a conversation that is not `active` (checked locally before each step and
|
|
203
226
|
by the server). Past `expiresAt` (30 days) the transfer is `expired`.
|
|
204
227
|
Owners keep observing exchanged files in the app until then.
|
|
205
|
-
- **Local cleanup** (lbframe/wacando-agent#21, `FileTransfers.sweep`):
|
|
206
|
-
connection
|
|
207
|
-
`wacando_files
|
|
208
|
-
|
|
228
|
+
- **Local cleanup** (lbframe/wacando-agent#21, #24, `FileTransfers.sweep`):
|
|
229
|
+
once per connection, before the first file operation (`wacando_send_file`,
|
|
230
|
+
`wacando_file_status`, `wacando_receive_file`, `wacando_files` and their CLI
|
|
231
|
+
and SDK forms; never from the read-only `wacando_contacts`,
|
|
232
|
+
`wacando_conversations`, `wacando_read`, `wacando_wait`); at every
|
|
233
|
+
connection and reconnection for `wacando-agent daemon` (SDK:
|
|
234
|
+
`files.sweepOnConnect: true`); before every `wacando_files`/`files` listing;
|
|
235
|
+
and before refusing a send or a received share for a full queue or
|
|
236
|
+
quarantine, the runtime deletes the **local ciphertext, keys and quarantined
|
|
209
237
|
descriptors** of every transfer that can never continue: conversation no
|
|
210
238
|
longer `active` (STOP) or unknown to the server, file unknown, different,
|
|
211
239
|
`rejected` or `expired` on the server, or past its expiry by the local clock
|
|
212
240
|
(the server's `expiresAt`, else 30 days after the local request or receipt;
|
|
213
241
|
this one also works offline). A server that cannot be reached removes
|
|
214
242
|
nothing else. Sent records (public metadata, no key) are kept until their
|
|
215
|
-
expiry. Leftovers of an interrupted write older than an hour are removed
|
|
243
|
+
expiry. Leftovers of an interrupted write older than an hour are removed,
|
|
244
|
+
including a ciphertext whose record was never written (a crash between the
|
|
245
|
+
two writes: without its record it has no key). A record that changed while
|
|
246
|
+
the sweep consulted the server is kept (a file received meanwhile stays
|
|
247
|
+
listed, also if STOP follows), and no transfer step of the same process runs
|
|
248
|
+
during a removal. Two processes of one identity sweeping at once (a daemon
|
|
249
|
+
and an MCP server) only ever remove ended transfers.
|
|
216
250
|
The listing reports `cleaned: { outgoing, incoming }` counts when it removed
|
|
217
251
|
something. Nothing is logged.
|
|
218
252
|
- **Received files are kept**: a file written to the quarantine directory is
|
package/docs/WACANDO_PACKAGES.md
CHANGED
|
@@ -42,7 +42,28 @@ source repository; a URL is cloned into a temporary directory.
|
|
|
42
42
|
result is byte-identical to `packages/wacando/` and `provenance.json` (it needs
|
|
43
43
|
access to the private repository, so it is not part of `npm run check`).
|
|
44
44
|
|
|
45
|
-
Current source (2.
|
|
45
|
+
Current source (2.4.1): lbframe/wacando main
|
|
46
|
+
`87b51a96aff0a13ca5ad6650c9c841bee72e1612` (#200 merged at `e669157`). It brings lbframe/wacando#200:
|
|
47
|
+
the embedded `scramClient` applies `guardStart`, so an error while the XMPP
|
|
48
|
+
stream opens rejects `start()` without leaving an unhandled rejection behind
|
|
49
|
+
(the runtime's own copy of that guard is removed); `@xmpp/events` is now a
|
|
50
|
+
recorded external of the embedded code and an exact dependency. The entry
|
|
51
|
+
list is unchanged: `guardStart` is applied inside `scramClient`, and the
|
|
52
|
+
runtime does not use `startOrStop` (`connectSession` already stops the
|
|
53
|
+
reconnect loop and the client on any failure, presence echo included).
|
|
54
|
+
|
|
55
|
+
2.4.0 embedded lbframe/wacando main
|
|
56
|
+
`40dd0c30b3653095f08994f9534fd5110e1d0925`. It brings lbframe/wacando#186:
|
|
57
|
+
`#wacando/xmpp` now also exports `scramClient`, `startScramClient` and
|
|
58
|
+
`ScramAuthError` (SCRAM client verifying the ServerSignature `v=`, SASL2
|
|
59
|
+
refused, iteration count capped at 100000), through which the runtime makes
|
|
60
|
+
every XMPP connection (`packages/runtime/session.mjs`; `@xmpp/client` is now
|
|
61
|
+
also a recorded external of the embedded code); and lbframe/wacando#166:
|
|
62
|
+
`downloadFile` refuses more than 10 MiB and fails closed on size.
|
|
63
|
+
`tests/scram-wiring.test.mjs` forbids any raw `@xmpp/client` `client()`
|
|
64
|
+
outside the embedded code.
|
|
65
|
+
|
|
66
|
+
2.2.0 and 2.3.0 embedded lbframe/wacando main
|
|
46
67
|
`618922ecaf2213da18949a8b27d343c8767b64c5`, which adds the review copy of
|
|
47
68
|
lbframe/wacando#169 (`fileReviewFor`, `formatFileReview`, `parseFileReview`,
|
|
48
69
|
`decryptFileReview`, `isFileReviewBody` in `#wacando/omemo`). Before it,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@wacando/agent",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.4.1",
|
|
4
4
|
"description": "Wacando agent runtime: CLI/daemon, MCP server and Node SDK on official libsignal OMEMO, with thin Codex, Cursor and Claude Code plugins.",
|
|
5
5
|
"license": "UNLICENSED",
|
|
6
6
|
"type": "module",
|
|
@@ -53,6 +53,7 @@
|
|
|
53
53
|
"dependencies": {
|
|
54
54
|
"@signalapp/libsignal-client": "0.102.2",
|
|
55
55
|
"@xmpp/client": "0.14.0",
|
|
56
|
+
"@xmpp/events": "0.14.0",
|
|
56
57
|
"@xmpp/jid": "0.14.0",
|
|
57
58
|
"@xmpp/xml": "0.14.0",
|
|
58
59
|
"base64-js": "1.5.1",
|
|
@@ -13,6 +13,13 @@ const identifier = value => typeof value === 'string' && /^[a-zA-Z0-9_-]{1,200}$
|
|
|
13
13
|
const secret = (value, prefix) => typeof value === 'string' && value.startsWith(prefix) && /^[a-zA-Z0-9_.-]{8,512}$/.test(value);
|
|
14
14
|
const lostEnrollment = 'Enrollment result was not safely saved. Check the agent status on the site. If the token was consumed and no durable credential was saved, revoke this identity and create a new one with a different handle. No automatic recovery exists in v1.';
|
|
15
15
|
export const SESSION_REPLACED = 'SESSION_REPLACED: this identity was taken over by a newer session elsewhere (one session per agent). Nothing reconnects automatically; restart this runtime to take it back.';
|
|
16
|
+
/**
|
|
17
|
+
* lbframe/wacando-agent#26: the server failed SCRAM verification (forged or
|
|
18
|
+
* missing ServerSignature `v=`, SASL2 offered, no SCRAM mechanism, iteration
|
|
19
|
+
* count out of bounds...). Terminal: never retried automatically.
|
|
20
|
+
*/
|
|
21
|
+
export const XMPP_SERVER_UNVERIFIED = 'XMPP_SERVER_UNVERIFIED: the XMPP server did not prove it knows this identity\'s credential (SCRAM-SHA-256/SHA-1 with a verified server signature over SASL1 is required). The connection was closed before any session; connection attempts have stopped. Check the configured service, then restart this runtime.';
|
|
22
|
+
export const SCRAM_REFUSED = 'SCRAM_REFUSED';
|
|
16
23
|
const retryConnection = 'Agent credential is saved, but connection was not confirmed. Automatic retry is scheduled while this MCP host stays open. The same enrollment tool can also resume without consuming the temporary token or recreating the agent.';
|
|
17
24
|
|
|
18
25
|
export class OnboardingService {
|
|
@@ -32,6 +39,9 @@ export class OnboardingService {
|
|
|
32
39
|
// Identities whose session the server replaced (<conflict/>): terminal for
|
|
33
40
|
// this process, so two runtimes never steal the session from each other.
|
|
34
41
|
this.replaced = new Set();
|
|
42
|
+
// Identities whose XMPP server failed SCRAM verification (#26): terminal
|
|
43
|
+
// for this process, never retried in a loop against that server.
|
|
44
|
+
this.refused = new Set();
|
|
35
45
|
this.schedule = schedule; this.cancel = cancel; this.random = random; this.report = report;
|
|
36
46
|
this.onStanza = onStanza;
|
|
37
47
|
this.onSession = onSession;
|
|
@@ -128,7 +138,7 @@ export class OnboardingService {
|
|
|
128
138
|
catch {
|
|
129
139
|
// open() schedules network recovery itself. Lock/storage failures also
|
|
130
140
|
// remain observable and retryable, except after terminal access rejection.
|
|
131
|
-
if (!this.closed && !this.timers.has(agent.agentId) && !['revoked', 'replaced'].includes(this.states.get(agent.agentId)?.state)) this.retry(agent, 'local operation');
|
|
141
|
+
if (!this.closed && !this.timers.has(agent.agentId) && !['revoked', 'replaced', 'refused'].includes(this.states.get(agent.agentId)?.state)) this.retry(agent, 'local operation');
|
|
132
142
|
}
|
|
133
143
|
finally { this.recoveries.delete(recovery); }
|
|
134
144
|
}, delay);
|
|
@@ -150,10 +160,18 @@ export class OnboardingService {
|
|
|
150
160
|
this.status(agent, 'replaced', phase);
|
|
151
161
|
await this.disconnect(agent.agentId);
|
|
152
162
|
}
|
|
163
|
+
/** The XMPP server failed SCRAM verification: stop for good in this process. */
|
|
164
|
+
async markRefused(agent, phase = 'XMPP connection') {
|
|
165
|
+
this.refused.add(agent.agentId);
|
|
166
|
+
this.cancel(this.timers.get(agent.agentId)); this.timers.delete(agent.agentId);
|
|
167
|
+
if (this.states.get(agent.agentId)?.state !== 'refused') this.status(agent, 'refused', phase);
|
|
168
|
+
await this.disconnect(agent.agentId);
|
|
169
|
+
}
|
|
153
170
|
async open(agent) {
|
|
154
171
|
this.cancel(this.timers.get(agent.agentId)); this.timers.delete(agent.agentId);
|
|
155
172
|
let release;
|
|
156
173
|
let phase = 'local identity';
|
|
174
|
+
if (this.refused.has(agent.agentId)) throw new SafeError(XMPP_SERVER_UNVERIFIED);
|
|
157
175
|
if (this.replaced.has(agent.agentId) || this.sessions.get(agent.agentId)?.session?.isReplaced?.()) {
|
|
158
176
|
await this.markReplaced(agent);
|
|
159
177
|
throw new SafeError(SESSION_REPLACED);
|
|
@@ -211,6 +229,12 @@ export class OnboardingService {
|
|
|
211
229
|
await this.store.write(agent.origin, this.host, 'agent', agent.agentId, { ...agent, revoked: true });
|
|
212
230
|
throw new SafeError('Agent revoked or durable access rejected. Connection attempts have stopped. Check the identity on the site.');
|
|
213
231
|
}
|
|
232
|
+
if (error?.code === SCRAM_REFUSED) {
|
|
233
|
+
// Forged/missing ServerSignature or SASL2 offer: the connection is
|
|
234
|
+
// already closed; no automatic retry against this server.
|
|
235
|
+
await this.markRefused(agent, phase);
|
|
236
|
+
throw new SafeError(XMPP_SERVER_UNVERIFIED);
|
|
237
|
+
}
|
|
214
238
|
const active = this.sessions.get(agent.agentId);
|
|
215
239
|
if (this.replaced.has(agent.agentId) || active?.session?.isReplaced?.()) {
|
|
216
240
|
// Another process took this identity over (newest session wins). Never
|
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
// Must match all distributed plugin manifests; checked by the test suite.
|
|
2
|
-
export const pluginVersion = '2.
|
|
2
|
+
export const pluginVersion = '2.4.1';
|
|
@@ -104,7 +104,7 @@ export function publicConversation(view, extra = {}) {
|
|
|
104
104
|
|
|
105
105
|
/** Messaging for ONE connected identity. Created by AgentRuntime.conversations(). */
|
|
106
106
|
export class ConversationClient {
|
|
107
|
-
constructor({ runtime, agent, session, files }) {
|
|
107
|
+
constructor({ runtime, agent, session, files, sweepOnConnect = false }) {
|
|
108
108
|
this.runtime = runtime;
|
|
109
109
|
this.agent = agent;
|
|
110
110
|
this.session = session;
|
|
@@ -131,8 +131,11 @@ export class ConversationClient {
|
|
|
131
131
|
this.lifecyclesLoaded = this.loadLifecycles();
|
|
132
132
|
// lbframe/wacando-agent#10: encrypted files under owner approval.
|
|
133
133
|
this.files = files?.(this) ?? null;
|
|
134
|
-
// lbframe/wacando-agent#21: clean up ended transfers
|
|
135
|
-
|
|
134
|
+
// lbframe/wacando-agent#21, #24: clean up ended transfers once per
|
|
135
|
+
// connection, at the connection only for a daemon (sweepOnConnect), else
|
|
136
|
+
// before the first file operation: a read-only tool never deletes anything.
|
|
137
|
+
this.sweepOnConnect = sweepOnConnect;
|
|
138
|
+
this.filesSwept = this.sweepOnConnect ? this.sweepFiles() : null;
|
|
136
139
|
}
|
|
137
140
|
|
|
138
141
|
/** Content-free counts of the local file records removed, or null (never throws). */
|
|
@@ -191,7 +194,7 @@ export class ConversationClient {
|
|
|
191
194
|
this.session = session;
|
|
192
195
|
this.recovery = this.attachRecovery(session);
|
|
193
196
|
this.lifecyclesLoaded = this.loadLifecycles();
|
|
194
|
-
this.filesSwept = this.sweepFiles();
|
|
197
|
+
this.filesSwept = this.sweepOnConnect ? this.sweepFiles() : null;
|
|
195
198
|
this.tracker = new MucOriginTracker(session.client, { trustedRelayJid: this.policyJid });
|
|
196
199
|
this.rooms.clear();
|
|
197
200
|
for (const resolve of this.submissions.values()) resolve({ state: 'refused', reason: 'connection-lost' });
|
|
@@ -343,6 +346,8 @@ export class ConversationClient {
|
|
|
343
346
|
|
|
344
347
|
fileTransfers() {
|
|
345
348
|
if (!this.files) throw new SafeError('File transfer is not available on this runtime.');
|
|
349
|
+
// First file operation of this connection (none is read-only): clean up.
|
|
350
|
+
this.filesSwept ??= this.sweepFiles();
|
|
346
351
|
return this.files;
|
|
347
352
|
}
|
|
348
353
|
/** Encrypt a local file, ask the owner's approval, then deposit and share it (bounded wait). */
|