@twinsearth/w2m-dsh-plugin 0.6.3 → 0.8.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.
Files changed (37) hide show
  1. package/CHANGELOG.md +1030 -0
  2. package/README.md +9 -2
  3. package/bin/w2m-localside.mjs +0 -0
  4. package/bin/w2m-rabbit.mjs +6 -0
  5. package/bin/w2m-stun.mjs +0 -0
  6. package/deploy/server/README.md +109 -0
  7. package/deploy/server/deploy-w2m.sh +161 -0
  8. package/deploy/server/w2m-localside.service +31 -0
  9. package/deploy/server/w2m-rabbit.service +26 -0
  10. package/deploy/server/w2m-stun.service +26 -0
  11. package/docs/ACCOUNT-FLEET-DESIGN.md +369 -0
  12. package/package.json +33 -22
  13. package/src/chatops/account.mjs +361 -0
  14. package/src/chatops/actions.mjs +227 -0
  15. package/src/chatops/approval-broker.mjs +320 -0
  16. package/src/chatops/auth.mjs +376 -0
  17. package/src/chatops/bridge.mjs +344 -0
  18. package/src/chatops/channel.mjs +192 -0
  19. package/src/chatops/commands.mjs +409 -0
  20. package/src/chatops/config-route.mjs +332 -0
  21. package/src/chatops/device-signing.mjs +218 -0
  22. package/src/chatops/durability.mjs +202 -0
  23. package/src/chatops/file-safety.mjs +282 -0
  24. package/src/chatops/fleet-scope.mjs +176 -0
  25. package/src/chatops/fleet-wiring.mjs +199 -0
  26. package/src/chatops/fleet.mjs +272 -0
  27. package/src/chatops/handoff.mjs +258 -0
  28. package/src/chatops/ilink-protocol.mjs +165 -0
  29. package/src/chatops/ilink-transport.mjs +168 -0
  30. package/src/chatops/ilink.mjs +423 -0
  31. package/src/chatops/index.mjs +429 -0
  32. package/src/chatops/outbound-queue.mjs +282 -0
  33. package/src/chatops/policy.mjs +356 -0
  34. package/src/chatops/run-command.mjs +308 -0
  35. package/src/plugin/tools.mjs +1 -1
  36. package/src/relay/server.mjs +96 -0
  37. package/src/relay/state.mjs +2233 -2011
