@twinsearth/w2m-dsh-plugin 0.8.0 → 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 +146 -0
- package/HANDOFF.md +173 -0
- package/bin/w2m-localside.mjs +325 -308
- package/deploy/mac/mac-self-update.mjs +257 -0
- package/package.json +4 -3
- package/src/agent/agent.mjs +24 -2
- package/src/chatops/account.mjs +400 -360
- package/src/chatops/fleet-join.mjs +164 -0
- package/src/chatops/fleet-wiring.mjs +63 -4
- package/src/chatops/ilink-fleet-bridge.mjs +197 -0
- package/src/chatops/index.mjs +440 -429
- package/src/relay/state.mjs +49 -3
|
@@ -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
|
+
}
|
|
@@ -34,6 +34,8 @@
|
|
|
34
34
|
*/
|
|
35
35
|
|
|
36
36
|
import { selectTargets, SCOPE_REFUSAL } from './fleet-scope.mjs';
|
|
37
|
+
// v0.8.1: the account-derived fleet key, so a machine can join its own fleet with no human step.
|
|
38
|
+
import { planJoin } from './fleet-join.mjs';
|
|
37
39
|
|
|
38
40
|
/** Why a request reached nothing, before target selection even runs. */
|
|
39
41
|
export const WIRING_REFUSAL = Object.freeze({
|
|
@@ -174,13 +176,70 @@ export function fleetForPairingCode(codeRecord) {
|
|
|
174
176
|
* Kept as a function so the "the fleet came from the code" rule lives in one place: a caller that
|
|
175
177
|
* builds a device literal by hand is the way a device ends up choosing its own fleet.
|
|
176
178
|
*/
|
|
177
|
-
|
|
179
|
+
/**
|
|
180
|
+
* The `fleet_id` a device joins, and **where that fact came from**.
|
|
181
|
+
*
|
|
182
|
+
* Three sources, in order of authority, and the order is the whole security argument:
|
|
183
|
+
*
|
|
184
|
+
* | source | who decided | why |
|
|
185
|
+
* |---|---|---|
|
|
186
|
+
* | `pairing_code` | the operator, by issuing the code | explicit intent, and the only source that can place a device in a fleet it could not compute |
|
|
187
|
+
* | `fleet_key` | the account, by derivation | the auto-join path: the device proves membership by holding the key |
|
|
188
|
+
* | `request` | the relay operator, via `--fleet` | a relay that serves one fleet and is told which |
|
|
189
|
+
*
|
|
190
|
+
* ## Why the key is *derived from* rather than *compared against*
|
|
191
|
+
*
|
|
192
|
+
* A device presenting a `fleet_key` is **assigned** the fleet id computed from that key —
|
|
193
|
+
* `SHA-256(key)`. The relay does not decide whether to believe a claimed fleet id; it computes one.
|
|
194
|
+
* So a device can only ever land in the fleet whose key it holds, there is no trust-on-first-use to
|
|
195
|
+
* get wrong, and nothing to forge: a device without the key forms a different, empty fleet rather
|
|
196
|
+
* than joining somebody else's.
|
|
197
|
+
*
|
|
198
|
+
* This is what lets a second machine on one account join with **no human step and no relay
|
|
199
|
+
* configuration** — the gap that let a new PC sit invisible next to its own fleet.
|
|
200
|
+
*
|
|
201
|
+
* @param {object} input
|
|
202
|
+
* @param {object|null} input.codeRecord The pairing code's record, if one was used.
|
|
203
|
+
* @param {string|null} [input.fleetKeyEncoded] The account-derived key, if the device offered one.
|
|
204
|
+
* @param {string|null} [input.requestedFleet] An explicit fleet from the relay's own configuration.
|
|
205
|
+
* @returns {{fleet_id: string|null, fleet_source: string|null, fleet_reason?: string}}
|
|
206
|
+
*/
|
|
207
|
+
export function deviceFleetFields({ codeRecord, fleetKeyEncoded = null, requestedFleet = null }) {
|
|
178
208
|
const fromCode = fleetForPairingCode(codeRecord);
|
|
179
|
-
if (fromCode !== null)
|
|
209
|
+
if (fromCode !== null) {
|
|
210
|
+
/**
|
|
211
|
+
* The provenance has to say *how* the fleet was established, and a first version did not: it
|
|
212
|
+
* reported `pairing_code` for a device that never presented one, because the relay builds a
|
|
213
|
+
* synthetic code record for the key path so the binding reads from a single place.
|
|
214
|
+
*
|
|
215
|
+
* That mattered. `fleet_source` is what an operator reads in `devices.json` to answer *"how did
|
|
216
|
+
* this machine get into my fleet?"* — and answering "a pairing code" when there was none sends
|
|
217
|
+
* them looking for a code that was never issued. Found by a live pairing test, not by reading.
|
|
218
|
+
*/
|
|
219
|
+
if (codeRecord?.from_fleet_key === true) return { fleet_id: fromCode, fleet_source: 'fleet_key' };
|
|
220
|
+
return { fleet_id: fromCode, fleet_source: 'pairing_code' };
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* The auto-join path. Tried before the operator's `--fleet` so that a device which knows its own
|
|
225
|
+
* account lands in its own fleet rather than being absorbed into whatever fleet the relay serves —
|
|
226
|
+
* a relay configured for one account must not silently become the wrong fleet for a device whose
|
|
227
|
+
* account says otherwise.
|
|
228
|
+
*/
|
|
229
|
+
if (typeof fleetKeyEncoded === 'string' && fleetKeyEncoded !== '') {
|
|
230
|
+
const join = planJoin({ fleetKeyEncoded });
|
|
231
|
+
if (join.ok) return { fleet_id: join.fleetId, fleet_source: 'fleet_key' };
|
|
232
|
+
/**
|
|
233
|
+
* A malformed key does **not** pair the device into the relay's own fleet. Falling through would
|
|
234
|
+
* be the silent wrong answer: a device that meant to join its account would instead be placed in
|
|
235
|
+
* an unrelated fleet and told nothing.
|
|
236
|
+
*/
|
|
237
|
+
return { fleet_id: null, fleet_source: null, fleet_reason: join.reason };
|
|
238
|
+
}
|
|
239
|
+
|
|
180
240
|
if (typeof requestedFleet === 'string' && requestedFleet !== '') {
|
|
181
|
-
// Reachable only when the relay has no fleet of its own and an operator passed one explicitly.
|
|
182
241
|
// Recorded with its provenance so a fleet that arrived from a request is visible as such in
|
|
183
|
-
// `devices.json` rather than looking identical to one that came from a code.
|
|
242
|
+
// `devices.json` rather than looking identical to one that came from a code or a key.
|
|
184
243
|
return { fleet_id: requestedFleet, fleet_source: 'request' };
|
|
185
244
|
}
|
|
186
245
|
return { fleet_id: null, fleet_source: null };
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The bridge: a WeChat message becomes a fleet task, and the verdict goes back (v0.7.12).
|
|
3
|
+
*
|
|
4
|
+
* ## Every field name here was measured, not guessed
|
|
5
|
+
*
|
|
6
|
+
* Four separate wrong guesses about this protocol made a working feature look broken — including a
|
|
7
|
+
* false conclusion that the login token lived only 30 seconds. So the wire shapes are recorded with
|
|
8
|
+
* where they came from, and a test asserts them rather than the documentation:
|
|
9
|
+
*
|
|
10
|
+
* | shape | measured value |
|
|
11
|
+
* |---|---|
|
|
12
|
+
* | poll response | `{ msgs, sync_buf, get_updates_buf }` — the cursor is `get_updates_buf`, **not** `buf` |
|
|
13
|
+
* | message text | `item_list[0].text_item.text` — **not** `content` |
|
|
14
|
+
* | sender | `from_user_id` |
|
|
15
|
+
* | reply success | `{ message_id }` with **no** `ret` and **no** `errcode` |
|
|
16
|
+
* | login confirmation | status `confirmed`, **not** `scaned` |
|
|
17
|
+
*
|
|
18
|
+
* ## What this module does not do
|
|
19
|
+
*
|
|
20
|
+
* It does not decide authorisation. `parseInvocation` reports which **capability** a form needs, and
|
|
21
|
+
* the caller supplies an `authorize` function — because the rule "authorisation is checked before
|
|
22
|
+
* the handler runs, never inside it" is what stops a handler becoming reachable by forgetting its
|
|
23
|
+
* own guard, and a bridge that checked its own permission would be exactly that mistake.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import { parseInvocation, renderVerdict, renderDispatchFailure } from './run-command.mjs';
|
|
27
|
+
|
|
28
|
+
/** The header every authenticated call needs; undocumented and mandatory. */
|
|
29
|
+
export const AUTH_TYPE = 'ilink_bot_token';
|
|
30
|
+
export const APP_ID = 'bot';
|
|
31
|
+
/** 2.4.6, as the reference client sends. */
|
|
32
|
+
export const CLIENT_VERSION = String((2 << 16) | (4 << 8) | 6);
|
|
33
|
+
|
|
34
|
+
/** Message item types. Only text is dispatched; the rest are acknowledged and ignored. */
|
|
35
|
+
export const ITEM_TYPE = Object.freeze({ TEXT: 1 });
|
|
36
|
+
/** `2` is BOT-originated, which is what our replies are. */
|
|
37
|
+
export const MESSAGE_TYPE = Object.freeze({ USER: 1, BOT: 2 });
|
|
38
|
+
/** `2` is FINISH — a complete message rather than a streaming fragment. */
|
|
39
|
+
export const MESSAGE_STATE_FINISH = 2;
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Build the request headers.
|
|
43
|
+
*
|
|
44
|
+
* `X-WECHAT-UIN` is a fresh random uint32 as a decimal string, base64-encoded, on **every** request.
|
|
45
|
+
* It is not a session id and must not be cached.
|
|
46
|
+
*
|
|
47
|
+
* @param {object} input
|
|
48
|
+
* @param {string} [input.token]
|
|
49
|
+
* @param {() => string} [input.makeUin]
|
|
50
|
+
*/
|
|
51
|
+
export function buildHeaders({ token, makeUin }) {
|
|
52
|
+
const headers = {
|
|
53
|
+
'content-type': 'application/json',
|
|
54
|
+
'iLink-App-Id': APP_ID,
|
|
55
|
+
'iLink-App-ClientVersion': CLIENT_VERSION,
|
|
56
|
+
AuthorizationType: AUTH_TYPE,
|
|
57
|
+
};
|
|
58
|
+
if (typeof makeUin === 'function') headers['X-WECHAT-UIN'] = makeUin();
|
|
59
|
+
if (typeof token === 'string' && token !== '') headers.authorization = `Bearer ${token}`;
|
|
60
|
+
return headers;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** The text of a message, or null when it carries none. */
|
|
64
|
+
export function textOf(message) {
|
|
65
|
+
const items = Array.isArray(message?.item_list) ? message.item_list : [];
|
|
66
|
+
for (const item of items) {
|
|
67
|
+
if (item?.type === ITEM_TYPE.TEXT) {
|
|
68
|
+
const text = item?.text_item?.text;
|
|
69
|
+
if (typeof text === 'string') return text;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
return null;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* The reply envelope.
|
|
77
|
+
*
|
|
78
|
+
* `context_token` is **mandatory and comes from the inbound message**: without it the reply is not
|
|
79
|
+
* associated with the conversation. `message_state: 2` is FINISH — a first version omitted it and
|
|
80
|
+
* the endpoint answered `ret: -2, invalid arguments` for eight different payload shapes before the
|
|
81
|
+
* documented envelope was found.
|
|
82
|
+
*/
|
|
83
|
+
export function buildReply({ toUserId, contextToken, text }) {
|
|
84
|
+
return {
|
|
85
|
+
msg: {
|
|
86
|
+
to_user_id: toUserId,
|
|
87
|
+
message_type: MESSAGE_TYPE.BOT,
|
|
88
|
+
message_state: MESSAGE_STATE_FINISH,
|
|
89
|
+
context_token: contextToken,
|
|
90
|
+
item_list: [{ type: ITEM_TYPE.TEXT, text_item: { text } }],
|
|
91
|
+
},
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Whether a send succeeded. Success carries a `message_id` and **no** `ret`. */
|
|
96
|
+
export function sendSucceeded(response) {
|
|
97
|
+
if (response === null || typeof response !== 'object') return false;
|
|
98
|
+
if (typeof response.message_id !== 'undefined' && response.message_id !== null) return true;
|
|
99
|
+
return response.ret === 0 || response.errcode === 0;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Decide what to do with one inbound message.
|
|
104
|
+
*
|
|
105
|
+
* Returns a **plan** rather than performing it, so the decision is testable without a relay, a
|
|
106
|
+
* socket, or a clock — and so the caller can see authorisation checked in one place.
|
|
107
|
+
*
|
|
108
|
+
* @param {object} input
|
|
109
|
+
* @param {object} input.message One `msgs[]` entry.
|
|
110
|
+
* @param {(capability: string) => boolean} input.authorize
|
|
111
|
+
* @returns {{act: 'ignore', why: string}
|
|
112
|
+
* | {act: 'reply', to: string, contextToken: string, text: string}
|
|
113
|
+
* | {act: 'dispatch', to: string, contextToken: string, argv: string[], flags: object, ack: string}}
|
|
114
|
+
*/
|
|
115
|
+
export function planMessage({ message, authorize }) {
|
|
116
|
+
const from = message?.from_user_id;
|
|
117
|
+
const contextToken = message?.context_token;
|
|
118
|
+
const text = textOf(message);
|
|
119
|
+
|
|
120
|
+
if (typeof from !== 'string' || from === '') return { act: 'ignore', why: 'no sender' };
|
|
121
|
+
if (typeof contextToken !== 'string' || contextToken === '') return { act: 'ignore', why: 'no context_token' };
|
|
122
|
+
if (text === null) return { act: 'ignore', why: 'not a text message' };
|
|
123
|
+
|
|
124
|
+
const trimmed = text.trim();
|
|
125
|
+
// Only the exact `/run` prefix is an action. Everything else is conversation, which is what keeps
|
|
126
|
+
// a typo from becoming a fleet execution.
|
|
127
|
+
if (!/^\/run(\s|$)/i.test(trimmed)) return { act: 'ignore', why: 'not a /run line' };
|
|
128
|
+
|
|
129
|
+
const body = trimmed.replace(/^\/run\s*/i, '');
|
|
130
|
+
const invocation = parseInvocation(body);
|
|
131
|
+
|
|
132
|
+
if (invocation.kind === 'refused') {
|
|
133
|
+
return { act: 'reply', to: from, contextToken, text: invocation.text };
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const capability = invocation.capability;
|
|
137
|
+
if (typeof authorize === 'function' && authorize(capability) !== true) {
|
|
138
|
+
return {
|
|
139
|
+
act: 'reply',
|
|
140
|
+
to: from,
|
|
141
|
+
contextToken,
|
|
142
|
+
text: `这个窗口没有 ${capability} 权限,不能执行。`,
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
if (invocation.kind === 'ask') {
|
|
147
|
+
/**
|
|
148
|
+
* The beginner form is answered, not executed.
|
|
149
|
+
*
|
|
150
|
+
* It does **not** reach the fleet from here: the request is a sentence, and turning a sentence
|
|
151
|
+
* into a command inside the bridge would reintroduce exactly the inference `/run --ask` exists
|
|
152
|
+
* to avoid. The reply says what to send instead, so the low-privilege path stays a path to a
|
|
153
|
+
* *proposal* rather than to an execution.
|
|
154
|
+
*/
|
|
155
|
+
return {
|
|
156
|
+
act: 'reply',
|
|
157
|
+
to: from,
|
|
158
|
+
contextToken,
|
|
159
|
+
text:
|
|
160
|
+
`收到你的请求:${invocation.text}\n\n`
|
|
161
|
+
+ '这是低权限的「提问」模式,我不会直接执行。\n'
|
|
162
|
+
+ '如果要真的运行,请把确切的命令发给我,例如:\n'
|
|
163
|
+
+ ` /run ${invocation.text.split(/\s+/).slice(0, 3).join(' ')}`,
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
return {
|
|
168
|
+
act: 'dispatch',
|
|
169
|
+
to: from,
|
|
170
|
+
contextToken,
|
|
171
|
+
argv: invocation.argv,
|
|
172
|
+
flags: invocation.flags,
|
|
173
|
+
// Acknowledged before dispatch: a fleet run can take a minute, and silence reads as "broken".
|
|
174
|
+
ack: `已派发:${invocation.argv.join(' ')}\n模式:${invocation.flags.mode}\n等我回报结果…`,
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Render a completed task for the chat window.
|
|
180
|
+
*
|
|
181
|
+
* Reuses `renderVerdict`, which already caps the machine list and states the truncation — a list
|
|
182
|
+
* that silently stops at ten reads as "ten machines" rather than "ten shown".
|
|
183
|
+
*/
|
|
184
|
+
export function renderTaskOutcome({ taskId, body }) {
|
|
185
|
+
const verdict = body?.aggregate?.status ?? body?.status ?? body?.verdict ?? 'unknown';
|
|
186
|
+
const rows = body?.machines ?? body?.leases ?? body?.results ?? [];
|
|
187
|
+
return `${renderVerdict({ taskId, verdict, machines: rows }).text}`;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** Render a dispatch that never became a task. */
|
|
191
|
+
export function renderFailure(error) {
|
|
192
|
+
return renderDispatchFailure({
|
|
193
|
+
code: error?.code ?? error?.error?.code,
|
|
194
|
+
reason: error?.detail?.reason ?? error?.error?.detail?.reason,
|
|
195
|
+
detail: error?.detail?.hint ?? error?.error?.detail?.hint ?? error?.message,
|
|
196
|
+
});
|
|
197
|
+
}
|