@twinsearth/w2m-dsh-plugin 0.6.3 → 0.8.2

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 (42) hide show
  1. package/CHANGELOG.md +1176 -0
  2. package/HANDOFF.md +173 -0
  3. package/README.md +9 -2
  4. package/bin/w2m-localside.mjs +325 -308
  5. package/bin/w2m-rabbit.mjs +6 -0
  6. package/bin/w2m-stun.mjs +0 -0
  7. package/deploy/mac/mac-self-update.mjs +257 -0
  8. package/deploy/server/README.md +109 -0
  9. package/deploy/server/deploy-w2m.sh +161 -0
  10. package/deploy/server/w2m-localside.service +31 -0
  11. package/deploy/server/w2m-rabbit.service +26 -0
  12. package/deploy/server/w2m-stun.service +26 -0
  13. package/docs/ACCOUNT-FLEET-DESIGN.md +369 -0
  14. package/package.json +34 -22
  15. package/src/agent/agent.mjs +24 -2
  16. package/src/chatops/account.mjs +401 -0
  17. package/src/chatops/actions.mjs +227 -0
  18. package/src/chatops/approval-broker.mjs +320 -0
  19. package/src/chatops/auth.mjs +376 -0
  20. package/src/chatops/bridge.mjs +344 -0
  21. package/src/chatops/channel.mjs +192 -0
  22. package/src/chatops/commands.mjs +409 -0
  23. package/src/chatops/config-route.mjs +332 -0
  24. package/src/chatops/device-signing.mjs +218 -0
  25. package/src/chatops/durability.mjs +202 -0
  26. package/src/chatops/file-safety.mjs +282 -0
  27. package/src/chatops/fleet-join.mjs +164 -0
  28. package/src/chatops/fleet-scope.mjs +176 -0
  29. package/src/chatops/fleet-wiring.mjs +258 -0
  30. package/src/chatops/fleet.mjs +272 -0
  31. package/src/chatops/handoff.mjs +258 -0
  32. package/src/chatops/ilink-fleet-bridge.mjs +197 -0
  33. package/src/chatops/ilink-protocol.mjs +165 -0
  34. package/src/chatops/ilink-transport.mjs +168 -0
  35. package/src/chatops/ilink.mjs +423 -0
  36. package/src/chatops/index.mjs +440 -0
  37. package/src/chatops/outbound-queue.mjs +282 -0
  38. package/src/chatops/policy.mjs +356 -0
  39. package/src/chatops/run-command.mjs +308 -0
  40. package/src/plugin/tools.mjs +1 -1
  41. package/src/relay/server.mjs +96 -0
  42. package/src/relay/state.mjs +2279 -2011
