@twinsearth/w2m-dsh-plugin 0.8.0 → 0.8.3
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 +207 -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/plugin/tools.mjs +4691 -4622
- package/src/relay/state.mjs +49 -3
package/src/chatops/index.mjs
CHANGED
|
@@ -1,429 +1,440 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Mounting the chat bridge into a DSH profile (v0.7.0).
|
|
3
|
-
*
|
|
4
|
-
* ## What this is for
|
|
5
|
-
*
|
|
6
|
-
* The seven modules before this one carry the safety properties. This is the seam that makes
|
|
7
|
-
* them a plugin: it reads the row's config, builds the bridge, hands the host a listener for
|
|
8
|
-
* `approval/request`, and owns its own lifetime so a reload disposes it.
|
|
9
|
-
*
|
|
10
|
-
* ## The two things it deliberately does not do
|
|
11
|
-
*
|
|
12
|
-
* **It does not install a channel for you.** There is no default channel and no `ilink` or
|
|
13
|
-
* `feishu` adapter in this release, so a row with no channel enabled mounts a bridge that
|
|
14
|
-
* refuses everything and says so in its log. That is a real state and it is reported as one
|
|
15
|
-
* rather than papered over with a stub that would look like a working bot. Registering the
|
|
16
|
-
* in-memory channel is supported and is how the integration test drives the real path.
|
|
17
|
-
*
|
|
18
|
-
* **It does not adopt an owner.** `policy.mjs` has no first-contact path at all, so a fresh
|
|
19
|
-
* install is inert until the operator seeds `security.allowContacts` or redeems a pairing
|
|
20
|
-
* code printed by `pair()`. That is the fix for the finding this work started from, and it
|
|
21
|
-
* means a misconfigured install is silent rather than hijackable.
|
|
22
|
-
*
|
|
23
|
-
* ## Why `ctx.effect` and not a bare start
|
|
24
|
-
*
|
|
25
|
-
* A bridge holds timers — the approval deadline, every lane's throttle — and a map of pending
|
|
26
|
-
* approvals. `dsh-chatops` 0.1.0 owned none of them with a disposer, so a reload left timers
|
|
27
|
-
* firing at promises belonging to a fiber that no longer existed. Everything this module
|
|
28
|
-
* starts is registered as a reversible effect, and when the host provides no `ctx.effect` the
|
|
29
|
-
* gap is named rather than ignored.
|
|
30
|
-
*/
|
|
31
|
-
|
|
32
|
-
import { createBridge } from './bridge.mjs';
|
|
33
|
-
import { createPolicy } from './policy.mjs';
|
|
34
|
-
import { createConfigRoute } from './config-route.mjs';
|
|
35
|
-
import { createMemoryChannel, validateChannel, CHANNEL_STATE } from './channel.mjs';
|
|
36
|
-
import { createILinkChannel } from './ilink.mjs';
|
|
37
|
-
import { isAllowedApiUrl } from './ilink-protocol.mjs';
|
|
38
|
-
import { createFleetExecutor } from './fleet.mjs';
|
|
39
|
-
import { createChatopsActions } from './actions.mjs';
|
|
40
|
-
import { createAccountIdentity, parseSn, fleetOf, SN_STATE_KEY } from './account.mjs';
|
|
41
|
-
import { helpText, commandTable } from './commands.mjs';
|
|
42
|
-
import { readFileSync, writeFileSync, mkdirSync } from 'node:fs';
|
|
43
|
-
import { join, dirname } from 'node:path';
|
|
44
|
-
|
|
45
|
-
/**
|
|
46
|
-
* Load a stored serial number, or null.
|
|
47
|
-
*
|
|
48
|
-
* **Storage is deliberately not the config row.** A row is editable by whoever can edit the
|
|
49
|
-
* profile, and a serial number that a person can retype is a serial number that can be made to
|
|
50
|
-
* claim someone else's fleet. The state directory is where the machine's own identity already
|
|
51
|
-
* lives, so that is where this goes.
|
|
52
|
-
*
|
|
53
|
-
* A malformed stored SN is returned as-is rather than being ignored: `account.mjs` refuses it by
|
|
54
|
-
* name, and silently minting a replacement would hide a corrupted state file behind a machine
|
|
55
|
-
* that merely looks new.
|
|
56
|
-
*/
|
|
57
|
-
function loadStoredSn(path) {
|
|
58
|
-
if (path === null) return null;
|
|
59
|
-
try {
|
|
60
|
-
const raw = JSON.parse(readFileSync(path, 'utf8'));
|
|
61
|
-
const value = raw?.[SN_STATE_KEY];
|
|
62
|
-
return typeof value === 'string' && value !== '' ? value : null;
|
|
63
|
-
} catch {
|
|
64
|
-
// Absent or unreadable is the normal first-run path, not an error worth reporting twice.
|
|
65
|
-
return null;
|
|
66
|
-
}
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
/** Persist an SN at 0600, so the machine's identity is not world-readable. */
|
|
70
|
-
function storeSn(path, sn) {
|
|
71
|
-
if (path === null) return false;
|
|
72
|
-
try {
|
|
73
|
-
mkdirSync(dirname(path), { recursive: true });
|
|
74
|
-
writeFileSync(path, `${JSON.stringify({ [SN_STATE_KEY]: sn, schema_version: 1 }, null, 2)}\n`, { mode: 0o600 });
|
|
75
|
-
return true;
|
|
76
|
-
} catch {
|
|
77
|
-
return false;
|
|
78
|
-
}
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
/**
|
|
82
|
-
* A fleet that refuses everything, for an install with no relay configured.
|
|
83
|
-
*
|
|
84
|
-
* Named refusals rather than an absent action, because `/machines` on a phone must say *why* it
|
|
85
|
-
* has nothing — "no rabbitUrl is set" is actionable and "this install has no fleet" is not.
|
|
86
|
-
*/
|
|
87
|
-
function unreachableFleet() {
|
|
88
|
-
const refuse = () => ({ ok: false, reason: 'no_relay', text: '这个安装没有配置 rabbitUrl,无法访问编队。' });
|
|
89
|
-
return {
|
|
90
|
-
run: async () => refuse(),
|
|
91
|
-
wait: async () => refuse(),
|
|
92
|
-
status: async () => refuse(),
|
|
93
|
-
report: async () => refuse(),
|
|
94
|
-
devices: async () => refuse(),
|
|
95
|
-
};
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
/** Cordis service this plugin needs: none. The bridge is self-contained by construction. */
|
|
99
|
-
export const inject = [];
|
|
100
|
-
|
|
101
|
-
/** The config row's defaults, matching `cordis.patch.yml`. */
|
|
102
|
-
const DEFAULTS = Object.freeze({
|
|
103
|
-
channels: [],
|
|
104
|
-
channel: null,
|
|
105
|
-
security: Object.freeze({ allowContacts: [], allowRooms: [], listenFilehelper: true, listenSelf: true }),
|
|
106
|
-
push: Object.freeze({ onSessionComplete: true, onApproval: true, approvalTimeoutSec: 300 }),
|
|
107
|
-
reply: Object.freeze({ maxChunkBytes: 6000, rateLimitMs: 1200, maxFileMB: 100 }),
|
|
108
|
-
storagePath: '',
|
|
109
|
-
projectRoot: '',
|
|
110
|
-
stateDir: '',
|
|
111
|
-
operatorToken: '',
|
|
112
|
-
enableMemoryChannel: false,
|
|
113
|
-
/** The fleet. `rabbitUrl` is read from the same row the tools use, so there is one setting. */
|
|
114
|
-
rabbitUrl: '',
|
|
115
|
-
/** How long `/run` holds a chat reply before answering "still running". */
|
|
116
|
-
fleetWaitMs: 90_000,
|
|
117
|
-
/** Whether `/run` waits at all. A dispatch-only install answers with the task id instead. */
|
|
118
|
-
fleetAutoWait: true,
|
|
119
|
-
});
|
|
120
|
-
|
|
121
|
-
/** Read the row into a shape the modules take, with every default stated once. */
|
|
122
|
-
export function readChatopsConfig(config = {}) {
|
|
123
|
-
const security = { ...DEFAULTS.security, ...(config.security ?? {}) };
|
|
124
|
-
const push = { ...DEFAULTS.push, ...(config.push ?? {}) };
|
|
125
|
-
const reply = { ...DEFAULTS.reply, ...(config.reply ?? {}) };
|
|
126
|
-
const channels = Array.isArray(config.channels) && config.channels.length > 0
|
|
127
|
-
? config.channels
|
|
128
|
-
: config.channel
|
|
129
|
-
? [config.channel]
|
|
130
|
-
: [...DEFAULTS.channels];
|
|
131
|
-
const envToken = typeof process.env.W2M_OPERATOR_TOKEN === 'string' ? process.env.W2M_OPERATOR_TOKEN.trim() : '';
|
|
132
|
-
return {
|
|
133
|
-
channels,
|
|
134
|
-
security,
|
|
135
|
-
push,
|
|
136
|
-
reply,
|
|
137
|
-
storagePath: typeof config.storagePath === 'string' ? config.storagePath : DEFAULTS.storagePath,
|
|
138
|
-
projectRoot: typeof config.projectRoot === 'string' ? config.projectRoot : DEFAULTS.projectRoot,
|
|
139
|
-
// Where the machine's own identity lives. Read from the same row the agent uses, so there is
|
|
140
|
-
// one setting rather than two — a second state directory would be a second identity.
|
|
141
|
-
stateDir: typeof config.stateDir === 'string' && config.stateDir.trim() !== '' ? config.stateDir.trim() : DEFAULTS.stateDir,
|
|
142
|
-
// Resolution order matches the tools': the row, then the environment, then nothing. A
|
|
143
|
-
// different order here would mean the chat layer and the tools disagree about which token
|
|
144
|
-
// they hold, and only one of them would be refused.
|
|
145
|
-
operatorToken:
|
|
146
|
-
typeof config.operatorToken === 'string' && config.operatorToken.trim() !== ''
|
|
147
|
-
? config.operatorToken.trim()
|
|
148
|
-
: envToken !== ''
|
|
149
|
-
? envToken
|
|
150
|
-
: DEFAULTS.operatorToken,
|
|
151
|
-
rabbitUrl:
|
|
152
|
-
typeof config.rabbitUrl === 'string' && config.rabbitUrl.trim() !== ''
|
|
153
|
-
? config.rabbitUrl.trim()
|
|
154
|
-
: typeof process.env.W2M_RABBIT_URL === 'string' && process.env.W2M_RABBIT_URL.trim() !== ''
|
|
155
|
-
? process.env.W2M_RABBIT_URL.trim()
|
|
156
|
-
: DEFAULTS.rabbitUrl,
|
|
157
|
-
fleetWaitMs: Number.isInteger(config.fleetWaitMs) ? config.fleetWaitMs : DEFAULTS.fleetWaitMs,
|
|
158
|
-
fleetAutoWait: config.fleetAutoWait !== false,
|
|
159
|
-
enableMemoryChannel: config.enableMemoryChannel === true,
|
|
160
|
-
};
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
/**
|
|
164
|
-
* Report what a row would do, without starting anything.
|
|
165
|
-
*
|
|
166
|
-
* Exposed because "why is my bot silent" should be answerable from a config dump rather than
|
|
167
|
-
* by reading logs: an install with no channel enabled and no owner seeded is inert by design,
|
|
168
|
-
* and that is worth saying out loud in a status line.
|
|
169
|
-
*/
|
|
170
|
-
export function describeChatopsConfig(config = {}) {
|
|
171
|
-
const cfg = readChatopsConfig(config);
|
|
172
|
-
const notes = [];
|
|
173
|
-
if (cfg.channels.length === 0) {
|
|
174
|
-
notes.push('no channel is enabled, so nothing will be received');
|
|
175
|
-
}
|
|
176
|
-
const owners = [...cfg.security.allowContacts];
|
|
177
|
-
if (owners.length === 0) {
|
|
178
|
-
notes.push(
|
|
179
|
-
'no identity is seeded in security.allowContacts, so the bridge will refuse every message ' +
|
|
180
|
-
'until an owner is established — there is no first-contact adoption',
|
|
181
|
-
);
|
|
182
|
-
}
|
|
183
|
-
if (cfg.operatorToken === '') notes.push('no operatorToken, so the settings route will be disabled');
|
|
184
|
-
return {
|
|
185
|
-
channels: cfg.channels,
|
|
186
|
-
owners,
|
|
187
|
-
rooms: [...cfg.security.allowRooms],
|
|
188
|
-
approvalTimeoutSec: cfg.push.approvalTimeoutSec,
|
|
189
|
-
notes,
|
|
190
|
-
commands: commandTable().map((c) => c.usage),
|
|
191
|
-
};
|
|
192
|
-
}
|
|
193
|
-
|
|
194
|
-
/**
|
|
195
|
-
* Register an in-memory channel under a name.
|
|
196
|
-
*
|
|
197
|
-
* The supported path for a dry run and for tests. It is checked by the same validator a real
|
|
198
|
-
* adapter is, so it cannot drift from the contract.
|
|
199
|
-
*
|
|
200
|
-
* @param {object} options
|
|
201
|
-
* @param {string} options.name
|
|
202
|
-
* @param {string[]} options.prefixes
|
|
203
|
-
*/
|
|
204
|
-
export function defineChannel(options) {
|
|
205
|
-
const verdict = validateChannel(options);
|
|
206
|
-
if (!verdict.ok) throw new TypeError(`defineChannel: ${verdict.reason}`);
|
|
207
|
-
return options;
|
|
208
|
-
}
|
|
209
|
-
|
|
210
|
-
/**
|
|
211
|
-
* Cordis entry point.
|
|
212
|
-
*
|
|
213
|
-
* @param {object} ctx
|
|
214
|
-
* @param {object} [config] The row's config.
|
|
215
|
-
*/
|
|
216
|
-
export async function apply(ctx, config = {}) {
|
|
217
|
-
const cfg = readChatopsConfig(config);
|
|
218
|
-
const log = {
|
|
219
|
-
info: (m) => ctx?.logger?.info?.(`chatops: ${m}`),
|
|
220
|
-
warn: (m) => ctx?.logger?.warn?.(`chatops: ${m}`),
|
|
221
|
-
};
|
|
222
|
-
|
|
223
|
-
const described = describeChatopsConfig(config);
|
|
224
|
-
for (const note of described.notes) log.warn(note);
|
|
225
|
-
if (cfg.channels.length === 0) {
|
|
226
|
-
// Named, not silent. A bridge with no channel is a configuration, and a row that looks
|
|
227
|
-
// enabled while receiving nothing is the failure mode this message exists to prevent.
|
|
228
|
-
log.warn('no channel adapter is available in this release; the bridge mounts inert');
|
|
229
|
-
}
|
|
230
|
-
|
|
231
|
-
const channels = [];
|
|
232
|
-
if (cfg.enableMemoryChannel) {
|
|
233
|
-
channels.push(
|
|
234
|
-
createMemoryChannel({
|
|
235
|
-
name: 'memory',
|
|
236
|
-
prefixes: ['mem'],
|
|
237
|
-
onOutbound: (record) => log.info(`-> ${record.window}: ${String(record.text).slice(0, 80)}`),
|
|
238
|
-
}),
|
|
239
|
-
);
|
|
240
|
-
}
|
|
241
|
-
|
|
242
|
-
// The WeChat adapter, mounted when its row asks for it.
|
|
243
|
-
if (cfg.channels.includes('ilink')) {
|
|
244
|
-
const base = ctx?.chatopsIlinkBase ?? undefined;
|
|
245
|
-
if (base !== undefined && !isAllowedApiUrl(base).ok) {
|
|
246
|
-
// Refused before a token can go anywhere. Named rather than attempted.
|
|
247
|
-
log.warn(`the ilink base url was refused: ${isAllowedApiUrl(base).reason}`);
|
|
248
|
-
} else {
|
|
249
|
-
const token = ctx?.chatopsIlinkToken ?? null;
|
|
250
|
-
channels.push(
|
|
251
|
-
createILinkChannel({
|
|
252
|
-
// The host *may* supply a transport. In v0.7.0 that was the only option, which meant
|
|
253
|
-
// the channel could never send a request at all, because nothing shipped one. The
|
|
254
|
-
// adapter now builds a real HTTP transport itself and this only overrides it.
|
|
255
|
-
transport: typeof ctx?.chatopsIlinkRequest === 'function' ? ctx.chatopsIlinkRequest : undefined,
|
|
256
|
-
token,
|
|
257
|
-
apiBase: base,
|
|
258
|
-
audit: (event, detail) => ctx?.logger?.debug?.(`chatops ${event} ${JSON.stringify(detail ?? {})}`),
|
|
259
|
-
}),
|
|
260
|
-
);
|
|
261
|
-
if (token === null || token === '') {
|
|
262
|
-
// Neither an error nor silent: an install that has not scanned a QR yet is a normal
|
|
263
|
-
// state, and saying so is what stops "the bot is broken" being the only conclusion
|
|
264
|
-
// available to an operator.
|
|
265
|
-
log.warn('the ilink channel is mounted with no bot token, so it will not poll until one is adopted');
|
|
266
|
-
}
|
|
267
|
-
}
|
|
268
|
-
}
|
|
269
|
-
|
|
270
|
-
// The bridge refuses to be built with no channel, which is right for the data path and
|
|
271
|
-
// wrong for a configuration report. So an inert mount registers one that accepts nothing
|
|
272
|
-
// and says so, rather than failing the whole profile on a row that is merely disabled.
|
|
273
|
-
if (channels.length === 0) {
|
|
274
|
-
if (cfg.channels.length > 0) {
|
|
275
|
-
// Configured to receive, and cannot. This is the state worth being loudest about: the
|
|
276
|
-
// row says the bot is on, so a silent bridge would read as a platform problem.
|
|
277
|
-
log.warn(`channels ${cfg.channels.join(', ')} were requested but none could be mounted`);
|
|
278
|
-
}
|
|
279
|
-
const inert = createMemoryChannel({ name: 'memory', prefixes: ['mem'] });
|
|
280
|
-
channels.push(inert);
|
|
281
|
-
log.warn('mounted with an inert channel so the profile loads; enable a channel to receive anything');
|
|
282
|
-
}
|
|
283
|
-
|
|
284
|
-
// The policy is built here rather than inside `createBridge`, so the action set can share the
|
|
285
|
-
// same one. Two policies would mean two answers to "who is trusted", and `/trust` would write
|
|
286
|
-
// to the copy that admission does not read.
|
|
287
|
-
const policy = createPolicy({
|
|
288
|
-
trustedContacts: cfg.security.allowContacts,
|
|
289
|
-
allowedRooms: cfg.security.allowRooms,
|
|
290
|
-
audit: (event, detail) => ctx?.logger?.debug?.(`chatops ${event} ${JSON.stringify(detail ?? {})}`),
|
|
291
|
-
});
|
|
292
|
-
|
|
293
|
-
/**
|
|
294
|
-
* This machine's identity: which account it belongs to, and its serial number.
|
|
295
|
-
*
|
|
296
|
-
* Resolved before the channel work so a failure is reported once and early. A failure here is
|
|
297
|
-
* **not fatal to the plugin**: a machine with no signed-in account still runs the bridge, it
|
|
298
|
-
* just has no fleet — and the design says it must not be handed a placeholder one, because that
|
|
299
|
-
* would put two unrelated machines in the same pool.
|
|
300
|
-
*/
|
|
301
|
-
const snPath = cfg.stateDir === '' ? null : join(cfg.stateDir, 'chatops-identity.json');
|
|
302
|
-
let identity = { ok: false, reason: 'not_resolved' };
|
|
303
|
-
try {
|
|
304
|
-
identity = await createAccountIdentity({
|
|
305
|
-
ctx,
|
|
306
|
-
stored: loadStoredSn(snPath),
|
|
307
|
-
platform: process.platform,
|
|
308
|
-
audit: (event, detail) => ctx?.logger?.debug?.(`chatops ${event} ${JSON.stringify(detail ?? {})}`),
|
|
309
|
-
}).resolve();
|
|
310
|
-
} catch (error) {
|
|
311
|
-
identity = { ok: false, reason: 'identity_threw', detail: error instanceof Error ? error.message : String(error) };
|
|
312
|
-
}
|
|
313
|
-
|
|
314
|
-
if (identity.ok) {
|
|
315
|
-
// Written once, on creation. A stored SN is never rewritten, so a restart cannot change the
|
|
316
|
-
// machine's identity — every restart looking like a new device is how a fleet fills with
|
|
317
|
-
// ghosts of one machine.
|
|
318
|
-
if (identity.reused === false && storeSn(snPath, identity.sn)) {
|
|
319
|
-
log.info(`identity ${identity.sn} (fleet ${identity.accountCode}, from ${identity.hardwareSources?.join('+') ?? 'unknown'})`);
|
|
320
|
-
} else if (identity.reused) {
|
|
321
|
-
log.info(`identity ${identity.sn} (reused; fleet ${identity.accountCode})`);
|
|
322
|
-
}
|
|
323
|
-
} else {
|
|
324
|
-
// Named, and at warn level, because "why is this machine in no fleet" is otherwise unanswerable
|
|
325
|
-
// from the outside. `signed-out` is the common case and is not an error.
|
|
326
|
-
log.warn(
|
|
327
|
-
identity.reason === 'signed-out'
|
|
328
|
-
? 'no DeepSeek account is signed in, so this machine has no fleet; it will not be grouped with any other'
|
|
329
|
-
: `no serial number was produced (${identity.reason}${identity.detail ? `: ${identity.detail}` : ''})`,
|
|
330
|
-
);
|
|
331
|
-
}
|
|
332
|
-
|
|
333
|
-
// The fleet half of the action set. This is what makes `/run` mean something: without it the
|
|
334
|
-
// command could only ever answer "this install has no fleet", which is exactly what v0.7.1 and
|
|
335
|
-
// earlier did.
|
|
336
|
-
const fleet =
|
|
337
|
-
cfg.rabbitUrl === ''
|
|
338
|
-
? null
|
|
339
|
-
: createFleetExecutor({
|
|
340
|
-
rabbitUrl: cfg.rabbitUrl,
|
|
341
|
-
token: cfg.operatorToken === '' ? null : cfg.operatorToken,
|
|
342
|
-
audit: (event, detail) => ctx?.logger?.debug?.(`chatops ${event} ${JSON.stringify(detail ?? {})}`),
|
|
343
|
-
});
|
|
344
|
-
if (fleet === null) log.warn('no rabbitUrl, so /run, /where, /report and /machines will refuse');
|
|
345
|
-
|
|
346
|
-
const actions = createChatopsActions({
|
|
347
|
-
executor: fleet ?? unreachableFleet(),
|
|
348
|
-
policy,
|
|
349
|
-
host: ctx?.chatopsHost ?? {},
|
|
350
|
-
waitMs: cfg.fleetWaitMs,
|
|
351
|
-
autoWait: cfg.fleetAutoWait,
|
|
352
|
-
audit: (event, detail) => ctx?.logger?.debug?.(`chatops ${event} ${JSON.stringify(detail ?? {})}`),
|
|
353
|
-
});
|
|
354
|
-
|
|
355
|
-
const bridge = createBridge({
|
|
356
|
-
channels,
|
|
357
|
-
actions,
|
|
358
|
-
// Passed in, not rebuilt: `/trust` must widen the list that admission actually consults.
|
|
359
|
-
policy,
|
|
360
|
-
chunkBytes: cfg.reply.maxChunkBytes,
|
|
361
|
-
throttleMs: cfg.reply.rateLimitMs,
|
|
362
|
-
projectRoot: cfg.projectRoot === '' ? undefined : cfg.projectRoot,
|
|
363
|
-
maxFileBytes: cfg.reply.maxFileMB * 1024 * 1024,
|
|
364
|
-
audit: (event, detail) => ctx?.logger?.debug?.(`chatops ${event} ${JSON.stringify(detail ?? {})}`),
|
|
365
|
-
});
|
|
366
|
-
|
|
367
|
-
// The approval seam, and the whole of the fix for the defect that reached beyond this
|
|
368
|
-
// plugin: four lines, and the host's own answerers are reached on every path where this
|
|
369
|
-
// bridge declines or runs out of time.
|
|
370
|
-
ctx?.on?.('approval/request', (request, next) => {
|
|
371
|
-
const windowKey = ctx?.chatopsWindowFor?.(request) ?? null;
|
|
372
|
-
return bridge
|
|
373
|
-
.offerApproval({
|
|
374
|
-
request,
|
|
375
|
-
windowKey,
|
|
376
|
-
timeoutMs: cfg.push.onApproval ? cfg.push.approvalTimeoutSec * 1000 : 0,
|
|
377
|
-
next,
|
|
378
|
-
})
|
|
379
|
-
.then((result) => (result.claimed ? result.outcome : undefined));
|
|
380
|
-
});
|
|
381
|
-
|
|
382
|
-
const route = cfg.operatorToken === ''
|
|
383
|
-
? null
|
|
384
|
-
: createConfigRoute({
|
|
385
|
-
token: cfg.operatorToken,
|
|
386
|
-
read: () => ({ ...config }),
|
|
387
|
-
write: async (next) => {
|
|
388
|
-
if (typeof ctx?.chatopsWriteConfig !== 'function') {
|
|
389
|
-
throw new Error('no config writer is registered');
|
|
390
|
-
}
|
|
391
|
-
await ctx.chatopsWriteConfig(next);
|
|
392
|
-
},
|
|
393
|
-
audit: (event, detail) => ctx?.logger?.debug?.(`chatops ${event} ${JSON.stringify(detail ?? {})}`),
|
|
394
|
-
});
|
|
395
|
-
if (route === null) log.warn('operatorToken is empty, so the settings route is disabled');
|
|
396
|
-
|
|
397
|
-
if (typeof ctx?.effect !== 'function') {
|
|
398
|
-
// Not started rather than started-and-leaked: a bridge whose timers nobody owns is the
|
|
399
|
-
// leak this release exists to remove.
|
|
400
|
-
log.warn('the context provides no ctx.effect, so the bridge was not started');
|
|
401
|
-
return { started: false, reason: 'no_effect', ...described };
|
|
402
|
-
}
|
|
403
|
-
|
|
404
|
-
ctx.effect(() => {
|
|
405
|
-
let stopped = false;
|
|
406
|
-
bridge.start().catch((error) => log.warn(`start failed: ${error.message}`));
|
|
407
|
-
return () => {
|
|
408
|
-
if (stopped) return;
|
|
409
|
-
stopped = true;
|
|
410
|
-
return bridge.stop();
|
|
411
|
-
};
|
|
412
|
-
});
|
|
413
|
-
|
|
414
|
-
return {
|
|
415
|
-
started: true,
|
|
416
|
-
help: helpText(),
|
|
417
|
-
configRoute: route !== null,
|
|
418
|
-
// Reported here so `apply`'s result answers "which fleet is this machine in" without the
|
|
419
|
-
// caller needing to know about account.mjs. `sn: null` plus a `reason` means no fleet, and
|
|
420
|
-
// the reason is the actionable half.
|
|
421
|
-
sn: identity.ok ? identity.sn : null,
|
|
422
|
-
fleet: identity.ok ? identity.accountCode : null,
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
1
|
+
/**
|
|
2
|
+
* Mounting the chat bridge into a DSH profile (v0.7.0).
|
|
3
|
+
*
|
|
4
|
+
* ## What this is for
|
|
5
|
+
*
|
|
6
|
+
* The seven modules before this one carry the safety properties. This is the seam that makes
|
|
7
|
+
* them a plugin: it reads the row's config, builds the bridge, hands the host a listener for
|
|
8
|
+
* `approval/request`, and owns its own lifetime so a reload disposes it.
|
|
9
|
+
*
|
|
10
|
+
* ## The two things it deliberately does not do
|
|
11
|
+
*
|
|
12
|
+
* **It does not install a channel for you.** There is no default channel and no `ilink` or
|
|
13
|
+
* `feishu` adapter in this release, so a row with no channel enabled mounts a bridge that
|
|
14
|
+
* refuses everything and says so in its log. That is a real state and it is reported as one
|
|
15
|
+
* rather than papered over with a stub that would look like a working bot. Registering the
|
|
16
|
+
* in-memory channel is supported and is how the integration test drives the real path.
|
|
17
|
+
*
|
|
18
|
+
* **It does not adopt an owner.** `policy.mjs` has no first-contact path at all, so a fresh
|
|
19
|
+
* install is inert until the operator seeds `security.allowContacts` or redeems a pairing
|
|
20
|
+
* code printed by `pair()`. That is the fix for the finding this work started from, and it
|
|
21
|
+
* means a misconfigured install is silent rather than hijackable.
|
|
22
|
+
*
|
|
23
|
+
* ## Why `ctx.effect` and not a bare start
|
|
24
|
+
*
|
|
25
|
+
* A bridge holds timers — the approval deadline, every lane's throttle — and a map of pending
|
|
26
|
+
* approvals. `dsh-chatops` 0.1.0 owned none of them with a disposer, so a reload left timers
|
|
27
|
+
* firing at promises belonging to a fiber that no longer existed. Everything this module
|
|
28
|
+
* starts is registered as a reversible effect, and when the host provides no `ctx.effect` the
|
|
29
|
+
* gap is named rather than ignored.
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
import { createBridge } from './bridge.mjs';
|
|
33
|
+
import { createPolicy } from './policy.mjs';
|
|
34
|
+
import { createConfigRoute } from './config-route.mjs';
|
|
35
|
+
import { createMemoryChannel, validateChannel, CHANNEL_STATE } from './channel.mjs';
|
|
36
|
+
import { createILinkChannel } from './ilink.mjs';
|
|
37
|
+
import { isAllowedApiUrl } from './ilink-protocol.mjs';
|
|
38
|
+
import { createFleetExecutor } from './fleet.mjs';
|
|
39
|
+
import { createChatopsActions } from './actions.mjs';
|
|
40
|
+
import { createAccountIdentity, parseSn, fleetOf, SN_STATE_KEY } from './account.mjs';
|
|
41
|
+
import { helpText, commandTable } from './commands.mjs';
|
|
42
|
+
import { readFileSync, writeFileSync, mkdirSync } from 'node:fs';
|
|
43
|
+
import { join, dirname } from 'node:path';
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Load a stored serial number, or null.
|
|
47
|
+
*
|
|
48
|
+
* **Storage is deliberately not the config row.** A row is editable by whoever can edit the
|
|
49
|
+
* profile, and a serial number that a person can retype is a serial number that can be made to
|
|
50
|
+
* claim someone else's fleet. The state directory is where the machine's own identity already
|
|
51
|
+
* lives, so that is where this goes.
|
|
52
|
+
*
|
|
53
|
+
* A malformed stored SN is returned as-is rather than being ignored: `account.mjs` refuses it by
|
|
54
|
+
* name, and silently minting a replacement would hide a corrupted state file behind a machine
|
|
55
|
+
* that merely looks new.
|
|
56
|
+
*/
|
|
57
|
+
function loadStoredSn(path) {
|
|
58
|
+
if (path === null) return null;
|
|
59
|
+
try {
|
|
60
|
+
const raw = JSON.parse(readFileSync(path, 'utf8'));
|
|
61
|
+
const value = raw?.[SN_STATE_KEY];
|
|
62
|
+
return typeof value === 'string' && value !== '' ? value : null;
|
|
63
|
+
} catch {
|
|
64
|
+
// Absent or unreadable is the normal first-run path, not an error worth reporting twice.
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Persist an SN at 0600, so the machine's identity is not world-readable. */
|
|
70
|
+
function storeSn(path, sn) {
|
|
71
|
+
if (path === null) return false;
|
|
72
|
+
try {
|
|
73
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
74
|
+
writeFileSync(path, `${JSON.stringify({ [SN_STATE_KEY]: sn, schema_version: 1 }, null, 2)}\n`, { mode: 0o600 });
|
|
75
|
+
return true;
|
|
76
|
+
} catch {
|
|
77
|
+
return false;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* A fleet that refuses everything, for an install with no relay configured.
|
|
83
|
+
*
|
|
84
|
+
* Named refusals rather than an absent action, because `/machines` on a phone must say *why* it
|
|
85
|
+
* has nothing — "no rabbitUrl is set" is actionable and "this install has no fleet" is not.
|
|
86
|
+
*/
|
|
87
|
+
function unreachableFleet() {
|
|
88
|
+
const refuse = () => ({ ok: false, reason: 'no_relay', text: '这个安装没有配置 rabbitUrl,无法访问编队。' });
|
|
89
|
+
return {
|
|
90
|
+
run: async () => refuse(),
|
|
91
|
+
wait: async () => refuse(),
|
|
92
|
+
status: async () => refuse(),
|
|
93
|
+
report: async () => refuse(),
|
|
94
|
+
devices: async () => refuse(),
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Cordis service this plugin needs: none. The bridge is self-contained by construction. */
|
|
99
|
+
export const inject = [];
|
|
100
|
+
|
|
101
|
+
/** The config row's defaults, matching `cordis.patch.yml`. */
|
|
102
|
+
const DEFAULTS = Object.freeze({
|
|
103
|
+
channels: [],
|
|
104
|
+
channel: null,
|
|
105
|
+
security: Object.freeze({ allowContacts: [], allowRooms: [], listenFilehelper: true, listenSelf: true }),
|
|
106
|
+
push: Object.freeze({ onSessionComplete: true, onApproval: true, approvalTimeoutSec: 300 }),
|
|
107
|
+
reply: Object.freeze({ maxChunkBytes: 6000, rateLimitMs: 1200, maxFileMB: 100 }),
|
|
108
|
+
storagePath: '',
|
|
109
|
+
projectRoot: '',
|
|
110
|
+
stateDir: '',
|
|
111
|
+
operatorToken: '',
|
|
112
|
+
enableMemoryChannel: false,
|
|
113
|
+
/** The fleet. `rabbitUrl` is read from the same row the tools use, so there is one setting. */
|
|
114
|
+
rabbitUrl: '',
|
|
115
|
+
/** How long `/run` holds a chat reply before answering "still running". */
|
|
116
|
+
fleetWaitMs: 90_000,
|
|
117
|
+
/** Whether `/run` waits at all. A dispatch-only install answers with the task id instead. */
|
|
118
|
+
fleetAutoWait: true,
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
/** Read the row into a shape the modules take, with every default stated once. */
|
|
122
|
+
export function readChatopsConfig(config = {}) {
|
|
123
|
+
const security = { ...DEFAULTS.security, ...(config.security ?? {}) };
|
|
124
|
+
const push = { ...DEFAULTS.push, ...(config.push ?? {}) };
|
|
125
|
+
const reply = { ...DEFAULTS.reply, ...(config.reply ?? {}) };
|
|
126
|
+
const channels = Array.isArray(config.channels) && config.channels.length > 0
|
|
127
|
+
? config.channels
|
|
128
|
+
: config.channel
|
|
129
|
+
? [config.channel]
|
|
130
|
+
: [...DEFAULTS.channels];
|
|
131
|
+
const envToken = typeof process.env.W2M_OPERATOR_TOKEN === 'string' ? process.env.W2M_OPERATOR_TOKEN.trim() : '';
|
|
132
|
+
return {
|
|
133
|
+
channels,
|
|
134
|
+
security,
|
|
135
|
+
push,
|
|
136
|
+
reply,
|
|
137
|
+
storagePath: typeof config.storagePath === 'string' ? config.storagePath : DEFAULTS.storagePath,
|
|
138
|
+
projectRoot: typeof config.projectRoot === 'string' ? config.projectRoot : DEFAULTS.projectRoot,
|
|
139
|
+
// Where the machine's own identity lives. Read from the same row the agent uses, so there is
|
|
140
|
+
// one setting rather than two — a second state directory would be a second identity.
|
|
141
|
+
stateDir: typeof config.stateDir === 'string' && config.stateDir.trim() !== '' ? config.stateDir.trim() : DEFAULTS.stateDir,
|
|
142
|
+
// Resolution order matches the tools': the row, then the environment, then nothing. A
|
|
143
|
+
// different order here would mean the chat layer and the tools disagree about which token
|
|
144
|
+
// they hold, and only one of them would be refused.
|
|
145
|
+
operatorToken:
|
|
146
|
+
typeof config.operatorToken === 'string' && config.operatorToken.trim() !== ''
|
|
147
|
+
? config.operatorToken.trim()
|
|
148
|
+
: envToken !== ''
|
|
149
|
+
? envToken
|
|
150
|
+
: DEFAULTS.operatorToken,
|
|
151
|
+
rabbitUrl:
|
|
152
|
+
typeof config.rabbitUrl === 'string' && config.rabbitUrl.trim() !== ''
|
|
153
|
+
? config.rabbitUrl.trim()
|
|
154
|
+
: typeof process.env.W2M_RABBIT_URL === 'string' && process.env.W2M_RABBIT_URL.trim() !== ''
|
|
155
|
+
? process.env.W2M_RABBIT_URL.trim()
|
|
156
|
+
: DEFAULTS.rabbitUrl,
|
|
157
|
+
fleetWaitMs: Number.isInteger(config.fleetWaitMs) ? config.fleetWaitMs : DEFAULTS.fleetWaitMs,
|
|
158
|
+
fleetAutoWait: config.fleetAutoWait !== false,
|
|
159
|
+
enableMemoryChannel: config.enableMemoryChannel === true,
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Report what a row would do, without starting anything.
|
|
165
|
+
*
|
|
166
|
+
* Exposed because "why is my bot silent" should be answerable from a config dump rather than
|
|
167
|
+
* by reading logs: an install with no channel enabled and no owner seeded is inert by design,
|
|
168
|
+
* and that is worth saying out loud in a status line.
|
|
169
|
+
*/
|
|
170
|
+
export function describeChatopsConfig(config = {}) {
|
|
171
|
+
const cfg = readChatopsConfig(config);
|
|
172
|
+
const notes = [];
|
|
173
|
+
if (cfg.channels.length === 0) {
|
|
174
|
+
notes.push('no channel is enabled, so nothing will be received');
|
|
175
|
+
}
|
|
176
|
+
const owners = [...cfg.security.allowContacts];
|
|
177
|
+
if (owners.length === 0) {
|
|
178
|
+
notes.push(
|
|
179
|
+
'no identity is seeded in security.allowContacts, so the bridge will refuse every message ' +
|
|
180
|
+
'until an owner is established — there is no first-contact adoption',
|
|
181
|
+
);
|
|
182
|
+
}
|
|
183
|
+
if (cfg.operatorToken === '') notes.push('no operatorToken, so the settings route will be disabled');
|
|
184
|
+
return {
|
|
185
|
+
channels: cfg.channels,
|
|
186
|
+
owners,
|
|
187
|
+
rooms: [...cfg.security.allowRooms],
|
|
188
|
+
approvalTimeoutSec: cfg.push.approvalTimeoutSec,
|
|
189
|
+
notes,
|
|
190
|
+
commands: commandTable().map((c) => c.usage),
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Register an in-memory channel under a name.
|
|
196
|
+
*
|
|
197
|
+
* The supported path for a dry run and for tests. It is checked by the same validator a real
|
|
198
|
+
* adapter is, so it cannot drift from the contract.
|
|
199
|
+
*
|
|
200
|
+
* @param {object} options
|
|
201
|
+
* @param {string} options.name
|
|
202
|
+
* @param {string[]} options.prefixes
|
|
203
|
+
*/
|
|
204
|
+
export function defineChannel(options) {
|
|
205
|
+
const verdict = validateChannel(options);
|
|
206
|
+
if (!verdict.ok) throw new TypeError(`defineChannel: ${verdict.reason}`);
|
|
207
|
+
return options;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Cordis entry point.
|
|
212
|
+
*
|
|
213
|
+
* @param {object} ctx
|
|
214
|
+
* @param {object} [config] The row's config.
|
|
215
|
+
*/
|
|
216
|
+
export async function apply(ctx, config = {}) {
|
|
217
|
+
const cfg = readChatopsConfig(config);
|
|
218
|
+
const log = {
|
|
219
|
+
info: (m) => ctx?.logger?.info?.(`chatops: ${m}`),
|
|
220
|
+
warn: (m) => ctx?.logger?.warn?.(`chatops: ${m}`),
|
|
221
|
+
};
|
|
222
|
+
|
|
223
|
+
const described = describeChatopsConfig(config);
|
|
224
|
+
for (const note of described.notes) log.warn(note);
|
|
225
|
+
if (cfg.channels.length === 0) {
|
|
226
|
+
// Named, not silent. A bridge with no channel is a configuration, and a row that looks
|
|
227
|
+
// enabled while receiving nothing is the failure mode this message exists to prevent.
|
|
228
|
+
log.warn('no channel adapter is available in this release; the bridge mounts inert');
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
const channels = [];
|
|
232
|
+
if (cfg.enableMemoryChannel) {
|
|
233
|
+
channels.push(
|
|
234
|
+
createMemoryChannel({
|
|
235
|
+
name: 'memory',
|
|
236
|
+
prefixes: ['mem'],
|
|
237
|
+
onOutbound: (record) => log.info(`-> ${record.window}: ${String(record.text).slice(0, 80)}`),
|
|
238
|
+
}),
|
|
239
|
+
);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// The WeChat adapter, mounted when its row asks for it.
|
|
243
|
+
if (cfg.channels.includes('ilink')) {
|
|
244
|
+
const base = ctx?.chatopsIlinkBase ?? undefined;
|
|
245
|
+
if (base !== undefined && !isAllowedApiUrl(base).ok) {
|
|
246
|
+
// Refused before a token can go anywhere. Named rather than attempted.
|
|
247
|
+
log.warn(`the ilink base url was refused: ${isAllowedApiUrl(base).reason}`);
|
|
248
|
+
} else {
|
|
249
|
+
const token = ctx?.chatopsIlinkToken ?? null;
|
|
250
|
+
channels.push(
|
|
251
|
+
createILinkChannel({
|
|
252
|
+
// The host *may* supply a transport. In v0.7.0 that was the only option, which meant
|
|
253
|
+
// the channel could never send a request at all, because nothing shipped one. The
|
|
254
|
+
// adapter now builds a real HTTP transport itself and this only overrides it.
|
|
255
|
+
transport: typeof ctx?.chatopsIlinkRequest === 'function' ? ctx.chatopsIlinkRequest : undefined,
|
|
256
|
+
token,
|
|
257
|
+
apiBase: base,
|
|
258
|
+
audit: (event, detail) => ctx?.logger?.debug?.(`chatops ${event} ${JSON.stringify(detail ?? {})}`),
|
|
259
|
+
}),
|
|
260
|
+
);
|
|
261
|
+
if (token === null || token === '') {
|
|
262
|
+
// Neither an error nor silent: an install that has not scanned a QR yet is a normal
|
|
263
|
+
// state, and saying so is what stops "the bot is broken" being the only conclusion
|
|
264
|
+
// available to an operator.
|
|
265
|
+
log.warn('the ilink channel is mounted with no bot token, so it will not poll until one is adopted');
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// The bridge refuses to be built with no channel, which is right for the data path and
|
|
271
|
+
// wrong for a configuration report. So an inert mount registers one that accepts nothing
|
|
272
|
+
// and says so, rather than failing the whole profile on a row that is merely disabled.
|
|
273
|
+
if (channels.length === 0) {
|
|
274
|
+
if (cfg.channels.length > 0) {
|
|
275
|
+
// Configured to receive, and cannot. This is the state worth being loudest about: the
|
|
276
|
+
// row says the bot is on, so a silent bridge would read as a platform problem.
|
|
277
|
+
log.warn(`channels ${cfg.channels.join(', ')} were requested but none could be mounted`);
|
|
278
|
+
}
|
|
279
|
+
const inert = createMemoryChannel({ name: 'memory', prefixes: ['mem'] });
|
|
280
|
+
channels.push(inert);
|
|
281
|
+
log.warn('mounted with an inert channel so the profile loads; enable a channel to receive anything');
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// The policy is built here rather than inside `createBridge`, so the action set can share the
|
|
285
|
+
// same one. Two policies would mean two answers to "who is trusted", and `/trust` would write
|
|
286
|
+
// to the copy that admission does not read.
|
|
287
|
+
const policy = createPolicy({
|
|
288
|
+
trustedContacts: cfg.security.allowContacts,
|
|
289
|
+
allowedRooms: cfg.security.allowRooms,
|
|
290
|
+
audit: (event, detail) => ctx?.logger?.debug?.(`chatops ${event} ${JSON.stringify(detail ?? {})}`),
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* This machine's identity: which account it belongs to, and its serial number.
|
|
295
|
+
*
|
|
296
|
+
* Resolved before the channel work so a failure is reported once and early. A failure here is
|
|
297
|
+
* **not fatal to the plugin**: a machine with no signed-in account still runs the bridge, it
|
|
298
|
+
* just has no fleet — and the design says it must not be handed a placeholder one, because that
|
|
299
|
+
* would put two unrelated machines in the same pool.
|
|
300
|
+
*/
|
|
301
|
+
const snPath = cfg.stateDir === '' ? null : join(cfg.stateDir, 'chatops-identity.json');
|
|
302
|
+
let identity = { ok: false, reason: 'not_resolved' };
|
|
303
|
+
try {
|
|
304
|
+
identity = await createAccountIdentity({
|
|
305
|
+
ctx,
|
|
306
|
+
stored: loadStoredSn(snPath),
|
|
307
|
+
platform: process.platform,
|
|
308
|
+
audit: (event, detail) => ctx?.logger?.debug?.(`chatops ${event} ${JSON.stringify(detail ?? {})}`),
|
|
309
|
+
}).resolve();
|
|
310
|
+
} catch (error) {
|
|
311
|
+
identity = { ok: false, reason: 'identity_threw', detail: error instanceof Error ? error.message : String(error) };
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
if (identity.ok) {
|
|
315
|
+
// Written once, on creation. A stored SN is never rewritten, so a restart cannot change the
|
|
316
|
+
// machine's identity — every restart looking like a new device is how a fleet fills with
|
|
317
|
+
// ghosts of one machine.
|
|
318
|
+
if (identity.reused === false && storeSn(snPath, identity.sn)) {
|
|
319
|
+
log.info(`identity ${identity.sn} (fleet ${identity.accountCode}${identity.fleetKey ? ', joinable' : ', no join key'}, from ${identity.hardwareSources?.join('+') ?? 'unknown'})`);
|
|
320
|
+
} else if (identity.reused) {
|
|
321
|
+
log.info(`identity ${identity.sn} (reused; fleet ${identity.accountCode})`);
|
|
322
|
+
}
|
|
323
|
+
} else {
|
|
324
|
+
// Named, and at warn level, because "why is this machine in no fleet" is otherwise unanswerable
|
|
325
|
+
// from the outside. `signed-out` is the common case and is not an error.
|
|
326
|
+
log.warn(
|
|
327
|
+
identity.reason === 'signed-out'
|
|
328
|
+
? 'no DeepSeek account is signed in, so this machine has no fleet; it will not be grouped with any other'
|
|
329
|
+
: `no serial number was produced (${identity.reason}${identity.detail ? `: ${identity.detail}` : ''})`,
|
|
330
|
+
);
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
// The fleet half of the action set. This is what makes `/run` mean something: without it the
|
|
334
|
+
// command could only ever answer "this install has no fleet", which is exactly what v0.7.1 and
|
|
335
|
+
// earlier did.
|
|
336
|
+
const fleet =
|
|
337
|
+
cfg.rabbitUrl === ''
|
|
338
|
+
? null
|
|
339
|
+
: createFleetExecutor({
|
|
340
|
+
rabbitUrl: cfg.rabbitUrl,
|
|
341
|
+
token: cfg.operatorToken === '' ? null : cfg.operatorToken,
|
|
342
|
+
audit: (event, detail) => ctx?.logger?.debug?.(`chatops ${event} ${JSON.stringify(detail ?? {})}`),
|
|
343
|
+
});
|
|
344
|
+
if (fleet === null) log.warn('no rabbitUrl, so /run, /where, /report and /machines will refuse');
|
|
345
|
+
|
|
346
|
+
const actions = createChatopsActions({
|
|
347
|
+
executor: fleet ?? unreachableFleet(),
|
|
348
|
+
policy,
|
|
349
|
+
host: ctx?.chatopsHost ?? {},
|
|
350
|
+
waitMs: cfg.fleetWaitMs,
|
|
351
|
+
autoWait: cfg.fleetAutoWait,
|
|
352
|
+
audit: (event, detail) => ctx?.logger?.debug?.(`chatops ${event} ${JSON.stringify(detail ?? {})}`),
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
const bridge = createBridge({
|
|
356
|
+
channels,
|
|
357
|
+
actions,
|
|
358
|
+
// Passed in, not rebuilt: `/trust` must widen the list that admission actually consults.
|
|
359
|
+
policy,
|
|
360
|
+
chunkBytes: cfg.reply.maxChunkBytes,
|
|
361
|
+
throttleMs: cfg.reply.rateLimitMs,
|
|
362
|
+
projectRoot: cfg.projectRoot === '' ? undefined : cfg.projectRoot,
|
|
363
|
+
maxFileBytes: cfg.reply.maxFileMB * 1024 * 1024,
|
|
364
|
+
audit: (event, detail) => ctx?.logger?.debug?.(`chatops ${event} ${JSON.stringify(detail ?? {})}`),
|
|
365
|
+
});
|
|
366
|
+
|
|
367
|
+
// The approval seam, and the whole of the fix for the defect that reached beyond this
|
|
368
|
+
// plugin: four lines, and the host's own answerers are reached on every path where this
|
|
369
|
+
// bridge declines or runs out of time.
|
|
370
|
+
ctx?.on?.('approval/request', (request, next) => {
|
|
371
|
+
const windowKey = ctx?.chatopsWindowFor?.(request) ?? null;
|
|
372
|
+
return bridge
|
|
373
|
+
.offerApproval({
|
|
374
|
+
request,
|
|
375
|
+
windowKey,
|
|
376
|
+
timeoutMs: cfg.push.onApproval ? cfg.push.approvalTimeoutSec * 1000 : 0,
|
|
377
|
+
next,
|
|
378
|
+
})
|
|
379
|
+
.then((result) => (result.claimed ? result.outcome : undefined));
|
|
380
|
+
});
|
|
381
|
+
|
|
382
|
+
const route = cfg.operatorToken === ''
|
|
383
|
+
? null
|
|
384
|
+
: createConfigRoute({
|
|
385
|
+
token: cfg.operatorToken,
|
|
386
|
+
read: () => ({ ...config }),
|
|
387
|
+
write: async (next) => {
|
|
388
|
+
if (typeof ctx?.chatopsWriteConfig !== 'function') {
|
|
389
|
+
throw new Error('no config writer is registered');
|
|
390
|
+
}
|
|
391
|
+
await ctx.chatopsWriteConfig(next);
|
|
392
|
+
},
|
|
393
|
+
audit: (event, detail) => ctx?.logger?.debug?.(`chatops ${event} ${JSON.stringify(detail ?? {})}`),
|
|
394
|
+
});
|
|
395
|
+
if (route === null) log.warn('operatorToken is empty, so the settings route is disabled');
|
|
396
|
+
|
|
397
|
+
if (typeof ctx?.effect !== 'function') {
|
|
398
|
+
// Not started rather than started-and-leaked: a bridge whose timers nobody owns is the
|
|
399
|
+
// leak this release exists to remove.
|
|
400
|
+
log.warn('the context provides no ctx.effect, so the bridge was not started');
|
|
401
|
+
return { started: false, reason: 'no_effect', ...described };
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
ctx.effect(() => {
|
|
405
|
+
let stopped = false;
|
|
406
|
+
bridge.start().catch((error) => log.warn(`start failed: ${error.message}`));
|
|
407
|
+
return () => {
|
|
408
|
+
if (stopped) return;
|
|
409
|
+
stopped = true;
|
|
410
|
+
return bridge.stop();
|
|
411
|
+
};
|
|
412
|
+
});
|
|
413
|
+
|
|
414
|
+
return {
|
|
415
|
+
started: true,
|
|
416
|
+
help: helpText(),
|
|
417
|
+
configRoute: route !== null,
|
|
418
|
+
// Reported here so `apply`'s result answers "which fleet is this machine in" without the
|
|
419
|
+
// caller needing to know about account.mjs. `sn: null` plus a `reason` means no fleet, and
|
|
420
|
+
// the reason is the actionable half.
|
|
421
|
+
sn: identity.ok ? identity.sn : null,
|
|
422
|
+
fleet: identity.ok ? identity.accountCode : null,
|
|
423
|
+
/**
|
|
424
|
+
* v0.8.1: the key a machine presents to join its account's fleet with no human step.
|
|
425
|
+
*
|
|
426
|
+
* Exposed so the agent can offer it at pairing. It is a **derived** value, not the account
|
|
427
|
+
* id, so anything reading this result -- a log, a status surface -- learns which fleet this
|
|
428
|
+
* machine belongs to without learning whose account it is.
|
|
429
|
+
*
|
|
430
|
+
* `null` alongside a `reason` means no fleet: a signed-out machine has none, and the design
|
|
431
|
+
* says it must not be given a random one instead.
|
|
432
|
+
*/
|
|
433
|
+
fleetKey: identity.ok ? (identity.fleetKey ?? null) : null,
|
|
434
|
+
identity: identity.ok ? identity : { reason: identity.reason, detail: identity.detail ?? null },
|
|
435
|
+
...described,
|
|
436
|
+
};
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
/** States a caller may ask about. Re-exported so a status surface need not import the module. */
|
|
440
|
+
export { CHANNEL_STATE };
|