@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.
- package/CHANGELOG.md +1176 -0
- package/HANDOFF.md +173 -0
- package/README.md +9 -2
- package/bin/w2m-localside.mjs +325 -308
- package/bin/w2m-rabbit.mjs +6 -0
- package/bin/w2m-stun.mjs +0 -0
- package/deploy/mac/mac-self-update.mjs +257 -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 +34 -22
- package/src/agent/agent.mjs +24 -2
- package/src/chatops/account.mjs +401 -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-join.mjs +164 -0
- package/src/chatops/fleet-scope.mjs +176 -0
- package/src/chatops/fleet-wiring.mjs +258 -0
- package/src/chatops/fleet.mjs +272 -0
- package/src/chatops/handoff.mjs +258 -0
- package/src/chatops/ilink-fleet-bridge.mjs +197 -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 +440 -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 +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
|
+
}
|