@@ -0,0 +1,202 @@
1
+ /**
2
+ * Durability policy: how many copies exist, where, and what happens as the fleet grows (v0.7.12).
3
+ *
4
+ * ## The requirement, and the trap inside it
5
+ *
6
+ * The stated requirement is **"tolerate losing 90% of nodes and still recover"**, described as
7
+ * "10× redundancy". Those are two different claims, and conflating them produces a system that
8
+ * *looks* compliant and is not:
9
+ *
10
+ * * **10 replicas** is a property of the *storage layout* — each object is written to 10 places.
11
+ * It is **impossible** below 10 nodes: there are not 10 places to write to.
12
+ * * **Surviving 90% loss** is a property of the *whole system*, counting every copy including the
13
+ * authoritative one. It is achievable below 10 nodes **only because the server holds a complete
14
+ * copy**.
15
+ *
16
+ * So with 3 machines and a nominal 10× target, the honest statement is:
17
+ *
18
+ * > the devices hold at most 3 copies, and **the 90% guarantee is met by the server's full copy,
19
+ * > not by device redundancy.**
20
+ *
21
+ * That distinction is not pedantry — it changes the design. It means the server is **not "one more
22
+ * replica"** that can be dropped when devices are plentiful; it is the authority, and the tier
23
+ * policy below keeps it complete at every stage.
24
+ *
25
+ * ## The tiers, as specified
26
+ *
27
+ * | devices | primary | server's role |
28
+ * |---|---|---|
29
+ * | < 100 | the server | authority and sole complete copy |
30
+ * | 100 – 1000 | the server, with devices holding copies | authority, refreshed from devices |
31
+ * | > 1000 | the devices | **fallback**, must never be deleted |
32
+ *
33
+ * The thresholds are inclusive-at-the-bottom, so 100 devices is already the middle tier and 1000 is
34
+ * already distributed-primary. Stated because an off-by-one here silently changes where the
35
+ * authority lives.
36
+ */
37
+
38
+ /** The requirement, as one number: lose this fraction of *all* copies and still recover. */
39
+ export const REQUIRED_LOSS_TOLERANCE = 0.9;
40
+
41
+ /** The nominal replication target. A layout goal, **not** achievable below this many nodes. */
42
+ export const NOMINAL_REPLICATION = 10;
43
+
44
+ /** Where the authority lives. */
45
+ export const TIER = Object.freeze({
46
+ /** Fewer than `deviceThreshold` devices: the server is the only complete copy. */
47
+ SERVER: 'server',
48
+ /** Devices keep copies, the server stays authoritative. */
49
+ HYBRID: 'hybrid',
50
+ /** Devices are primary; the server is a fallback that must not be deleted from. */
51
+ DISTRIBUTED: 'distributed',
52
+ });
53
+
54
+ export const DEFAULT_THRESHOLDS = Object.freeze({
55
+ /** At or above this many devices, devices start holding copies. */
56
+ hybridAt: 100,
57
+ /** At or above this many devices, devices are primary and the server is a fallback. */
58
+ distributedAt: 1000,
59
+ });
60
+
61
+ /**
62
+ * Which tier a fleet of `deviceCount` devices is in.
63
+ *
64
+ * `deviceCount` is **live, paired devices**, not a configured maximum: a fleet of 1000 that is
65
+ * currently 3 machines online is a 3-machine fleet for the purpose of where the authority sits.
66
+ * Using the configured size would move the authority to devices that are not there.
67
+ */
68
+ export function tierFor(deviceCount, thresholds = DEFAULT_THRESHOLDS) {
69
+ const n = Number.isFinite(deviceCount) && deviceCount > 0 ? Math.floor(deviceCount) : 0;
70
+ const { hybridAt, distributedAt } = { ...DEFAULT_THRESHOLDS, ...thresholds };
71
+ if (n >= distributedAt) return TIER.DISTRIBUTED;
72
+ if (n >= hybridAt) return TIER.HYBRID;
73
+ return TIER.SERVER;
74
+ }
75
+
76
+ /**
77
+ * The replication factor actually achievable, which is **bounded by the node count**.
78
+ *
79
+ * This is the function the "10×" claim needs and usually does not have: asking for 10 replicas
80
+ * across 3 nodes is not 10 copies, it is 3 copies and a log line. Returning the achievable number
81
+ * means a caller can report the truth instead of the target.
82
+ *
83
+ * @param {object} input
84
+ * @param {number} input.deviceCount Live devices.
85
+ * @param {number} [input.withServer] Whether the server's own copy counts toward the total.
86
+ * @param {number} [input.target] The nominal goal.
87
+ */
88
+ export function achievableReplication({ deviceCount, withServer = true, target = NOMINAL_REPLICATION }) {
89
+ const n = Number.isFinite(deviceCount) && deviceCount > 0 ? Math.floor(deviceCount) : 0;
90
+ const places = n + (withServer ? 1 : 0);
91
+ return { requested: target, achievable: Math.min(target, places), places, limitedByNodes: places < target };
92
+ }
93
+
94
+ /**
95
+ * How much of the fleet can be lost while the data is still recoverable.
96
+ *
97
+ * **The recoverable condition is a surviving complete copy**, not "one replica left standing": a
98
+ * replica set is only as good as its ability to reconstruct the whole object. So this counts the
99
+ * server as one complete copy and devices as *partial* copies unless the device set itself holds
100
+ * `target` replicas of every object.
101
+ *
102
+ * Returns a fraction, so the caller compares against `REQUIRED_LOSS_TOLERANCE` rather than
103
+ * re-deriving the arithmetic.
104
+ *
105
+ * @param {object} input
106
+ * @param {number} input.deviceCount Live devices holding copies.
107
+ * @param {boolean} [input.serverComplete] Whether the server holds a complete copy. Defaults true,
108
+ * and the tier policy requires it at every tier.
109
+ * @param {number} [input.target] Nominal replication goal.
110
+ */
111
+ export function lossTolerance({ deviceCount, serverComplete = true, target = NOMINAL_REPLICATION }) {
112
+ const n = Number.isFinite(deviceCount) && deviceCount > 0 ? Math.floor(deviceCount) : 0;
113
+ const { achievable, limitedByNodes } = achievableReplication({ deviceCount: n, withServer: false, target });
114
+
115
+ /**
116
+ * The server alone survives the loss of **every** device: it is a complete copy, so 100%.
117
+ *
118
+ * This is what makes the 90% requirement satisfiable at any fleet size — and it is also why the
119
+ * server must never be deleted from. If the server's copy were dropped to save space once devices
120
+ * held copies, the guarantee would vanish exactly when the fleet looked big enough to trust.
121
+ */
122
+ if (serverComplete) {
123
+ return {
124
+ tolerate: 1,
125
+ reason: 'server_complete',
126
+ deviceReplicas: achievable,
127
+ limitedByNodes,
128
+ /** The guarantee holds regardless of device loss, because the server copy is complete. */
129
+ independentOfDevices: true,
130
+ };
131
+ }
132
+
133
+ // No complete server copy: the devices must reconstruct from their own replicas, so the fraction
134
+ // that can be lost is bounded by how many copies actually exist.
135
+ if (achievable <= 1) {
136
+ return { tolerate: 0, reason: 'single_copy', deviceReplicas: achievable, limitedByNodes, independentOfDevices: false };
137
+ }
138
+ const tolerate = 1 - 1 / achievable;
139
+ return {
140
+ tolerate,
141
+ reason: 'device_replicas_only',
142
+ deviceReplicas: achievable,
143
+ limitedByNodes,
144
+ independentOfDevices: false,
145
+ /** True when this arrangement actually meets the stated requirement on its own. */
146
+ meetsRequirement: tolerate >= REQUIRED_LOSS_TOLERANCE,
147
+ };
148
+ }
149
+
150
+ /**
151
+ * The whole answer for one fleet, in the shape an operator or a health endpoint wants.
152
+ *
153
+ * Deliberately reports **where the guarantee comes from**. "health: 100%" with no provenance is the
154
+ * reading that lets someone delete the server copy and discover the problem later.
155
+ */
156
+ export function durabilityReport({ deviceCount, thresholds = DEFAULT_THRESHOLDS, serverComplete = true, target = NOMINAL_REPLICATION }) {
157
+ const tier = tierFor(deviceCount, thresholds);
158
+ const loss = lossTolerance({ deviceCount, serverComplete, target });
159
+ const replication = achievableReplication({ deviceCount, withServer: false, target });
160
+ return {
161
+ device_count: Number.isFinite(deviceCount) ? Math.floor(deviceCount) : 0,
162
+ tier,
163
+ /** The nominal goal and what the fleet can actually do, side by side. */
164
+ replication_target: target,
165
+ replication_device_copies: replication.achievable,
166
+ replication_limited_by_nodes: replication.limitedByNodes,
167
+ loss_tolerated: loss.tolerate,
168
+ required_loss_tolerance: REQUIRED_LOSS_TOLERANCE,
169
+ meets_required_tolerance: loss.tolerate >= REQUIRED_LOSS_TOLERANCE,
170
+ guarantee_basis: loss.reason,
171
+ server_copy_required: true,
172
+ /** Named so the health number can never be read without its provenance. */
173
+ note:
174
+ loss.reason === 'server_complete'
175
+ ? `the guarantee is the server's complete copy, not device redundancy: with ${Number.isFinite(deviceCount) ? Math.floor(deviceCount) : 0} device(s) `
176
+ + `only ${replication.achievable} device copies exist${replication.limitedByNodes ? `, short of the nominal ${target}` : ''}. `
177
+ + 'Deleting the server copy would remove the guarantee.'
178
+ : 'there is no complete server copy, so recovery depends on the device replicas alone',
179
+ };
180
+ }
181
+
182
+ /**
183
+ * How refreshing should be routed for a tier.
184
+ *
185
+ * The tier decides **who is asked first**, not who is trusted: the server is authoritative in every
186
+ * tier, so a disagreement is resolved by the server rather than by a quorum of devices.
187
+ */
188
+ export function refreshPlan({ tier, serverHealthy = true }) {
189
+ if (!serverHealthy) {
190
+ // The server is a fallback that must never be deleted from, so an unhealthy server still gets
191
+ // written to and read from once it returns — the plan says "degraded", not "skip".
192
+ return { order: ['devices', 'server'], degraded: true, reason: 'server_unhealthy' };
193
+ }
194
+ if (tier === TIER.DISTRIBUTED) {
195
+ // Devices are primary for *serving*, the server still receives every write.
196
+ return { order: ['devices', 'server'], degraded: false, reason: 'distributed_primary' };
197
+ }
198
+ if (tier === TIER.HYBRID) {
199
+ return { order: ['server', 'devices'], degraded: false, reason: 'server_primary_with_device_copies' };
200
+ }
201
+ return { order: ['server'], degraded: false, reason: 'server_only' };
202
+ }
@@ -0,0 +1,282 @@
1
+ /**
2
+ * Reading a file out of the project, safely (v0.7.0).
3
+ *
4
+ * ## The two defects this file exists to make impossible
5
+ *
6
+ * **1. A lexical fence is not a fence.** `dsh-chatops` 0.1.0 resolved the requested
7
+ * path and compared prefixes:
8
+ *
9
+ * const abs = resolve(cwd, relPath)
10
+ * if (abs !== cwd && !abs.startsWith(cwd + sep)) { refuse }
11
+ *
12
+ * `resolve()` normalises `..` but does **not** resolve symlinks, and `statSync` and
13
+ * `readFile` both follow them. A symlink *inside* the project pointing anywhere
14
+ * readable — `~/.dsh/.credentials.yaml` on the machine this was audited against —
15
+ * resolves to a path that still begins with the project root, so the check passes and
16
+ * `/send` ships the target's contents to a chat platform. The containment check has to
17
+ * be performed on the **real** path, which means resolving symlinks on both sides and
18
+ * re-checking after resolution, because the target itself may be a link.
19
+ *
20
+ * **2. A delivery that failed must not be reported as delivered.** Only one of that
21
+ * plugin's four channels re-threw a failed send; the other three logged, told the user
22
+ * "❌ 发送失败", and then returned normally — so the caller counted the file as sent and
23
+ * the model was told `已发送到 N 个 IM 窗口`. The read side here returns a discriminated
24
+ * result rather than throwing, and the send side is required to turn a failure into a
25
+ * failed result. Neither layer is allowed to guess.
26
+ *
27
+ * ## What this module is
28
+ *
29
+ * Pure I/O with injected `stat`/`realpath`/`readFile`, so every refusal is testable
30
+ * without a real filesystem and without a symlink that has to exist on the host running
31
+ * the tests. Nothing here knows about chat platforms.
32
+ */
33
+
34
+ /**
35
+ * Path arithmetic is done in **posix** terms, on every platform.
36
+ *
37
+ * Two reasons, and the second is the one that matters. A relay, a Linux VPS and a
38
+ * container image all speak `/`; and a fence whose containment logic depends on
39
+ * `node:path`'s platform-specific `sep` cannot be tested for symlink escapes on Windows,
40
+ * which is where the fence is most needed and where the plugin that got this wrong was
41
+ * developed. Choosing one separator makes the logic identical everywhere and testable
42
+ * from a POSIX-only fixture, which is what the suite does.
43
+ */
44
+ import { resolve, sep, isAbsolute, basename, extname } from 'node:path/posix';
45
+
46
+ /** Why a file was not sent. Reported, never thrown: a refusal is a normal answer. */
47
+ export const FILE_REFUSAL = Object.freeze({
48
+ /** The path escaped the project root, after symlinks were resolved. */
49
+ ESCAPED: 'escaped_project',
50
+ /** The path is absolute, or otherwise not project-relative. */
51
+ NOT_RELATIVE: 'not_relative',
52
+ /** Nothing is there. */
53
+ MISSING: 'missing',
54
+ /** It exists and is not a regular file. */
55
+ NOT_A_FILE: 'not_a_file',
56
+ /** Larger than the configured ceiling. */
57
+ TOO_LARGE: 'too_large',
58
+ /** The extension is one the fleet refuses to move through a chat platform. */
59
+ TYPE_REFUSED: 'type_refused',
60
+ /** The path is structurally unusable. */
61
+ MALFORMED: 'malformed',
62
+ });
63
+
64
+ /**
65
+ * Extensions that are never sent, whatever the operator asks for.
66
+ *
67
+ * A chat platform is a third-party service and a message is not a filesystem. This is
68
+ * deliberately a **deny-list of shapes** rather than an allow-list of extensions: the
69
+ * useful case is "send me the log / the screenshot / the report", and an allow-list
70
+ * would refuse the perfectly ordinary file someone actually wanted. What is refused is
71
+ * the set whose contents are credentials or executable code, where "I asked for it" is
72
+ * not a good enough reason.
73
+ */
74
+ export const REFUSED_EXTENSIONS = Object.freeze([
75
+ '.pem', '.key', '.p12', '.pfx', '.jks', '.keystore',
76
+ '.env', '.credentials', '.secret', '.secrets',
77
+ '.exe', '.dll', '.so', '.dylib', '.msi', '.bat', '.cmd', '.ps1', '.sh', '.app',
78
+ ]);
79
+
80
+ /** Name fragments refused regardless of extension: `.env.local`, `id_rsa`, `foo.pem.bak`. */
81
+ const REFUSED_NAME_PATTERNS = Object.freeze([
82
+ /^\.env(\..*)?$/i,
83
+ /^id_(rsa|dsa|ecdsa|ed25519)(\..*)?$/i,
84
+ /(^|[._-])(secret|credential|password|passwd|token)s?([._-]|$)/i,
85
+ ]);
86
+
87
+ /**
88
+ * Default ceiling. A chat platform will refuse a large file anyway, and the failure
89
+ * should be ours, named, before the upload starts — not the platform's, after it has
90
+ * half-sent something.
91
+ */
92
+ export const DEFAULT_MAX_BYTES = 100 * 1024 * 1024;
93
+
94
+ /**
95
+ * Create a reader bound to one project root.
96
+ *
97
+ * @param {object} options
98
+ * @param {string} options.projectRoot Absolute project root. The fence is this directory.
99
+ * @param {number} [options.maxBytes]
100
+ * @param {string[]} [options.extraRefusedExtensions]
101
+ * @param {string[]} [options.allowExtensions] Escape hatch: these are permitted even if refused above.
102
+ * @param {(p: string) => Promise<{isFile: () => boolean, size: number, isSymbolicLink?: () => boolean}>} [options.lstat]
103
+ * @param {(p: string) => Promise<string>} [options.realpath]
104
+ * @param {(p: string) => Promise<Buffer>} [options.readFile]
105
+ * @param {(event: string, detail?: object) => void} [options.audit]
106
+ */
107
+ export function createFileReader(options) {
108
+ const { projectRoot } = options;
109
+ if (typeof projectRoot !== 'string' || projectRoot === '') {
110
+ throw new TypeError('createFileReader requires a projectRoot');
111
+ }
112
+ const maxBytes = options.maxBytes ?? DEFAULT_MAX_BYTES;
113
+ const audit = options.audit ?? (() => {});
114
+ const refusedExtensions = new Set(
115
+ [...REFUSED_EXTENSIONS, ...(options.extraRefusedExtensions ?? [])].map((e) => e.toLowerCase()),
116
+ );
117
+ const allowedExtensions = new Set((options.allowExtensions ?? []).map((e) => e.toLowerCase()));
118
+
119
+ const lstat = options.lstat ?? (async (p) => (await import('node:fs/promises')).lstat(p));
120
+ const realpath = options.realpath ?? (async (p) => (await import('node:fs/promises')).realpath(p));
121
+ const readFile = options.readFile ?? (async (p) => (await import('node:fs/promises')).readFile(p));
122
+
123
+ /**
124
+ * The real project root.
125
+ *
126
+ * A failure is **not** cached. Caching one would turn a transient problem — the
127
+ * workspace briefly unreadable during a checkout, say — into a reader that refuses
128
+ * every path for the rest of the process, with a stale reason.
129
+ */
130
+ let realRootCache = null;
131
+ async function realRoot() {
132
+ if (realRootCache !== null) return realRootCache;
133
+ const resolved = await realpath(projectRoot);
134
+ realRootCache = resolved;
135
+ return resolved;
136
+ }
137
+
138
+ /** Is `candidate` the root itself or inside it? Compared after symlink resolution. */
139
+ function contains(root, candidate) {
140
+ if (candidate === root) return true;
141
+ return candidate.startsWith(root.endsWith(sep) ? root : root + sep);
142
+ }
143
+
144
+ /**
145
+ * Read one project-relative file.
146
+ *
147
+ * @param {string} relPath
148
+ * @returns {Promise<{ok: true, path: string, name: string, bytes: number, data: Buffer}
149
+ * | {ok: false, reason: string, detail?: string}>}
150
+ */
151
+ async function read(relPath) {
152
+ if (typeof relPath !== 'string' || relPath.trim() === '') {
153
+ return refuse(FILE_REFUSAL.MALFORMED, 'empty path');
154
+ }
155
+ // An absolute path is not a project-relative one, and treating it as one is how a
156
+ // "relative to the project" API quietly becomes an arbitrary-read API.
157
+ if (isAbsolute(relPath)) return refuse(FILE_REFUSAL.NOT_RELATIVE, relPath);
158
+ // NUL bytes cannot appear in a real path and truncate in some syscalls.
159
+ if (relPath.includes('\u0000')) return refuse(FILE_REFUSAL.MALFORMED, 'NUL in path');
160
+
161
+ const root = await realRoot();
162
+ const lexical = resolve(root, relPath);
163
+
164
+ // Cheap refusal first, so a traversal never even reaches the filesystem.
165
+ if (!contains(root, lexical)) return refuse(FILE_REFUSAL.ESCAPED, `lexical: ${lexical}`);
166
+
167
+ let stats;
168
+ try {
169
+ stats = await lstat(lexical);
170
+ } catch {
171
+ return refuse(FILE_REFUSAL.MISSING, relPath);
172
+ }
173
+
174
+ // The real check. `realpath` follows every link in every component, so a symlinked
175
+ // directory halfway down the path is caught as surely as a symlinked leaf.
176
+ let real;
177
+ try {
178
+ real = await realpath(lexical);
179
+ } catch {
180
+ return refuse(FILE_REFUSAL.MISSING, relPath);
181
+ }
182
+ if (!contains(root, real)) {
183
+ // The one that 0.1.0 shipped: a link inside the project pointing out of it.
184
+ return refuse(FILE_REFUSAL.ESCAPED, `resolved: ${real}`);
185
+ }
186
+
187
+ const name = basename(real);
188
+ const ext = extname(name).toLowerCase();
189
+ if (!allowedExtensions.has(ext)) {
190
+ if (refusedExtensions.has(ext)) return refuse(FILE_REFUSAL.TYPE_REFUSED, ext);
191
+ if (REFUSED_NAME_PATTERNS.some((re) => re.test(name))) return refuse(FILE_REFUSAL.TYPE_REFUSED, name);
192
+ }
193
+
194
+ // `lstat` above describes the link; what matters for "is this a file" is the target.
195
+ let target;
196
+ try {
197
+ target = await lstat(real);
198
+ } catch {
199
+ return refuse(FILE_REFUSAL.MISSING, relPath);
200
+ }
201
+ if (!target.isFile()) return refuse(FILE_REFUSAL.NOT_A_FILE, relPath);
202
+ if (target.size > maxBytes) {
203
+ return refuse(FILE_REFUSAL.TOO_LARGE, `${target.size} > ${maxBytes}`);
204
+ }
205
+
206
+ const data = await readFile(real);
207
+ audit('file/read', { name, bytes: data.length });
208
+ return { ok: true, path: real, name, bytes: data.length, data };
209
+ }
210
+
211
+ function refuse(reason, detail) {
212
+ audit('file/refused', { reason, detail: detail ?? null });
213
+ return { ok: false, reason, detail };
214
+ }
215
+
216
+ return { read, get projectRoot() { return projectRoot; }, maxBytes };
217
+ }
218
+
219
+ /**
220
+ * Send one file to one destination, and be honest about whether it worked.
221
+ *
222
+ * The `send` callback is the channel's own upload. This wrapper exists so that the
223
+ * **only** way to report success is for `send` to have resolved: a rejected send
224
+ * becomes `{ok: false}` here, and the caller has no way to count it as delivered. That
225
+ * is the whole fix for "the tool told the model it sent four files and sent two".
226
+ *
227
+ * @param {object} options
228
+ * @param {(file: {name: string, bytes: number, data: Buffer}, caption: string) => Promise<void>} options.send
229
+ * @param {(event: string, detail?: object) => void} [options.audit]
230
+ */
231
+ export function createFileSender(options) {
232
+ const send = options.send;
233
+ if (typeof send !== 'function') throw new TypeError('createFileSender requires a send()');
234
+ const audit = options.audit ?? (() => {});
235
+
236
+ /**
237
+ * @param {{name: string, bytes: number, data: Buffer}} file
238
+ * @param {string} [caption]
239
+ * @returns {Promise<{ok: true} | {ok: false, reason: string, detail?: string}>}
240
+ */
241
+ async function deliver(file, caption = '') {
242
+ try {
243
+ await send(file, caption);
244
+ audit('file/sent', { name: file.name, bytes: file.bytes });
245
+ return { ok: true };
246
+ } catch (error) {
247
+ // Named, not swallowed. A channel that logged this and returned would make the
248
+ // count in the caller's reply a lie.
249
+ const detail = error instanceof Error ? error.message : String(error);
250
+ audit('file/send-failed', { name: file.name, detail });
251
+ return { ok: false, reason: 'send_failed', detail };
252
+ }
253
+ }
254
+
255
+ return { deliver };
256
+ }
257
+
258
+ /**
259
+ * Fan one file out to several destinations and report the count truthfully.
260
+ *
261
+ * Returns the outcomes rather than a sentence, because the sentence 0.1.0 produced —
262
+ * "已发送到 N 个 IM 窗口" — was composed from the number of windows it *tried*, not the
263
+ * number that succeeded.
264
+ *
265
+ * @param {object} options
266
+ * @param {Array<{window: string, deliver: (file: object, caption: string) => Promise<{ok: boolean, [k: string]: unknown}>}>} options.targets
267
+ */
268
+ export async function fanOutFile({ targets, file, caption = '' }) {
269
+ const results = await Promise.all(
270
+ targets.map(async (target) => ({ window: target.window, ...(await target.deliver(file, caption)) })),
271
+ );
272
+ const sent = results.filter((r) => r.ok).length;
273
+ const failed = results.filter((r) => !r.ok);
274
+ return {
275
+ sent,
276
+ attempted: results.length,
277
+ failed: failed.map((r) => ({ window: r.window, reason: r.reason, detail: r.detail })),
278
+ // Exactly true, and the caller is expected to use this rather than `attempted`.
279
+ allSent: sent === results.length && results.length > 0,
280
+ results,
281
+ };
282
+ }
@@ -0,0 +1,164 @@
1
+ /**
2
+ * Joining a fleet by account, with no human copying a code (v0.8.1).
3
+ *
4
+ * ## The bug this fixes
5
+ *
6
+ * A new machine signed into the same DeepSeek account did **not** join the same fleet. The plugin
7
+ * computed its SN, derived `fleet` from the account code, logged it — and then never used it for
8
+ * anything. Membership still required a one-time pairing code typed by a human, so a second machine
9
+ * on the same account was invisible to the fleet.
10
+ *
11
+ * ## Why the account code could not simply be used as the credential
12
+ *
13
+ * `accountCode` is `sha256(userId)` truncated to **8 hex characters — 32 bits**. The module that
14
+ * defines it documents the trade honestly ("the cost of 32 bits, stated rather than hidden"), and
15
+ * for its purpose — grouping and display — the cost is fine.
16
+ *
17
+ * As a **credential** it is not fine: 2^32 is brute-forceable, so treating it as the thing that
18
+ * authorises joining would let anyone enumerate their way into any fleet. The bug was a
19
+ * conflation: a *grouping identifier* was doing duty as a *secret*.
20
+ *
21
+ * ## What actually works
22
+ *
23
+ * The machines of one account share something an outsider cannot obtain: the ability to **read
24
+ * `userId`** from the signed-in account. So the fleet secret is derived from it:
25
+ *
26
+ * ```
27
+ * fleetKey = HMAC-SHA256(userId, "w2m-fleet-v1") 256 bits, only same-account machines can compute
28
+ * fleetId = SHA-256(fleetKey) what the relay groups by
29
+ * ```
30
+ *
31
+ * ## Why the relay needs to know nothing about accounts
32
+ *
33
+ * This is the property that makes the design simple: **presenting the key *is* the proof.** The
34
+ * relay derives `fleetId` from the key it is given, so a device can only ever land in the fleet
35
+ * whose key it holds. There is no trust-on-first-use to get wrong, no operator configuration, no
36
+ * account lookup, and nothing to forge — a device that does not know the key simply forms a
37
+ * different, empty fleet rather than joining somebody else's.
38
+ *
39
+ * It also means the wire carries a **derived** value rather than `userId`, so a relay (or anyone
40
+ * reading its state) learns a fleet's identity without learning the account's.
41
+ */
42
+
43
+ import { createHmac, createHash, timingSafeEqual } from 'node:crypto';
44
+
45
+ /** The derivation label. Changing it changes every fleet id, so it is versioned. */
46
+ export const FLEET_KEY_LABEL = 'w2m-fleet-v1';
47
+
48
+ /** `userId` is a UUID; anything shorter is not one, and deriving from it would weaken the key. */
49
+ export const MIN_ACCOUNT_MATERIAL = 16;
50
+
51
+ /**
52
+ * Derive the fleet key for an account.
53
+ *
54
+ * @param {string} userId The raw account id from the signed-in account, **not** the account code.
55
+ * @returns {Buffer} 32 bytes.
56
+ */
57
+ export function deriveAccountFleetKey(userId) {
58
+ if (typeof userId !== 'string' || userId.trim() === '') {
59
+ throw new TypeError('deriveAccountFleetKey needs the account userId; the 32-bit account code is not a secret and will not do');
60
+ }
61
+ const material = userId.trim();
62
+ if (material.length < MIN_ACCOUNT_MATERIAL) {
63
+ /**
64
+ * Refused rather than accepted-and-weakened.
65
+ *
66
+ * A short value here is almost always the account *code* being passed by mistake, and deriving
67
+ * from 32 bits would produce a key that looks strong and is not — the failure would be silent,
68
+ * which is the worst kind for a credential.
69
+ */
70
+ throw new TypeError(
71
+ `the account material is ${material.length} characters, which is too short to derive a fleet key from. `
72
+ + 'This is usually the 8-hex account code rather than the account userId.',
73
+ );
74
+ }
75
+ return createHmac('sha256', material).update(FLEET_KEY_LABEL, 'utf8').digest();
76
+ }
77
+
78
+ /** The key as it travels: base64url, so it survives a header, a JSON string and a URL. */
79
+ export function encodeFleetKey(key) {
80
+ return Buffer.from(key).toString('base64url');
81
+ }
82
+
83
+ /** Parse a transported key back to bytes. Returns null rather than throwing, for a network path. */
84
+ export function decodeFleetKey(encoded) {
85
+ if (typeof encoded !== 'string' || encoded === '') return null;
86
+ try {
87
+ const buf = Buffer.from(encoded, 'base64url');
88
+ // A key is exactly 32 bytes; anything else is not one, and accepting a short buffer would
89
+ // silently weaken every comparison that follows.
90
+ return buf.length === 32 ? buf : null;
91
+ } catch {
92
+ return null;
93
+ }
94
+ }
95
+
96
+ /**
97
+ * The id the relay groups by.
98
+ *
99
+ * Derived from the key rather than sent alongside it, so the two can never disagree — and so a
100
+ * device cannot claim a fleet id it does not hold the key for.
101
+ */
102
+ export function fleetIdFromKey(key) {
103
+ if (!Buffer.isBuffer(key) || key.length !== 32) throw new TypeError('fleetIdFromKey needs a 32-byte key');
104
+ return createHash('sha256').update(key).digest('hex');
105
+ }
106
+
107
+ /**
108
+ * The relay-side check: does this key belong to the fleet it claims?
109
+ *
110
+ * Constant-time, because a comparison that leaks timing on a secret is a comparison that can be
111
+ * walked one byte at a time.
112
+ */
113
+ export function fleetKeyMatches(expectedKey, presentedKey) {
114
+ if (!Buffer.isBuffer(expectedKey) || !Buffer.isBuffer(presentedKey)) return false;
115
+ if (expectedKey.length !== presentedKey.length) return false;
116
+ return timingSafeEqual(expectedKey, presentedKey);
117
+ }
118
+
119
+ /**
120
+ * Whether a fleet key is well-formed, for a relay that is about to store one.
121
+ *
122
+ * Deliberately shape-only: the relay has no way to know whether a key corresponds to a real
123
+ * account, and pretending otherwise would be a check that cannot fail honestly. What it *can* check
124
+ * is that the key is the right size, which is what stops a truncated or empty value becoming a
125
+ * fleet's identity.
126
+ */
127
+ export function isWellFormedFleetKey(encoded) {
128
+ return decodeFleetKey(encoded) !== null;
129
+ }
130
+
131
+ /**
132
+ * The whole join decision, in one function, so the client and the relay cannot disagree.
133
+ *
134
+ * @param {object} input
135
+ * @param {string} input.userId The signed-in account id, when the caller is a client.
136
+ * @param {string} input.fleetKeyEncoded A key presented on the wire, when the caller is a relay.
137
+ * @returns {{ok: true, fleetId: string, fleetKey: string}
138
+ * | {ok: false, reason: string, text: string}}
139
+ */
140
+ export function planJoin({ userId = null, fleetKeyEncoded = null } = {}) {
141
+ if (typeof fleetKeyEncoded === 'string' && fleetKeyEncoded !== '') {
142
+ const key = decodeFleetKey(fleetKeyEncoded);
143
+ if (key === null) {
144
+ return { ok: false, reason: 'malformed_fleet_key', text: '这个舰队密钥格式不对(应为 32 字节的 base64url)。' };
145
+ }
146
+ return { ok: true, fleetId: fleetIdFromKey(key), fleetKey: encodeFleetKey(key) };
147
+ }
148
+
149
+ if (typeof userId === 'string' && userId.trim() !== '') {
150
+ let key;
151
+ try {
152
+ key = deriveAccountFleetKey(userId);
153
+ } catch (error) {
154
+ return { ok: false, reason: 'bad_account_material', text: error.message };
155
+ }
156
+ return { ok: true, fleetId: fleetIdFromKey(key), fleetKey: encodeFleetKey(key) };
157
+ }
158
+
159
+ return {
160
+ ok: false,
161
+ reason: 'no_identity',
162
+ text: '既没有已登录的账号,也没有舰队密钥,无法确定属于哪条舰队。',
163
+ };
164
+ }