@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,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,176 @@
1
+ /**
2
+ * Which devices a request may reach, and which fleet a pairing code admits to (v0.7.6).
3
+ *
4
+ * ## The defect this replaces, in one line
5
+ *
6
+ * `src/relay/state.mjs` L1259-1262:
7
+ *
8
+ * const all = [...this.devices.values()];
9
+ * const targets = Array.isArray(input.target_machines) && input.target_machines.length > 0
10
+ * ? all.filter((d) => input.target_machines.includes(d.machine_id))
11
+ * : all;
12
+ *
13
+ * `targets` is **every device on the relay**. No filter, because until now there was nothing to
14
+ * filter by: a device had a random `machine_id` and the `user_id` the relay stored was never read.
15
+ * So every account on a relay shares one pool, and any caller holding a valid operator token
16
+ * broadcasts to all of them. That is the finding this release fixes.
17
+ *
18
+ * ## Why the decision lives here rather than inline
19
+ *
20
+ * The relay is a large stateful object where a mistake is expensive to find. The rule "which
21
+ * devices may this request reach" is a **pure function of (requester's fleet, device's fleet,
22
+ * explicitly named targets)**, so it is expressed here, at that shape, and tested exhaustively.
23
+ * The relay calls it; it does not reimplement it.
24
+ *
25
+ * ## The three rules, and the reasoning that is not obvious
26
+ *
27
+ * **1. A request reaches only its own fleet.** Not "only its own fleet by default" — there is no
28
+ * parameter to widen it. A cross-fleet read is never legitimate, so it is not expressible.
29
+ *
30
+ * **2. An empty fleet matches nothing.** `fleet_id: undefined` on both sides must **not** compare
31
+ * equal. This is the failure that would silently undo everything: two ungrouped devices would
32
+ * match each other, which is exactly the "everything in one pool" behaviour being removed. So an
33
+ * absent fleet is a device that belongs to no fleet, and it is reachable by nobody.
34
+ *
35
+ * **3. A cross-fleet target is refused rather than ignored.** Silently dropping a named target
36
+ * leaves the caller believing it dispatched somewhere. The refusal **does not say whether the
37
+ * target exists** — a caller must not be able to probe another account's device ids.
38
+ */
39
+
40
+ /** Why a request reached nobody, or was refused outright. */
41
+ export const SCOPE_REFUSAL = Object.freeze({
42
+ /** The requester has no fleet, so it reaches no devices. */
43
+ NO_FLEET: 'no_fleet',
44
+ /** The fleet exists but holds no online device. */
45
+ NO_DEVICE_IN_FLEET: 'no_device_in_fleet',
46
+ /** A named target belongs to another fleet. Deliberately unspecific about which. */
47
+ TARGET_OUTSIDE_FLEET: 'target_outside_fleet',
48
+ /** The named target is not a device this relay holds at all. */
49
+ TARGET_UNKNOWN: 'target_unknown',
50
+ });
51
+
52
+ /**
53
+ * Does this device belong to this fleet?
54
+ *
55
+ * **Absent is not a wildcard.** Both sides must be a non-empty string and equal. A helper that
56
+ * treated `undefined === undefined` as a match would put every ungrouped device in one pool — the
57
+ * precise defect being removed — and it would pass every test that only ever sets a fleet.
58
+ */
59
+ export function inFleet(deviceFleet, requesterFleet) {
60
+ if (typeof deviceFleet !== 'string' || deviceFleet === '') return false;
61
+ if (typeof requesterFleet !== 'string' || requesterFleet === '') return false;
62
+ return deviceFleet === requesterFleet;
63
+ }
64
+
65
+ /**
66
+ * The devices a request may reach.
67
+ *
68
+ * @param {object} input
69
+ * @param {Array<{machine_id: string, fleet_id?: string, online?: boolean}>} input.devices
70
+ * @param {string|null} input.fleetId The requester's fleet, from its verified statement.
71
+ * @param {string[]} [input.targetMachines] Explicit targets, if any.
72
+ * @param {boolean} [input.requireOnline] Skip devices the relay believes are offline.
73
+ * @returns {{ok: true, targets: object[], skippedOffline: string[]}
74
+ * | {ok: false, reason: string, text: string}}
75
+ */
76
+ export function selectTargets({ devices, fleetId, targetMachines, requireOnline = true }) {
77
+ if (typeof fleetId !== 'string' || fleetId === '') {
78
+ // A requester with no fleet reaches nothing. Not "everything", and not an error the caller can
79
+ // retry differently — it needs an account.
80
+ return { ok: false, reason: SCOPE_REFUSAL.NO_FLEET, text: '这个请求没有舰队身份,所以它不能到达任何设备。' };
81
+ }
82
+
83
+ const mine = devices.filter((d) => inFleet(d.fleet_id, fleetId));
84
+
85
+ if (Array.isArray(targetMachines) && targetMachines.length > 0) {
86
+ const byId = new Map(mine.map((d) => [d.machine_id, d]));
87
+ const chosen = [];
88
+ for (const id of targetMachines) {
89
+ const device = byId.get(id);
90
+ if (device === undefined) {
91
+ // Two different worlds, deliberately reported as one answer. Saying "that machine exists
92
+ // but is not yours" would let a caller enumerate another account's fleet by trying ids.
93
+ return {
94
+ ok: false,
95
+ reason: SCOPE_REFUSAL.TARGET_OUTSIDE_FLEET,
96
+ text: '指定的目标不在这条舰队里。',
97
+ };
98
+ }
99
+ chosen.push(device);
100
+ }
101
+ return finish(chosen, requireOnline);
102
+ }
103
+
104
+ return finish(mine, requireOnline);
105
+ }
106
+
107
+ function finish(devices, requireOnline) {
108
+ const skippedOffline = [];
109
+ const targets = [];
110
+ for (const device of devices) {
111
+ if (requireOnline && device.online === false) {
112
+ // Counted, not dropped silently: "we tried and it was offline" and "we never considered it"
113
+ // produce the same empty result and different fixes.
114
+ skippedOffline.push(device.machine_id);
115
+ continue;
116
+ }
117
+ targets.push(device);
118
+ }
119
+ if (targets.length === 0) {
120
+ return {
121
+ ok: false,
122
+ reason: SCOPE_REFUSAL.NO_DEVICE_IN_FLEET,
123
+ text:
124
+ skippedOffline.length > 0
125
+ ? '这条舰队里的机器当前都不在线。'
126
+ : '这条舰队里没有机器。',
127
+ };
128
+ }
129
+ return { ok: true, targets, skippedOffline };
130
+ }
131
+
132
+ /**
133
+ * Which fleet a pairing code admits to.
134
+ *
135
+ * **The fleet comes from the code, never from the device.** This is the whole of the isolation
136
+ * argument: if a device could name its own fleet it would join any account, and every signature in
137
+ * `auth.mjs` would faithfully attest to a lie. A code with no fleet admits to no fleet, and the
138
+ * pairing is refused rather than defaulted.
139
+ *
140
+ * @param {object} input
141
+ * @param {{fleet_id?: string, used?: boolean, exp?: number}|null} input.code The pairing code record.
142
+ * @param {number} [input.now]
143
+ */
144
+ export function fleetFromPairingCode({ code, now = Date.now() }) {
145
+ if (code === null || code === undefined || typeof code !== 'object') {
146
+ return { ok: false, reason: 'unknown_code', text: '配对码无效。' };
147
+ }
148
+ if (code.used === true) return { ok: false, reason: 'code_used', text: '这个配对码已经被使用过了。' };
149
+ if (typeof code.exp === 'number' && Number.isFinite(code.exp) && now >= code.exp) {
150
+ return { ok: false, reason: 'code_expired', text: '配对码已过期。' };
151
+ }
152
+ if (typeof code.fleet_id !== 'string' || code.fleet_id === '') {
153
+ // A relay that issued a fleetless code would be handing out access to a pool with no owner.
154
+ return { ok: false, reason: 'code_has_no_fleet', text: '这个配对码没有绑定舰队,拒绝配对。' };
155
+ }
156
+ return { ok: true, fleetId: code.fleet_id };
157
+ }
158
+
159
+ /**
160
+ * The statement a relay issues for a device that has just paired.
161
+ *
162
+ * Kept here rather than inline in the relay so the fleet that goes into the signature is visibly
163
+ * the fleet that came from the code — one function, one place where that binding is made.
164
+ */
165
+ export function statementForDevice({ device, fleetId, sn, now = Date.now(), ttlMs }) {
166
+ return {
167
+ algorithm: 'ed25519',
168
+ // From the pairing code. Not from `device.fleet_id`, which would be the device's own claim.
169
+ fleet_id: fleetId,
170
+ machine_id: device.machineId,
171
+ sn,
172
+ public_key: device.publicKeyPem,
173
+ issued_at_ms: now,
174
+ expires_at_ms: now + (Number.isFinite(ttlMs) ? ttlMs : 90 * 24 * 60 * 60 * 1000),
175
+ };
176
+ }