@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.
- package/CHANGELOG.md +1030 -0
- package/README.md +9 -2
- package/bin/w2m-localside.mjs +0 -0
- package/bin/w2m-rabbit.mjs +6 -0
- package/bin/w2m-stun.mjs +0 -0
- package/deploy/server/README.md +109 -0
- package/deploy/server/deploy-w2m.sh +161 -0
- package/deploy/server/w2m-localside.service +31 -0
- package/deploy/server/w2m-rabbit.service +26 -0
- package/deploy/server/w2m-stun.service +26 -0
- package/docs/ACCOUNT-FLEET-DESIGN.md +369 -0
- package/package.json +33 -22
- package/src/chatops/account.mjs +361 -0
- package/src/chatops/actions.mjs +227 -0
- package/src/chatops/approval-broker.mjs +320 -0
- package/src/chatops/auth.mjs +376 -0
- package/src/chatops/bridge.mjs +344 -0
- package/src/chatops/channel.mjs +192 -0
- package/src/chatops/commands.mjs +409 -0
- package/src/chatops/config-route.mjs +332 -0
- package/src/chatops/device-signing.mjs +218 -0
- package/src/chatops/durability.mjs +202 -0
- package/src/chatops/file-safety.mjs +282 -0
- package/src/chatops/fleet-scope.mjs +176 -0
- package/src/chatops/fleet-wiring.mjs +199 -0
- package/src/chatops/fleet.mjs +272 -0
- package/src/chatops/handoff.mjs +258 -0
- package/src/chatops/ilink-protocol.mjs +165 -0
- package/src/chatops/ilink-transport.mjs +168 -0
- package/src/chatops/ilink.mjs +423 -0
- package/src/chatops/index.mjs +429 -0
- package/src/chatops/outbound-queue.mjs +282 -0
- package/src/chatops/policy.mjs +356 -0
- package/src/chatops/run-command.mjs +308 -0
- package/src/plugin/tools.mjs +1 -1
- package/src/relay/server.mjs +96 -0
- 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
|
+
}
|