@mebular/fleet 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/agentdir.d.ts +43 -0
- package/dist/agentdir.d.ts.map +1 -0
- package/dist/agentdir.js +107 -0
- package/dist/agentdir.js.map +1 -0
- package/dist/cli.d.ts +3 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +1135 -0
- package/dist/cli.js.map +1 -0
- package/dist/collab/chatter.d.ts +54 -0
- package/dist/collab/chatter.d.ts.map +1 -0
- package/dist/collab/chatter.js +94 -0
- package/dist/collab/chatter.js.map +1 -0
- package/dist/collab/dag.d.ts +27 -0
- package/dist/collab/dag.d.ts.map +1 -0
- package/dist/collab/dag.js +107 -0
- package/dist/collab/dag.js.map +1 -0
- package/dist/collab/negotiation.d.ts +60 -0
- package/dist/collab/negotiation.d.ts.map +1 -0
- package/dist/collab/negotiation.js +90 -0
- package/dist/collab/negotiation.js.map +1 -0
- package/dist/collab/tree.d.ts +39 -0
- package/dist/collab/tree.d.ts.map +1 -0
- package/dist/collab/tree.js +177 -0
- package/dist/collab/tree.js.map +1 -0
- package/dist/config.d.ts +123 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +206 -0
- package/dist/config.js.map +1 -0
- package/dist/index.d.ts +33 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +40 -0
- package/dist/index.js.map +1 -0
- package/dist/join.d.ts +50 -0
- package/dist/join.d.ts.map +1 -0
- package/dist/join.js +167 -0
- package/dist/join.js.map +1 -0
- package/dist/jointoken.d.ts +144 -0
- package/dist/jointoken.d.ts.map +1 -0
- package/dist/jointoken.js +472 -0
- package/dist/jointoken.js.map +1 -0
- package/dist/model.d.ts +64 -0
- package/dist/model.d.ts.map +1 -0
- package/dist/model.js +158 -0
- package/dist/model.js.map +1 -0
- package/dist/onboard.d.ts +90 -0
- package/dist/onboard.d.ts.map +1 -0
- package/dist/onboard.js +510 -0
- package/dist/onboard.js.map +1 -0
- package/dist/protocol/envelope.d.ts +94 -0
- package/dist/protocol/envelope.d.ts.map +1 -0
- package/dist/protocol/envelope.js +169 -0
- package/dist/protocol/envelope.js.map +1 -0
- package/dist/protocol/events.d.ts +51 -0
- package/dist/protocol/events.d.ts.map +1 -0
- package/dist/protocol/events.js +87 -0
- package/dist/protocol/events.js.map +1 -0
- package/dist/qr.d.ts +15 -0
- package/dist/qr.d.ts.map +1 -0
- package/dist/qr.js +52 -0
- package/dist/qr.js.map +1 -0
- package/dist/quickstart.d.ts +167 -0
- package/dist/quickstart.d.ts.map +1 -0
- package/dist/quickstart.js +482 -0
- package/dist/quickstart.js.map +1 -0
- package/dist/quota.d.ts +41 -0
- package/dist/quota.d.ts.map +1 -0
- package/dist/quota.js +84 -0
- package/dist/quota.js.map +1 -0
- package/dist/runtime/agent.d.ts +100 -0
- package/dist/runtime/agent.d.ts.map +1 -0
- package/dist/runtime/agent.js +260 -0
- package/dist/runtime/agent.js.map +1 -0
- package/dist/runtime/executor.d.ts +42 -0
- package/dist/runtime/executor.d.ts.map +1 -0
- package/dist/runtime/executor.js +87 -0
- package/dist/runtime/executor.js.map +1 -0
- package/dist/runtime/fairness.d.ts +25 -0
- package/dist/runtime/fairness.d.ts.map +1 -0
- package/dist/runtime/fairness.js +79 -0
- package/dist/runtime/fairness.js.map +1 -0
- package/dist/runtime/node.d.ts +91 -0
- package/dist/runtime/node.d.ts.map +1 -0
- package/dist/runtime/node.js +203 -0
- package/dist/runtime/node.js.map +1 -0
- package/dist/runtime/openchamber-http.d.ts +27 -0
- package/dist/runtime/openchamber-http.d.ts.map +1 -0
- package/dist/runtime/openchamber-http.js +118 -0
- package/dist/runtime/openchamber-http.js.map +1 -0
- package/dist/runtime/openchamber.d.ts +38 -0
- package/dist/runtime/openchamber.d.ts.map +1 -0
- package/dist/runtime/openchamber.js +43 -0
- package/dist/runtime/openchamber.js.map +1 -0
- package/dist/runtime/planner.d.ts +23 -0
- package/dist/runtime/planner.d.ts.map +1 -0
- package/dist/runtime/planner.js +17 -0
- package/dist/runtime/planner.js.map +1 -0
- package/dist/runtime/worker.d.ts +96 -0
- package/dist/runtime/worker.d.ts.map +1 -0
- package/dist/runtime/worker.js +296 -0
- package/dist/runtime/worker.js.map +1 -0
- package/dist/security.d.ts +462 -0
- package/dist/security.d.ts.map +1 -0
- package/dist/security.js +1197 -0
- package/dist/security.js.map +1 -0
- package/dist/store/daemon-store.d.ts +33 -0
- package/dist/store/daemon-store.d.ts.map +1 -0
- package/dist/store/daemon-store.js +115 -0
- package/dist/store/daemon-store.js.map +1 -0
- package/dist/store/file-store.d.ts +22 -0
- package/dist/store/file-store.d.ts.map +1 -0
- package/dist/store/file-store.js +63 -0
- package/dist/store/file-store.js.map +1 -0
- package/dist/store/mebular-store.d.ts +22 -0
- package/dist/store/mebular-store.d.ts.map +1 -0
- package/dist/store/mebular-store.js +53 -0
- package/dist/store/mebular-store.js.map +1 -0
- package/dist/store/message-store.d.ts +26 -0
- package/dist/store/message-store.d.ts.map +1 -0
- package/dist/store/message-store.js +51 -0
- package/dist/store/message-store.js.map +1 -0
- package/dist/surface.d.ts +43 -0
- package/dist/surface.d.ts.map +1 -0
- package/dist/surface.js +515 -0
- package/dist/surface.js.map +1 -0
- package/dist/transport/null.d.ts +7 -0
- package/dist/transport/null.d.ts.map +1 -0
- package/dist/transport/null.js +16 -0
- package/dist/transport/null.js.map +1 -0
- package/dist/transport/spool.d.ts +12 -0
- package/dist/transport/spool.d.ts.map +1 -0
- package/dist/transport/spool.js +54 -0
- package/dist/transport/spool.js.map +1 -0
- package/dist/transport/types.d.ts +21 -0
- package/dist/transport/types.d.ts.map +1 -0
- package/dist/transport/types.js +6 -0
- package/dist/transport/types.js.map +1 -0
- package/dist/version.json +6 -0
- package/package.json +37 -4
- package/protocol/collab.example.json +23 -0
- package/protocol/daemon-home.example.json +66 -0
- package/protocol/envelope.example.json +12 -0
- package/protocol/state-machine.json +14 -0
- package/protocol/task-tree.example.json +72 -0
- package/src/agentdir.ts +138 -0
- package/src/cli.ts +1192 -0
- package/src/collab/chatter.ts +116 -0
- package/src/collab/dag.ts +124 -0
- package/src/collab/negotiation.ts +130 -0
- package/src/collab/tree.ts +184 -0
- package/src/config.ts +286 -0
- package/src/index.ts +39 -0
- package/src/join.ts +227 -0
- package/src/jointoken.ts +609 -0
- package/src/model.ts +203 -0
- package/src/onboard.ts +601 -0
- package/src/protocol/envelope.ts +228 -0
- package/src/protocol/events.ts +135 -0
- package/src/qr.ts +68 -0
- package/src/quickstart.ts +632 -0
- package/src/quota.ts +112 -0
- package/src/runtime/agent.ts +313 -0
- package/src/runtime/executor.ts +114 -0
- package/src/runtime/fairness.ts +90 -0
- package/src/runtime/node.ts +250 -0
- package/src/runtime/openchamber-http.ts +137 -0
- package/src/runtime/openchamber.ts +66 -0
- package/src/runtime/planner.ts +36 -0
- package/src/runtime/worker.ts +354 -0
- package/src/security.ts +1660 -0
- package/src/store/daemon-store.ts +144 -0
- package/src/store/file-store.ts +75 -0
- package/src/store/mebular-store.ts +66 -0
- package/src/store/message-store.ts +62 -0
- package/src/surface.ts +535 -0
- package/src/transport/null.ts +20 -0
- package/src/transport/spool.ts +62 -0
- package/src/transport/types.ts +27 -0
- package/README.md +0 -3
|
@@ -0,0 +1,632 @@
|
|
|
1
|
+
// Fleet 一键上车(Stage 1):`quickstart`(A 一条命令)+ `join`(B 一条命令)+ `pending`/`approve`。
|
|
2
|
+
//
|
|
3
|
+
// 设计约束:
|
|
4
|
+
// - **仅消费 `@mebular/core` 公共 API**(不 import core 内部路径),不改 core 语义。
|
|
5
|
+
// - 加入码含**敏感信任材料**(当前为同一用户主密钥,Stage 2 换令牌):绝不进日志/错误信息,
|
|
6
|
+
// `--code-file` 以 0600 落盘;`describeJoinCode` 只返回脱敏字段。
|
|
7
|
+
// - 默认拒绝不变:`quickstart` 默认 `--no-config-grant`,授权只走图上 grant(G1)。
|
|
8
|
+
|
|
9
|
+
import { chmod, mkdir, readFile, writeFile } from 'node:fs/promises';
|
|
10
|
+
import { existsSync } from 'node:fs';
|
|
11
|
+
import net from 'node:net';
|
|
12
|
+
import { hostname, networkInterfaces } from 'node:os';
|
|
13
|
+
import { delimiter, dirname, join } from 'node:path';
|
|
14
|
+
import { Mebular } from '@mebular/core';
|
|
15
|
+
|
|
16
|
+
import {
|
|
17
|
+
fleetConfigPath,
|
|
18
|
+
fleetMasterKeyPath,
|
|
19
|
+
loadFleetConfig,
|
|
20
|
+
masterKeyFingerprint,
|
|
21
|
+
permissionsApplicable,
|
|
22
|
+
readMasterKeyFile,
|
|
23
|
+
saveFleetConfig,
|
|
24
|
+
writeMasterKeyFile,
|
|
25
|
+
type FleetAgentConfig,
|
|
26
|
+
type FleetConfig,
|
|
27
|
+
type FleetEncryption,
|
|
28
|
+
} from './config.js';
|
|
29
|
+
import { mebularOptions, offlineMebularOptions, onboardDevice } from './onboard.js';
|
|
30
|
+
import { activateJoinInvite, buildJoinToken } from './jointoken.js';
|
|
31
|
+
|
|
32
|
+
/** 加入码内信任材料的记录形状(Stage 2 将由令牌取代)。 */
|
|
33
|
+
export interface JoinTrustMaterial {
|
|
34
|
+
publicKey: string;
|
|
35
|
+
privateKeyPkcs8: string;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface JoinCode {
|
|
39
|
+
v: 1;
|
|
40
|
+
kind: 'mebular-fleet-join';
|
|
41
|
+
/** 生成方的构建 SHA(join 侧版本核对;不一致即拒绝) */
|
|
42
|
+
sha: string;
|
|
43
|
+
/** A 的 deviceId */
|
|
44
|
+
device: string;
|
|
45
|
+
namespace: string;
|
|
46
|
+
/** A 的可达 multiaddr(含 /p2p/<id>),跨网回退用 */
|
|
47
|
+
multiaddrs: string[];
|
|
48
|
+
fingerprint: string;
|
|
49
|
+
issuedAt: number;
|
|
50
|
+
trust: JoinTrustMaterial;
|
|
51
|
+
note?: string;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface AgentProbe {
|
|
55
|
+
agents: FleetAgentConfig[];
|
|
56
|
+
sources: string[];
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export interface ServiceInstallOutcome {
|
|
60
|
+
installed: boolean;
|
|
61
|
+
note?: string;
|
|
62
|
+
}
|
|
63
|
+
export type ServiceInstaller = (dir: string) => Promise<ServiceInstallOutcome>;
|
|
64
|
+
|
|
65
|
+
/** 默认设备名:主机名清洗(小写、非法字符→`-`、去首尾 `-`)。 */
|
|
66
|
+
export function sanitizeDeviceName(name: string): string {
|
|
67
|
+
const cleaned = name
|
|
68
|
+
.toLowerCase()
|
|
69
|
+
.replace(/[^a-z0-9._-]+/g, '-')
|
|
70
|
+
.replace(/-+/g, '-')
|
|
71
|
+
.replace(/^[-.]+|[-.]+$/g, '');
|
|
72
|
+
return cleaned.length > 0 ? cleaned : 'device';
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function defaultDeviceName(host: string = hostname()): string {
|
|
76
|
+
return sanitizeDeviceName(host);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* 默认设备目录:`FLEET_DIR` → `MEBULAR_HOME` → `~/.mebular`(W2 一机一节点统一 home)。
|
|
81
|
+
* 旧 `~/.fleet` 仅作 **embedded 测试模式**(`--dir` 显式指定)。
|
|
82
|
+
*/
|
|
83
|
+
export function defaultFleetDir(env: NodeJS.ProcessEnv = process.env): string {
|
|
84
|
+
const fromEnv = env.FLEET_DIR ?? env.MEBULAR_HOME;
|
|
85
|
+
const home = env.HOME ?? env.USERPROFILE ?? '.';
|
|
86
|
+
return fromEnv !== undefined && fromEnv.length > 0 ? fromEnv : join(home, '.mebular');
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** PATH 中是否存在可执行文件(Windows 尝试 PATHEXT 扩展)。 */
|
|
90
|
+
export function commandExists(command: string, env: NodeJS.ProcessEnv = process.env, platform: string = process.platform): boolean {
|
|
91
|
+
const path = env.PATH ?? env.Path ?? '';
|
|
92
|
+
const exts = platform === 'win32' ? (env.PATHEXT ?? '.COM;.EXE;.BAT;.CMD').split(';') : [''];
|
|
93
|
+
for (const dir of path.split(delimiter)) {
|
|
94
|
+
if (dir.length === 0) continue;
|
|
95
|
+
for (const ext of exts) {
|
|
96
|
+
if (existsSync(join(dir, command + ext))) return true;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
return false;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const OPENCHAMBER_ENV_KEYS = [
|
|
103
|
+
'MEBULAR_FLEET_OPENCHAMBER_ENDPOINT',
|
|
104
|
+
'MEBULAR_FLEET_OPENCHAMBER_TOKEN',
|
|
105
|
+
'MEBULAR_FLEET_OPENCHAMBER_TOKEN_FILE',
|
|
106
|
+
];
|
|
107
|
+
|
|
108
|
+
/** agent 自动探测:PATH 有 hermes → hermes;存在 `MEBULAR_FLEET_OPENCHAMBER_*` → openchamber;否则 echo。 */
|
|
109
|
+
export function detectAgents(env: NodeJS.ProcessEnv = process.env, platform: string = process.platform): AgentProbe {
|
|
110
|
+
const agents: FleetAgentConfig[] = [];
|
|
111
|
+
const sources: string[] = [];
|
|
112
|
+
if (commandExists('hermes', env, platform)) {
|
|
113
|
+
agents.push({ name: 'hermes', kind: 'hermes' });
|
|
114
|
+
sources.push('hermes:PATH');
|
|
115
|
+
}
|
|
116
|
+
if (OPENCHAMBER_ENV_KEYS.some((k) => (env[k] ?? '').length > 0)) {
|
|
117
|
+
agents.push({ name: 'openchamber', kind: 'openchamber' });
|
|
118
|
+
sources.push('openchamber:env');
|
|
119
|
+
}
|
|
120
|
+
if (agents.length === 0) {
|
|
121
|
+
agents.push({ name: 'echo', kind: 'echo' });
|
|
122
|
+
sources.push('echo:default');
|
|
123
|
+
}
|
|
124
|
+
return { agents, sources };
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function parseListenPort(listen: string): { host: string; port: number } | null {
|
|
128
|
+
const m = /^\/(?:ip4|ip6)\/([^/]+)\/tcp\/(\d+)$/.exec(listen);
|
|
129
|
+
if (!m) return null;
|
|
130
|
+
return { host: m[1]!, port: Number(m[2]) };
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** 端口占用检查:占用 → 抛清晰错误 + 建议(`tcp/0` 表示随机端口,跳过)。 */
|
|
134
|
+
export async function assertListenAvailable(listen: string): Promise<void> {
|
|
135
|
+
const parsed = parseListenPort(listen);
|
|
136
|
+
if (parsed === null || parsed.port === 0) return;
|
|
137
|
+
const { host, port } = parsed;
|
|
138
|
+
await new Promise<void>((resolve, reject) => {
|
|
139
|
+
const server = net.createServer();
|
|
140
|
+
server.once('error', (error: NodeJS.ErrnoException) => {
|
|
141
|
+
if (error.code === 'EADDRINUSE') {
|
|
142
|
+
reject(
|
|
143
|
+
new Error(
|
|
144
|
+
`监听端口被占用:${listen}(EADDRINUSE)。建议:换端口(如 --listen /ip4/0.0.0.0/tcp/${port + 1}),或停用占用进程后重试。`,
|
|
145
|
+
),
|
|
146
|
+
);
|
|
147
|
+
} else {
|
|
148
|
+
reject(new Error(`监听地址不可用:${listen}(${error.code ?? error.message})`));
|
|
149
|
+
}
|
|
150
|
+
});
|
|
151
|
+
server.listen({ host: host === '0.0.0.0' || host === '::' ? undefined : host, port }, () => {
|
|
152
|
+
server.close(() => resolve());
|
|
153
|
+
});
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** 解析加入码:接受内联 base64 或其文本内容(兼容直接粘贴 JSON)。 */
|
|
158
|
+
export function decodeJoinCode(text: string): JoinCode {
|
|
159
|
+
const trimmed = text.trim();
|
|
160
|
+
if (trimmed.length === 0) throw new Error('加入码为空');
|
|
161
|
+
let json: string;
|
|
162
|
+
if (trimmed.startsWith('{')) {
|
|
163
|
+
json = trimmed;
|
|
164
|
+
} else {
|
|
165
|
+
json = Buffer.from(trimmed, 'base64').toString('utf-8');
|
|
166
|
+
}
|
|
167
|
+
let parsed: unknown;
|
|
168
|
+
try {
|
|
169
|
+
parsed = JSON.parse(json);
|
|
170
|
+
} catch {
|
|
171
|
+
throw new Error('加入码无法解析(既非 base64 也非 JSON)');
|
|
172
|
+
}
|
|
173
|
+
if (typeof parsed !== 'object' || parsed === null) throw new Error('加入码形状非法');
|
|
174
|
+
const c = parsed as Record<string, unknown>;
|
|
175
|
+
if (c.v !== 1 || c.kind !== 'mebular-fleet-join') throw new Error('加入码版本/类型不受支持');
|
|
176
|
+
if (typeof c.sha !== 'string' || typeof c.device !== 'string' || typeof c.namespace !== 'string') {
|
|
177
|
+
throw new Error('加入码缺少必填字段');
|
|
178
|
+
}
|
|
179
|
+
if (!Array.isArray(c.multiaddrs) || c.multiaddrs.some((a) => typeof a !== 'string')) {
|
|
180
|
+
throw new Error('加入码 multiaddrs 非法');
|
|
181
|
+
}
|
|
182
|
+
if (typeof c.fingerprint !== 'string') throw new Error('加入码缺少指纹');
|
|
183
|
+
const trust = c.trust as Record<string, unknown> | undefined;
|
|
184
|
+
if (typeof trust !== 'object' || trust === null || typeof trust.publicKey !== 'string' || typeof trust.privateKeyPkcs8 !== 'string') {
|
|
185
|
+
throw new Error('加入码信任材料非法');
|
|
186
|
+
}
|
|
187
|
+
return {
|
|
188
|
+
v: 1,
|
|
189
|
+
kind: 'mebular-fleet-join',
|
|
190
|
+
sha: c.sha,
|
|
191
|
+
device: c.device,
|
|
192
|
+
namespace: c.namespace,
|
|
193
|
+
multiaddrs: c.multiaddrs as string[],
|
|
194
|
+
fingerprint: c.fingerprint,
|
|
195
|
+
issuedAt: typeof c.issuedAt === 'number' ? c.issuedAt : 0,
|
|
196
|
+
trust: { publicKey: trust.publicKey, privateKeyPkcs8: trust.privateKeyPkcs8 },
|
|
197
|
+
...(typeof c.note === 'string' ? { note: c.note } : {}),
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/** 加入码 → 内联 base64。 */
|
|
202
|
+
export function encodeJoinCode(code: JoinCode): string {
|
|
203
|
+
return Buffer.from(JSON.stringify(code), 'utf-8').toString('base64');
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/** 脱敏描述(**不含信任材料**,可用于日志/报告)。 */
|
|
207
|
+
export function describeJoinCode(code: JoinCode): Record<string, unknown> {
|
|
208
|
+
return {
|
|
209
|
+
v: code.v,
|
|
210
|
+
kind: code.kind,
|
|
211
|
+
sha: code.sha,
|
|
212
|
+
device: code.device,
|
|
213
|
+
namespace: code.namespace,
|
|
214
|
+
multiaddrs: code.multiaddrs,
|
|
215
|
+
fingerprint: code.fingerprint,
|
|
216
|
+
issuedAt: code.issuedAt,
|
|
217
|
+
hasTrustMaterial: true,
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/** 写加入码文件(0600)。 */
|
|
222
|
+
export async function writeJoinCodeFile(path: string, inline: string): Promise<void> {
|
|
223
|
+
await mkdir(dirname(path), { recursive: true });
|
|
224
|
+
await writeFile(path, inline, { mode: 0o600 });
|
|
225
|
+
if (permissionsApplicable()) await chmod(path, 0o600);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* 构建 SHA 的**环境覆盖**(CLI 传入真实 `version.json` 值)。
|
|
230
|
+
* 本模块刻意不读 `import.meta`(ts-jest 以 CJS 加载 src,会编译失败);
|
|
231
|
+
* 缺省 `unknown` → 版本核对自动跳过。
|
|
232
|
+
*/
|
|
233
|
+
export function envBuildSha(env: NodeJS.ProcessEnv = process.env): string {
|
|
234
|
+
const envSha = env.MEBULAR_FLEET_BUILD_SHA;
|
|
235
|
+
return envSha !== undefined && envSha.length > 0 ? envSha : 'unknown';
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
export interface QuickstartInput {
|
|
239
|
+
dir: string;
|
|
240
|
+
device: string;
|
|
241
|
+
namespace?: string;
|
|
242
|
+
listen?: string;
|
|
243
|
+
codeFile?: string;
|
|
244
|
+
joinPort?: number;
|
|
245
|
+
joinHost?: string;
|
|
246
|
+
agents?: FleetAgentConfig[];
|
|
247
|
+
/** W2:同时配置**统一守护**(写 <dir>/config.json + fleet daemon 客户端 + 装 mebular-serve) */
|
|
248
|
+
daemon?: boolean;
|
|
249
|
+
/** 守护 MCP/app 端口(默认 7331) */
|
|
250
|
+
daemonPort?: number;
|
|
251
|
+
/** 守护服务安装回调(mebular-serve) */
|
|
252
|
+
installDaemon?: ServiceInstaller;
|
|
253
|
+
/** 服务安装回调;缺省 = 不安装(CLI 传真实安装器) */
|
|
254
|
+
installService?: ServiceInstaller;
|
|
255
|
+
/** 跳过端口占用检查(测试用) */
|
|
256
|
+
skipPortCheck?: boolean;
|
|
257
|
+
/** 覆盖构建 SHA(测试用) */
|
|
258
|
+
buildSha?: string;
|
|
259
|
+
/** 覆盖探测环境(测试用) */
|
|
260
|
+
env?: NodeJS.ProcessEnv;
|
|
261
|
+
platform?: string;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
export interface QuickstartResult {
|
|
265
|
+
device: string;
|
|
266
|
+
dir: string;
|
|
267
|
+
namespace: string;
|
|
268
|
+
fingerprint: string;
|
|
269
|
+
code: string;
|
|
270
|
+
codeFile?: string;
|
|
271
|
+
multiaddrs: string[];
|
|
272
|
+
agents: FleetAgentConfig[];
|
|
273
|
+
agentSources: string[];
|
|
274
|
+
serviceInstalled: boolean;
|
|
275
|
+
serviceNote?: string;
|
|
276
|
+
joinNext: string[];
|
|
277
|
+
warnings: string[];
|
|
278
|
+
/** T2:令牌加入(主密钥不复制)——推荐路径 */
|
|
279
|
+
inviteToken: string;
|
|
280
|
+
joinEndpoint: string;
|
|
281
|
+
joinPort: number;
|
|
282
|
+
/** W2:统一守护(`--daemon`)时的端点/服务安装结果 */
|
|
283
|
+
daemon?: { endpoint: string; installed: boolean; note?: string };
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
/** W2:确保守护 bearer 令牌存在(写入 <dir>/auth/tokens.json,0600);返回内联 token。 */
|
|
288
|
+
export async function ensureDaemonToken(dir: string): Promise<{ token: string; tokensFile: string }> {
|
|
289
|
+
const { createHash, randomUUID } = await import('node:crypto');
|
|
290
|
+
const tokensFile = join(dir, 'auth', 'tokens.json');
|
|
291
|
+
let record: { tokens: Array<Record<string, unknown>> } = { tokens: [] };
|
|
292
|
+
if (existsSync(tokensFile)) {
|
|
293
|
+
try {
|
|
294
|
+
const parsed = JSON.parse(await readFile(tokensFile, 'utf-8')) as { tokens?: Array<Record<string, unknown>> };
|
|
295
|
+
if (Array.isArray(parsed.tokens)) record = { tokens: parsed.tokens };
|
|
296
|
+
} catch {
|
|
297
|
+
record = { tokens: [] };
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
const token = `meb_${randomUUID().replace(/-/g, '')}${randomUUID().replace(/-/g, '')}`;
|
|
301
|
+
record.tokens.push({
|
|
302
|
+
id: randomUUID(),
|
|
303
|
+
sha256: createHash('sha256').update(token).digest('hex'),
|
|
304
|
+
scope: ['memory.read', 'memory.write', 'memory.admin'],
|
|
305
|
+
label: 'fleet-client',
|
|
306
|
+
createdAt: new Date().toISOString(),
|
|
307
|
+
revoked: false,
|
|
308
|
+
});
|
|
309
|
+
await mkdir(dirname(tokensFile), { recursive: true });
|
|
310
|
+
await writeFile(tokensFile, JSON.stringify(record, null, 2), { mode: 0o600 });
|
|
311
|
+
if (permissionsApplicable()) await chmod(tokensFile, 0o600);
|
|
312
|
+
return { token, tokensFile };
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/**
|
|
316
|
+
* W2 B2:把统一守护配置写进同一 home(`<dir>/config.json`),并让 fleet 走 daemon 客户端:
|
|
317
|
+
* fleet.config.json 增 `store:'daemon'` + `daemon:{endpoint,token}`。**不动** embedded 配置字段。
|
|
318
|
+
*/
|
|
319
|
+
export async function configureDaemonHome(
|
|
320
|
+
dir: string,
|
|
321
|
+
config: FleetConfig,
|
|
322
|
+
namespace: string,
|
|
323
|
+
joinPort: number,
|
|
324
|
+
daemonPort: number,
|
|
325
|
+
delegated: { userMasterPublicKeyFile: string } | null,
|
|
326
|
+
peers: Array<{ device: string; addr?: string }> = [],
|
|
327
|
+
): Promise<{ token: string; endpoint: string }> {
|
|
328
|
+
const { token, tokensFile } = await ensureDaemonToken(dir);
|
|
329
|
+
const daemonConfig = {
|
|
330
|
+
storagePath: config.storagePath,
|
|
331
|
+
deviceId: config.device,
|
|
332
|
+
identity: { mode: delegated ? 'delegated' : 'root' },
|
|
333
|
+
encryption: delegated
|
|
334
|
+
? { level: 'none', userMasterPublicKeyFile: delegated.userMasterPublicKeyFile }
|
|
335
|
+
: { level: 'none', keyFile: config.masterKeyFile },
|
|
336
|
+
network: { enabled: true, libp2p: { listen: [config.listen] }, ...(peers.length > 0 ? { peers } : {}) },
|
|
337
|
+
sync: {
|
|
338
|
+
autoSync: true,
|
|
339
|
+
pushOnWrite: true,
|
|
340
|
+
namespaces: [namespace],
|
|
341
|
+
antiEntropy: { enabled: true, intervalMs: 600000, jitterRatio: 0.2 },
|
|
342
|
+
},
|
|
343
|
+
joinService: { enabled: true, bind: '0.0.0.0', port: joinPort },
|
|
344
|
+
mcp: { http: { host: '127.0.0.1', port: daemonPort, auth: 'bearer', tokensFile } },
|
|
345
|
+
};
|
|
346
|
+
await writeFile(join(dir, 'config.json'), JSON.stringify(daemonConfig, null, 2), { mode: 0o600 });
|
|
347
|
+
if (permissionsApplicable()) await chmod(join(dir, 'config.json'), 0o600);
|
|
348
|
+
const endpoint = `http://127.0.0.1:${daemonPort}`;
|
|
349
|
+
const fleetPath = fleetConfigPath(dir);
|
|
350
|
+
const current = await loadFleetConfig(fleetPath);
|
|
351
|
+
await saveFleetConfig(fleetPath, { ...current, store: 'daemon', daemon: { endpoint, token } });
|
|
352
|
+
return { token, endpoint };
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/** W2:Agent 侧 MCP 配置片段(守护 MCP stdio)。 */
|
|
356
|
+
export function agentMcpConfig(): Record<string, unknown> {
|
|
357
|
+
return { mcp: { mebular: { type: 'local', command: ['mebular', 'mcp'] } } };
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
const DEFAULT_LISTEN = '/ip4/0.0.0.0/tcp/4001';
|
|
361
|
+
const DEFAULT_JOIN_PORT = 4002;
|
|
362
|
+
|
|
363
|
+
/** 选一个可对外通告的 LAN IPv4(无则回环)。 */
|
|
364
|
+
export function pickLanHost(): string {
|
|
365
|
+
const ifaces = networkInterfaces();
|
|
366
|
+
for (const list of Object.values(ifaces)) {
|
|
367
|
+
for (const info of list ?? []) {
|
|
368
|
+
if (info.family === 'IPv4' && !info.internal) return info.address;
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
return '127.0.0.1';
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
/** A 一条命令:onboard + 本机订阅(self) + 加入码 + 服务 + doctor。 */
|
|
375
|
+
export async function quickstart(input: QuickstartInput): Promise<QuickstartResult> {
|
|
376
|
+
const env = input.env ?? process.env;
|
|
377
|
+
const namespace = input.namespace ?? 'tasks';
|
|
378
|
+
const listen = input.listen ?? DEFAULT_LISTEN;
|
|
379
|
+
const warnings: string[] = [];
|
|
380
|
+
await mkdir(input.dir, { recursive: true });
|
|
381
|
+
if (permissionsApplicable(input.platform)) await chmod(input.dir, 0o700);
|
|
382
|
+
if (input.skipPortCheck !== true) await assertListenAvailable(listen);
|
|
383
|
+
|
|
384
|
+
const probe = input.agents !== undefined ? { agents: input.agents, sources: ['explicit'] } : detectAgents(env, input.platform);
|
|
385
|
+
const onboard = await onboardDevice({
|
|
386
|
+
dir: input.dir,
|
|
387
|
+
device: input.device,
|
|
388
|
+
namespace,
|
|
389
|
+
listen,
|
|
390
|
+
agents: probe.agents,
|
|
391
|
+
});
|
|
392
|
+
const config = onboard.config;
|
|
393
|
+
const encryption = await readMasterKeyFile(config.masterKeyFile);
|
|
394
|
+
|
|
395
|
+
// 图上订阅:本机声明参与该域(订阅自主,零门槛)。
|
|
396
|
+
const mebular = new Mebular(offlineMebularOptions(config, encryption) as never);
|
|
397
|
+
await mebular.initialize();
|
|
398
|
+
try {
|
|
399
|
+
await mebular.subscribeNamespace(namespace);
|
|
400
|
+
} finally {
|
|
401
|
+
await mebular.shutdown();
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
// T2:启用 join 服务并预生成一枚令牌(推荐路径;旧共享主密钥 code 仍保留用于兼容)。
|
|
405
|
+
const joinPort = input.joinPort ?? DEFAULT_JOIN_PORT;
|
|
406
|
+
await saveFleetConfig(fleetConfigPath(input.dir), {
|
|
407
|
+
...config,
|
|
408
|
+
joinService: { enabled: true, bind: '0.0.0.0', port: joinPort },
|
|
409
|
+
});
|
|
410
|
+
const joinEndpoint = `http://${input.joinHost ?? pickLanHost()}:${joinPort}`;
|
|
411
|
+
const tokenBuilder = new Mebular(offlineMebularOptions(config, encryption) as never);
|
|
412
|
+
await tokenBuilder.initialize();
|
|
413
|
+
let inviteToken: string;
|
|
414
|
+
try {
|
|
415
|
+
const token = await buildJoinToken({ mebular: tokenBuilder, deviceId: config.device, namespace, endpoint: joinEndpoint });
|
|
416
|
+
// 预生成令牌也进入单一有效邀请状态:之后在控制台重新生成会撤销它。
|
|
417
|
+
await activateJoinInvite(config.storagePath, token.nonce);
|
|
418
|
+
inviteToken = Buffer.from(JSON.stringify(token), 'utf-8').toString('base64');
|
|
419
|
+
} finally {
|
|
420
|
+
await tokenBuilder.shutdown();
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
const multiaddrs = await captureMultiaddrs(config, encryption, warnings);
|
|
424
|
+
const inline = encodeJoinCode({
|
|
425
|
+
v: 1,
|
|
426
|
+
kind: 'mebular-fleet-join',
|
|
427
|
+
sha: input.buildSha ?? envBuildSha(env),
|
|
428
|
+
device: config.device,
|
|
429
|
+
namespace,
|
|
430
|
+
multiaddrs,
|
|
431
|
+
fingerprint: onboard.masterKeyFingerprint,
|
|
432
|
+
issuedAt: Date.now(),
|
|
433
|
+
trust: {
|
|
434
|
+
publicKey: Buffer.from(encryption.userMasterKey).toString('base64'),
|
|
435
|
+
privateKeyPkcs8: await exportPkcs8(encryption),
|
|
436
|
+
},
|
|
437
|
+
note: 'stage1: shared master key (stage2 will use a join token)',
|
|
438
|
+
});
|
|
439
|
+
if (input.codeFile !== undefined) await writeJoinCodeFile(input.codeFile, inline);
|
|
440
|
+
|
|
441
|
+
let serviceInstalled = false;
|
|
442
|
+
let serviceNote: string | undefined;
|
|
443
|
+
if (input.installService !== undefined) {
|
|
444
|
+
const outcome = await input.installService(input.dir);
|
|
445
|
+
serviceInstalled = outcome.installed;
|
|
446
|
+
serviceNote = outcome.note;
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
// W2 B2:统一守护(同一 home):写 config.json + fleet daemon 客户端 + (可选)mebular-serve 服务 + MCP 配置
|
|
450
|
+
const daemonPort = input.daemonPort ?? 7331;
|
|
451
|
+
let daemonInfo: { endpoint: string; installed: boolean; note?: string } | null = null;
|
|
452
|
+
if (input.daemon === true) {
|
|
453
|
+
const { endpoint } = await configureDaemonHome(input.dir, config, namespace, joinPort, daemonPort, null);
|
|
454
|
+
let installed = false;
|
|
455
|
+
let dialNote: string | undefined;
|
|
456
|
+
if (input.installDaemon !== undefined) {
|
|
457
|
+
const outcome = await input.installDaemon(input.dir);
|
|
458
|
+
installed = outcome.installed;
|
|
459
|
+
dialNote = outcome.note;
|
|
460
|
+
}
|
|
461
|
+
daemonInfo = { endpoint, installed, ...(dialNote !== undefined ? { note: dialNote } : {}) };
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
return {
|
|
465
|
+
device: config.device,
|
|
466
|
+
dir: config.dir,
|
|
467
|
+
namespace,
|
|
468
|
+
fingerprint: onboard.masterKeyFingerprint,
|
|
469
|
+
code: inline,
|
|
470
|
+
...(input.codeFile !== undefined ? { codeFile: input.codeFile } : {}),
|
|
471
|
+
multiaddrs,
|
|
472
|
+
agents: probe.agents,
|
|
473
|
+
agentSources: probe.sources,
|
|
474
|
+
serviceInstalled,
|
|
475
|
+
...(serviceNote !== undefined ? { serviceNote } : {}),
|
|
476
|
+
joinNext: [
|
|
477
|
+
'fleet join --token <内联|文件>(推荐:主密钥不复制)',
|
|
478
|
+
'新设备加入后自行 subscribe;订阅零门槛、无需批准',
|
|
479
|
+
],
|
|
480
|
+
warnings,
|
|
481
|
+
inviteToken,
|
|
482
|
+
joinEndpoint,
|
|
483
|
+
joinPort,
|
|
484
|
+
...(daemonInfo !== null ? { daemon: daemonInfo } : {}),
|
|
485
|
+
};
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
async function captureMultiaddrs(config: FleetConfig, encryption: FleetEncryption, warnings: string[]): Promise<string[]> {
|
|
489
|
+
const m = new Mebular(mebularOptions(config, encryption) as never);
|
|
490
|
+
await m.initialize();
|
|
491
|
+
try {
|
|
492
|
+
const addrs = m.node?.getLocalMultiaddrs() ?? [];
|
|
493
|
+
if (addrs.length === 0) warnings.push('未捕获到可达 multiaddr(对端需人工提供地址)');
|
|
494
|
+
return addrs;
|
|
495
|
+
} finally {
|
|
496
|
+
await m.shutdown();
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
async function exportPkcs8(encryption: FleetEncryption): Promise<string> {
|
|
501
|
+
if (encryption.userMasterPrivateKey === undefined) throw new Error('缺少用户主私钥,无法导出加入码信任材料');
|
|
502
|
+
const raw = await globalThis.crypto.subtle.exportKey('pkcs8', encryption.userMasterPrivateKey);
|
|
503
|
+
return Buffer.from(raw).toString('base64');
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
export interface JoinInput {
|
|
507
|
+
dir: string;
|
|
508
|
+
code: string;
|
|
509
|
+
device: string;
|
|
510
|
+
namespace?: string;
|
|
511
|
+
listen?: string;
|
|
512
|
+
agents?: FleetAgentConfig[];
|
|
513
|
+
installService?: ServiceInstaller;
|
|
514
|
+
skipPortCheck?: boolean;
|
|
515
|
+
buildSha?: string;
|
|
516
|
+
env?: NodeJS.ProcessEnv;
|
|
517
|
+
platform?: string;
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
export interface JoinResult {
|
|
521
|
+
device: string;
|
|
522
|
+
dir: string;
|
|
523
|
+
peer: string;
|
|
524
|
+
namespace: string;
|
|
525
|
+
fingerprint: string;
|
|
526
|
+
alreadyJoined: boolean;
|
|
527
|
+
serviceInstalled: boolean;
|
|
528
|
+
serviceNote?: string;
|
|
529
|
+
multiaddrs: string[];
|
|
530
|
+
agents: FleetAgentConfig[];
|
|
531
|
+
agentSources: string[];
|
|
532
|
+
next: string[];
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
/** B 一条命令:版本核对 → 导入信任材料/地址 → onboard → 声明成员(active) → 服务 → doctor。 */
|
|
536
|
+
export async function joinFleet(input: JoinInput): Promise<JoinResult> {
|
|
537
|
+
const env = input.env ?? process.env;
|
|
538
|
+
const code = decodeJoinCode(input.code);
|
|
539
|
+
const localSha = input.buildSha ?? envBuildSha(env);
|
|
540
|
+
if (code.sha !== 'unknown' && localSha !== 'unknown' && code.sha !== localSha) {
|
|
541
|
+
throw new Error(`版本不一致:加入码构建 SHA=${code.sha},本机=${localSha}。请使用同版本(fleet --version)后重试。`);
|
|
542
|
+
}
|
|
543
|
+
const namespace = input.namespace ?? code.namespace;
|
|
544
|
+
const listen = input.listen ?? DEFAULT_LISTEN;
|
|
545
|
+
if (input.skipPortCheck !== true) await assertListenAvailable(listen);
|
|
546
|
+
|
|
547
|
+
const probe = input.agents !== undefined ? { agents: input.agents, sources: ['explicit'] } : detectAgents(env, input.platform);
|
|
548
|
+
|
|
549
|
+
// 幂等/防覆盖:已有配置且设备不符 → 拒绝;已有同设备且指纹一致 → 复用。
|
|
550
|
+
const keyPath = fleetMasterKeyPath(input.dir);
|
|
551
|
+
let alreadyJoined = false;
|
|
552
|
+
if (existsSync(fleetConfigPath(input.dir))) {
|
|
553
|
+
const existing = await loadFleetConfig(fleetConfigPath(input.dir));
|
|
554
|
+
if (existing.device !== input.device) {
|
|
555
|
+
throw new Error(`目录已被设备 ${existing.device} 占用(--dir ${input.dir});换目录或先停用。`);
|
|
556
|
+
}
|
|
557
|
+
if (existsSync(keyPath)) {
|
|
558
|
+
const existingKey = await readMasterKeyFile(keyPath);
|
|
559
|
+
if (masterKeyFingerprint(existingKey.userMasterKey) !== code.fingerprint) {
|
|
560
|
+
throw new Error('本机已有不同主密钥,拒绝被加入码覆盖。');
|
|
561
|
+
}
|
|
562
|
+
alreadyJoined = true;
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
await mkdir(input.dir, { recursive: true });
|
|
567
|
+
if (permissionsApplicable(input.platform)) await chmod(input.dir, 0o700);
|
|
568
|
+
if (!existsSync(keyPath)) {
|
|
569
|
+
await writeMasterKeyFile(keyPath, await importTrust(code));
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
const onboard = await onboardDevice({
|
|
573
|
+
dir: input.dir,
|
|
574
|
+
device: input.device,
|
|
575
|
+
namespace,
|
|
576
|
+
listen,
|
|
577
|
+
agents: probe.agents,
|
|
578
|
+
peerDevice: code.device,
|
|
579
|
+
...(code.multiaddrs[0] !== undefined ? { peerAddr: code.multiaddrs[0] } : {}),
|
|
580
|
+
});
|
|
581
|
+
const encryption = await readMasterKeyFile(onboard.config.masterKeyFile);
|
|
582
|
+
|
|
583
|
+
const mebular = new Mebular(offlineMebularOptions(onboard.config, encryption) as never);
|
|
584
|
+
await mebular.initialize();
|
|
585
|
+
try {
|
|
586
|
+
await mebular.subscribeNamespace(namespace);
|
|
587
|
+
} finally {
|
|
588
|
+
await mebular.shutdown();
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
let serviceInstalled = false;
|
|
592
|
+
let serviceNote: string | undefined;
|
|
593
|
+
if (input.installService !== undefined) {
|
|
594
|
+
const outcome = await input.installService(input.dir);
|
|
595
|
+
serviceInstalled = outcome.installed;
|
|
596
|
+
serviceNote = outcome.note;
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
return {
|
|
600
|
+
device: onboard.config.device,
|
|
601
|
+
dir: onboard.config.dir,
|
|
602
|
+
peer: code.device,
|
|
603
|
+
namespace,
|
|
604
|
+
fingerprint: onboard.masterKeyFingerprint,
|
|
605
|
+
alreadyJoined,
|
|
606
|
+
serviceInstalled,
|
|
607
|
+
...(serviceNote !== undefined ? { serviceNote } : {}),
|
|
608
|
+
multiaddrs: code.multiaddrs,
|
|
609
|
+
agents: probe.agents,
|
|
610
|
+
agentSources: probe.sources,
|
|
611
|
+
next: ['本机已声明订阅;对端同步后即可互通(无需批准)'],
|
|
612
|
+
};
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
async function importTrust(code: JoinCode): Promise<FleetEncryption> {
|
|
616
|
+
const userMasterKey = new Uint8Array(Buffer.from(code.trust.publicKey, 'base64'));
|
|
617
|
+
const userMasterPrivateKey = await globalThis.crypto.subtle.importKey(
|
|
618
|
+
'pkcs8',
|
|
619
|
+
Buffer.from(code.trust.privateKeyPkcs8, 'base64'),
|
|
620
|
+
{ name: 'Ed25519' },
|
|
621
|
+
true,
|
|
622
|
+
['sign'],
|
|
623
|
+
);
|
|
624
|
+
return { userMasterKey, userMasterPrivateKey };
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
/** 读加入码文件(或内联)。 */
|
|
628
|
+
export async function readJoinCode(input: { code?: string; codeFile?: string }): Promise<string> {
|
|
629
|
+
if (input.code !== undefined && input.code.length > 0) return input.code;
|
|
630
|
+
if (input.codeFile !== undefined) return await readFile(input.codeFile, 'utf-8');
|
|
631
|
+
throw new Error('join 需要 --code <内联> 或 --code-file <路径>');
|
|
632
|
+
}
|