@@ -0,0 +1,344 @@
1
+ /**
2
+ * The pipeline: one inbound message in, one action out (v0.7.0).
3
+ *
4
+ * ## What this replaces
5
+ *
6
+ * `dsh-chatops` 0.1.0's `SessionBridge` was 1,060 lines carrying ten to eleven
7
+ * responsibilities — the session catalogue, title resolution through three fallbacks,
8
+ * command parsing, eleven command implementations, prompt delivery, agent-event mirroring,
9
+ * the approval answerer, vendor card probing, file delivery, direct reads of the host's
10
+ * on-disk state, and an import of a vendor's view module. Its longest function was 87 lines
11
+ * of session-catalog work living inside a chat bridge, and it held ten mutable maps with no
12
+ * disposer owning any of them.
13
+ *
14
+ * Here the responsibilities are already elsewhere, and this file is the order they run in:
15
+ *
16
+ * admit (policy) -> route (commands) -> deliver (outbound queue)
17
+ * |
18
+ * +-> dispatch (the fleet, injected)
19
+ * +-> approvals (the broker)
20
+ * +-> files (the reader and the fan-out)
21
+ *
22
+ * The whole of it is one function per direction, and the interesting content is the refusal
23
+ * handling rather than the happy path.
24
+ *
25
+ * ## Two rules this file exists to enforce
26
+ *
27
+ * **A refusal is a reply, not silence.** Every dropped message produces something the
28
+ * operator can see — but only for identities that were admitted at least once, because
29
+ * replying to a stranger confirms the bot exists and spends the rate limit on them. The
30
+ * distinction is deliberate and the tests assert both halves.
31
+ *
32
+ * **Nothing is trusted because it arrived** is `policy.mjs`'s rule; this file's rule is that
33
+ * a failure anywhere in the pipeline still produces a reply. A bridge that goes quiet on an
34
+ * exception is indistinguishable from a bridge that is not running.
35
+ */
36
+
37
+ import { createPolicy, DENIAL } from './policy.mjs';
38
+ import { route, commandTable, OWNER_CAPABILITIES } from './commands.mjs';
39
+ import { createOutboundQueue } from './outbound-queue.mjs';
40
+ import { createApprovalBroker } from './approval-broker.mjs';
41
+ import { createFileReader, createFileSender, fanOutFile } from './file-safety.mjs';
42
+ import { validateChannel, CHANNEL_FEATURE, CHANNEL_STATE } from './channel.mjs';
43
+
44
+ /** Prefixes the registry declares, derived so the two cannot disagree. */
45
+ import { CHANNELS as CHANNEL_REGISTRY } from './policy.mjs';
46
+
47
+ function declaredPrefixes() {
48
+ const all = [];
49
+ for (const spec of Object.values(CHANNEL_REGISTRY)) {
50
+ all.push(...spec.prefixes, ...(spec.roomPrefixes ?? []));
51
+ }
52
+ all.push('filehelper');
53
+ return all;
54
+ }
55
+
56
+ /**
57
+ * Build the bridge.
58
+ *
59
+ * @param {object} options
60
+ * @param {object[]} options.channels Channel implementations. Each is validated at construction.
61
+ * @param {object} options.actions The injected action set `commands.mjs` calls.
62
+ * @param {object} [options.policy] Overrides for `createPolicy`.
63
+ * @param {number} [options.chunkBytes]
64
+ * @param {number} [options.throttleMs]
65
+ * @param {string} [options.projectRoot] Root the file fence is anchored to.
66
+ * @param {number} [options.maxFileBytes]
67
+ * @param {() => number} [options.now]
68
+ * @param {(event: string, detail?: object) => void} [options.audit]
69
+ * @param {object} [options.broker] Overrides for `createApprovalBroker`.
70
+ */
71
+ export function createBridge(options) {
72
+ const audit = options.audit ?? (() => {});
73
+ const channels = options.channels ?? [];
74
+ if (channels.length === 0) throw new TypeError('createBridge needs at least one channel');
75
+
76
+ // A channel that does not satisfy the contract is a startup error, not a runtime surprise
77
+ // discovered the first time someone tries to send a file.
78
+ const known = declaredPrefixes();
79
+ for (const channel of channels) {
80
+ const verdict = validateChannel(channel, known);
81
+ if (!verdict.ok) throw new TypeError(`invalid channel: ${verdict.reason}`);
82
+ }
83
+ // Two maps, not one. `owners` records who claimed a prefix so a duplicate is a startup
84
+ // error; `byPrefix` is the prefix -> channel-name lookup routing uses. Folding them into a
85
+ // single map looks tidy and is wrong: the duplicate check writes `channel.name` while the
86
+ // routing lookup needs the channel *itself*, so whichever read happens second gets the
87
+ // other one's value and every reply silently resolves to no channel.
88
+ const owners = new Map();
89
+ const byPrefix = new Map();
90
+ for (const channel of channels) {
91
+ for (const prefix of channel.prefixes) {
92
+ if (owners.has(prefix)) {
93
+ throw new TypeError(`prefix ${prefix} is claimed by both ${owners.get(prefix)} and ${channel.name}`);
94
+ }
95
+ owners.set(prefix, channel.name);
96
+ byPrefix.set(prefix, channel);
97
+ }
98
+ }
99
+
100
+ /**
101
+ * Accept either a ready policy or the overrides to build one.
102
+ *
103
+ * The chat action set needs the *same* policy instance the bridge admits with, or `/trust`
104
+ * writes to a copy that admission never reads and the command silently does nothing. Duck-typed
105
+ * on `admit` rather than `instanceof`, because the two modules must not have to import each
106
+ * other's constructors to agree on this.
107
+ */
108
+ const policy =
109
+ options.policy && typeof options.policy.admit === 'function'
110
+ ? options.policy
111
+ : createPolicy({ audit, ...options.policy });
112
+ const commands = commandTable();
113
+ const broker = createApprovalBroker({
114
+ now: options.now,
115
+ audit,
116
+ ...options.broker,
117
+ });
118
+
119
+ const queues = new Map();
120
+ function queueFor(channel) {
121
+ let queue = queues.get(channel.name);
122
+ if (!queue) {
123
+ queue = createOutboundQueue({
124
+ // Two parameters, not one destructured object: the queue hands over a chunk plus
125
+ // its metadata, and the window is in the metadata. Reading `{window, text}` off a
126
+ // single argument yields `undefined` for both, which fails silently — every reply
127
+ // is "sent" and the channel receives nothing.
128
+ send: async (chunk, meta) => channel.send({ window: meta.window, text: chunk }),
129
+ chunkBytes: options.chunkBytes,
130
+ throttleMs: options.throttleMs,
131
+ now: options.now,
132
+ audit,
133
+ });
134
+ queues.set(channel.name, queue);
135
+ }
136
+ return queue;
137
+ }
138
+
139
+ const reader =
140
+ options.projectRoot !== undefined
141
+ ? createFileReader({
142
+ projectRoot: options.projectRoot,
143
+ maxBytes: options.maxFileBytes,
144
+ audit,
145
+ // Injectable so a caller can test the routing and the counting without a real
146
+ // filesystem — the fence's own symlink behaviour is proven in its own suite, and a
147
+ // bridge test that needs POSIX links on a Windows host cannot run there at all.
148
+ ...options.readerIo,
149
+ })
150
+ : null;
151
+
152
+ const started = [];
153
+
154
+ /** Which channel owns a window key. Resolved from the registry, never from a hard-coded prefix. */
155
+ function channelFor(windowKey) {
156
+ const key = String(windowKey ?? '');
157
+ const prefix = key.split(':')[0];
158
+ return byPrefix.get(prefix) ?? byPrefix.get(key) ?? null;
159
+ }
160
+
161
+ /** Reply on whichever channel the message came from. Best effort, and never throws. */
162
+ async function reply(windowKey, text, lane = 'interactive') {
163
+ const channel = channelFor(windowKey) ?? auditChannel();
164
+ if (!channel) {
165
+ audit('reply/no-channel', { window: windowKey });
166
+ return { sent: 0, failed: 0 };
167
+ }
168
+ return queueFor(channel).enqueue({ window: windowKey, text, lane });
169
+ }
170
+
171
+ /**
172
+ * Where a reply goes when the window names no channel at all.
173
+ *
174
+ * A malformed or unregistered window is an operator's configuration being wrong, and
175
+ * answering it is the difference between "my bot is broken" and "my bot told me what is
176
+ * wrong". It is **not** routed by guessing a channel from the key — that would let a
177
+ * message choose its own reply path — so it goes to the first channel that started, with
178
+ * the reason named in the text.
179
+ */
180
+ function auditChannel() {
181
+ return started[0] ?? channels[0] ?? null;
182
+ }
183
+
184
+ /**
185
+ * Handle one inbound message.
186
+ *
187
+ * @param {{windowKey: string, talkerId?: string, text: string, addressed?: boolean}} message
188
+ * @returns {Promise<{action: string, [k: string]: unknown}>}
189
+ */
190
+ async function handleInbound(message) {
191
+ const windowKey = message?.windowKey;
192
+ // Admission first, and before anything else — including before deciding whether to
193
+ // reply, because whether we reply depends on what admission concluded.
194
+ const admitted = policy.admit(message);
195
+
196
+ if (!admitted.allow) {
197
+ // A stranger gets silence: replying confirms the bot exists and spends the
198
+ // anti-flood budget on someone who will never be admitted. An identity that has been
199
+ // admitted before gets the reason, because for them a silent drop is a bug report.
200
+ if (admitted.reason === DENIAL.NOT_ADOPTED || admitted.reason === DENIAL.SENDER_UNTRUSTED) {
201
+ audit('inbound/ignored', { window: windowKey, reason: admitted.reason });
202
+ return { action: 'ignored', reason: admitted.reason };
203
+ }
204
+ const text = `这条消息没有被处理:${admitted.reason}`;
205
+ await reply(windowKey, text);
206
+ return { action: 'refused', reason: admitted.reason };
207
+ }
208
+
209
+ const caps = new Set(OWNER_CAPABILITIES);
210
+ let routed;
211
+ try {
212
+ routed = await route(message.text, {
213
+ capabilities: caps,
214
+ identity: { channel: admitted.channel, id: admitted.id, window: windowKey },
215
+ policyModes: options.policyModes ?? ['read-only', 'ask', 'danger-full-access'],
216
+ actions: actionsFor(windowKey),
217
+ commands,
218
+ });
219
+ } catch (error) {
220
+ // Unreachable through `route`, which catches its own handler failures. Kept because a
221
+ // bridge that goes quiet on an exception looks exactly like a bridge that is not
222
+ // running, and that is the failure this whole file is written against.
223
+ audit('inbound/error', { window: windowKey, detail: String(error) });
224
+ await reply(windowKey, `处理失败:${error instanceof Error ? error.message : String(error)}`);
225
+ return { action: 'error' };
226
+ }
227
+
228
+ if (routed.kind === 'prompt') {
229
+ if (typeof options.actions.prompt !== 'function') {
230
+ await reply(windowKey, '这台机器没有接入会话,无法发送 prompt。');
231
+ return { action: 'unavailable' };
232
+ }
233
+ // The window's bound session is resolved by the injected action, not here: binding is
234
+ // session-catalogue work and does not belong in the transport layer.
235
+ const delivered = await options.actions.prompt(windowKey, routed.text);
236
+ await reply(windowKey, delivered?.ack ?? '已收到。');
237
+ return { action: 'prompt' };
238
+ }
239
+
240
+ await reply(windowKey, routed.text);
241
+ return { action: routed.kind, name: routed.name, refusal: routed.refusal, taskId: routed.taskId };
242
+ }
243
+
244
+ /** The action set the command table sees, with the window bound in. */
245
+ function actionsFor(windowKey) {
246
+ const injected = options.actions ?? {};
247
+ return {
248
+ ...injected,
249
+ pending: (window) => broker.listFor(window),
250
+ decideApproval: (id, outcome) => broker.decide(id, outcome),
251
+ };
252
+ }
253
+
254
+ /**
255
+ * Offer an approval to the bridge, as the host's answerer would.
256
+ *
257
+ * The four-line wiring this module exists to make readable. `next` is called exactly when
258
+ * the bridge declines or its deadline passes, and **never** when it answers — so the
259
+ * host's own answerers are reached in every case where this bridge has nothing to say.
260
+ */
261
+ async function offerApproval({ request, windowKey, timeoutMs, next }) {
262
+ const offered = broker.offer({ request, windowKey, timeoutMs, delegate: next });
263
+ if (!offered.claim) return { claimed: false, reason: offered.reason };
264
+ return { claimed: true, id: offered.id, outcome: await offered.promise };
265
+ }
266
+
267
+ /** Send one project file to every window bound to a session. */
268
+ async function sendFile({ relPath, windows, caption = '' }) {
269
+ if (!reader) return { ok: false, reason: 'no_project_root' };
270
+ const file = await reader.read(relPath);
271
+ if (!file.ok) return { ok: false, reason: file.reason, detail: file.detail };
272
+
273
+ const outcome = await fanOutFile({
274
+ file,
275
+ caption,
276
+ targets: windows.map((w) => {
277
+ const channel = channelFor(w);
278
+ if (!channel || !channel.capabilities?.[CHANNEL_FEATURE.FILES]) {
279
+ return { window: w, deliver: async () => ({ ok: false, reason: 'channel_cannot_send_files' }) };
280
+ }
281
+ const sender = createFileSender({ send: async (f, c) => channel.send({ window: w, text: c, file: f }), audit });
282
+ return { window: w, deliver: sender.deliver };
283
+ }),
284
+ });
285
+ // The count the caller reports must come from here, not from `windows.length`.
286
+ return { ok: outcome.allSent, ...outcome, name: file.name, bytes: file.bytes };
287
+ }
288
+
289
+ async function start() {
290
+ for (const channel of channels) {
291
+ try {
292
+ await channel.start((message) => handleInbound(message));
293
+ started.push(channel);
294
+ audit('channel/started', { channel: channel.name });
295
+ } catch (error) {
296
+ // One channel failing to start must not take the others down — 0.1.0 got this right
297
+ // and it is kept. What it got wrong was staying silent about it: the state is
298
+ // recorded, and `status()` reports it rather than leaving `online: false` to mean
299
+ // four different things.
300
+ channel.setState?.(CHANNEL_STATE.UNCONFIGURED);
301
+ audit('channel/start-failed', { channel: channel.name, detail: String(error) });
302
+ }
303
+ }
304
+ return { started: started.length, total: channels.length };
305
+ }
306
+
307
+ async function stop() {
308
+ for (const queue of queues.values()) await queue.dispose();
309
+ queues.clear();
310
+ for (const channel of [...started].reverse()) {
311
+ try {
312
+ await channel.stop();
313
+ } catch {
314
+ /* a channel that cannot stop cleanly is not worth failing a shutdown over */
315
+ }
316
+ }
317
+ broker.dispose();
318
+ started.length = 0;
319
+ }
320
+
321
+ /** What is running, told apart rather than collapsed into a boolean. */
322
+ function status() {
323
+ return {
324
+ channels: channels.map((c) => ({ name: c.name, state: c.state ?? 'unknown' })),
325
+ pendingApprovals: broker.size,
326
+ owners: policy.ownerCount,
327
+ admitted: policy.hasOwners(),
328
+ };
329
+ }
330
+
331
+ return {
332
+ start,
333
+ stop,
334
+ handleInbound,
335
+ offerApproval,
336
+ sendFile,
337
+ status,
338
+ // Exposed for the integration suite and for wiring the host's own surfaces.
339
+ policy,
340
+ broker,
341
+ commands,
342
+ channelFor,
343
+ };
344
+ }
@@ -0,0 +1,192 @@
1
+ /**
2
+ * The contract every IM channel implements (v0.7.0).
3
+ *
4
+ * ## What this replaces
5
+ *
6
+ * `dsh-chatops` 0.1.0 had three overlapping shapes — a `BridgeChannel`, a `ManagedChannel`,
7
+ * and the types in a file named after one vendor — and none of them was the real contract.
8
+ * The bridge was typed against one, handed another, and reached past its own type with
9
+ * three `as any` casts to call `channelFor`. Beyond the declared members it also *probed*
10
+ * for `statusSnapshot`, `sendFile`, `sendApprovalCard`, `sendProgressCard`,
11
+ * `completeProgressCard` and `onCardAction`, each on an `any`, so a channel missing one
12
+ * failed only at runtime and only once the feature was used.
13
+ *
14
+ * A channel whose prefix was missing from the authorisation regexes had every message from
15
+ * it silently dropped. Adding a fifth channel meant touching nine places, four of them
16
+ * duplicated prefix tables that had to agree.
17
+ *
18
+ * ## The shape here
19
+ *
20
+ * One interface, five members, and a **capabilities** descriptor instead of method
21
+ * sniffing: a channel that cannot send cards says so, and the caller asks rather than
22
+ * probing. The registry in `policy.mjs` is the single source of channel identity, so a
23
+ * channel cannot exist in one table and be missing from another.
24
+ *
25
+ * A channel never decides who may speak. That is `policy.mjs`, and keeping it out of the
26
+ * channel is what stops a vendor from quietly becoming part of the authorisation path —
27
+ * which is exactly what a hard-coded `fsu:` prefix inside the approval broker was.
28
+ */
29
+
30
+ /** What a channel can do, asked instead of guessed. */
31
+ export const CHANNEL_FEATURE = Object.freeze({
32
+ /** Messages longer than the platform budget must be split. */
33
+ CHUNKING: 'chunking',
34
+ /** Can carry a file or image. */
35
+ FILES: 'files',
36
+ /** Can render an interactive approval card. */
37
+ CARDS: 'cards',
38
+ /** Can edit or complete a message already sent. */
39
+ EDIT: 'edit',
40
+ /** Group conversations reach this channel. */
41
+ ROOMS: 'rooms',
42
+ });
43
+
44
+ /** Channel lifecycle states, reported rather than inferred from a boolean. */
45
+ export const CHANNEL_STATE = Object.freeze({
46
+ /** Not started, or deliberately off. */
47
+ IDLE: 'idle',
48
+ /** Credentials missing or refused; the channel will not take work. */
49
+ UNCONFIGURED: 'unconfigured',
50
+ /** Trying. Not yet able to send. */
51
+ CONNECTING: 'connecting',
52
+ READY: 'ready',
53
+ /** Was ready, lost it, retrying. The state 0.1.0 collapsed into `online: false`. */
54
+ DEGRADED: 'degraded',
55
+ STOPPED: 'stopped',
56
+ });
57
+
58
+ /**
59
+ * Validate that an object satisfies the channel contract, and say what is missing.
60
+ *
61
+ * Called at construction rather than on first use. The failure this prevents is the one
62
+ * that plugin had: a channel that starts, reports itself registered, accepts routed
63
+ * messages, and throws only when a feature is first exercised.
64
+ *
65
+ * @param {object} channel
66
+ * @param {string[]} [declaredPrefixes] The prefixes `policy.mjs` claims for this channel.
67
+ * @returns {{ok: true} | {ok: false, missing: string[], reason: string}}
68
+ */
69
+ export function validateChannel(channel, declaredPrefixes) {
70
+ if (!channel || typeof channel !== 'object') {
71
+ return { ok: false, missing: [], reason: 'channel is not an object' };
72
+ }
73
+ const missing = [];
74
+ if (typeof channel.name !== 'string' || channel.name === '') missing.push('name');
75
+ if (typeof channel.start !== 'function') missing.push('start()');
76
+ if (typeof channel.stop !== 'function') missing.push('stop()');
77
+ if (typeof channel.send !== 'function') missing.push('send()');
78
+ if (!Array.isArray(channel.prefixes) || channel.prefixes.length === 0) missing.push('prefixes[]');
79
+
80
+ if (missing.length > 0) {
81
+ return { ok: false, missing, reason: `channel is missing: ${missing.join(', ')}` };
82
+ }
83
+
84
+ // A channel whose prefixes the registry does not know would have every message from it
85
+ // refused as `channel_unknown` -- silently, from the operator's point of view. Naming it
86
+ // here turns a dead channel into a startup error.
87
+ if (Array.isArray(declaredPrefixes) && declaredPrefixes.length > 0) {
88
+ const unknown = channel.prefixes.filter((p) => !declaredPrefixes.includes(p));
89
+ if (unknown.length > 0) {
90
+ return {
91
+ ok: false,
92
+ missing: [],
93
+ reason:
94
+ `channel ${channel.name} claims prefix(es) ${unknown.join(', ')} that the channel registry ` +
95
+ `does not declare, so every message from it would be refused as channel_unknown`,
96
+ };
97
+ }
98
+ }
99
+
100
+ const features = channel.capabilities ?? {};
101
+ const badFeature = Object.values(CHANNEL_FEATURE).find((f) => features[f] !== undefined && typeof features[f] !== 'boolean');
102
+ if (badFeature) {
103
+ return { ok: false, missing: [], reason: `capability ${badFeature} must be a boolean, not ${typeof features[badFeature]}` };
104
+ }
105
+
106
+ return { ok: true };
107
+ }
108
+
109
+ /**
110
+ * A channel that exists in memory, for tests and for a dry run.
111
+ *
112
+ * It is a **first-class implementation**, not a mock: the same object is what a real
113
+ * adapter is checked against, and what the integration suite drives end to end. A test
114
+ * double that diverges from the interface is how an interface stops meaning anything, so
115
+ * this one is validated by the same `validateChannel` a real channel is.
116
+ *
117
+ * @param {object} [options]
118
+ * @param {string} [options.name]
119
+ * @param {string[]} [options.prefixes]
120
+ * @param {object} [options.capabilities]
121
+ * @param {(message: object) => void} [options.onOutbound] Called for each send; the test's
122
+ * window into what the bridge tried to say.
123
+ * @param {() => boolean} [options.failWhen] Return true to make the next send reject.
124
+ */
125
+ export function createMemoryChannel(options = {}) {
126
+ const name = options.name ?? 'memory';
127
+ const prefixes = options.prefixes ?? ['mem'];
128
+ const capabilities = {
129
+ [CHANNEL_FEATURE.CHUNKING]: true,
130
+ [CHANNEL_FEATURE.FILES]: true,
131
+ [CHANNEL_FEATURE.CARDS]: false,
132
+ [CHANNEL_FEATURE.EDIT]: false,
133
+ [CHANNEL_FEATURE.ROOMS]: false,
134
+ ...options.capabilities,
135
+ };
136
+
137
+ /** window -> [{ text, at }]. */
138
+ const sent = [];
139
+ let state = CHANNEL_STATE.IDLE;
140
+ let handlers = null;
141
+ const log = [];
142
+
143
+ // Built by assignment rather than returned as an object literal: an object literal with
144
+ // `...spread` **evaluates** an accessor instead of copying it, so a `sent` getter written
145
+ // that way silently becomes a snapshot taken at construction.
146
+ const channel = {
147
+ name,
148
+ prefixes,
149
+ capabilities,
150
+
151
+ async start(inbound) {
152
+ if (typeof inbound !== 'function') throw new TypeError('start() needs an inbound handler');
153
+ handlers = inbound;
154
+ state = CHANNEL_STATE.READY;
155
+ log.push('start');
156
+ },
157
+
158
+ async stop() {
159
+ state = CHANNEL_STATE.STOPPED;
160
+ handlers = null;
161
+ log.push('stop');
162
+ },
163
+
164
+ async send({ window, text }) {
165
+ if (state !== CHANNEL_STATE.READY) {
166
+ throw new Error(`channel ${name} is ${state}, not ready`);
167
+ }
168
+ if (options.failWhen?.(text)) throw new Error('scripted send failure');
169
+ const record = { window, text, at: Date.now() };
170
+ sent.push(record);
171
+ options.onOutbound?.(record);
172
+ },
173
+
174
+ /** Deliver a message as if it arrived from the platform. */
175
+ async receive(message) {
176
+ if (typeof handlers !== 'function') throw new Error(`channel ${name} was never started`);
177
+ return handlers(message);
178
+ },
179
+
180
+ setState(next) {
181
+ state = next;
182
+ },
183
+ };
184
+
185
+ Object.defineProperty(channel, 'state', { get: () => state, enumerable: true });
186
+ // Read-only views for tests and for a dry run. Named so it is obvious they are not part of
187
+ // the contract a real channel implements.
188
+ Object.defineProperty(channel, 'sent', { get: () => sent, enumerable: false });
189
+ Object.defineProperty(channel, 'log', { get: () => log, enumerable: false });
190
+
191
+ return channel;
192
+ }