pi-maestro-mobile 0.2.13 → 0.3.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/cli.js +85 -21
- package/dist/cli.js.map +1 -1
- package/dist/extension.d.ts.map +1 -1
- package/dist/extension.js +126 -96
- package/dist/extension.js.map +1 -1
- package/dist/host-controller.d.ts +3 -0
- package/dist/host-controller.d.ts.map +1 -1
- package/dist/host-controller.js +12 -0
- package/dist/host-controller.js.map +1 -1
- package/dist/jsonl-index.js +2 -2
- package/dist/jsonl-index.js.map +1 -1
- package/dist/jsonl-pager.d.ts.map +1 -1
- package/dist/jsonl-pager.js +68 -6
- package/dist/jsonl-pager.js.map +1 -1
- package/dist/jsonl-tail-watcher.d.ts +44 -0
- package/dist/jsonl-tail-watcher.d.ts.map +1 -0
- package/dist/jsonl-tail-watcher.js +275 -0
- package/dist/jsonl-tail-watcher.js.map +1 -0
- package/dist/live-sessions.d.ts.map +1 -1
- package/dist/live-sessions.js +22 -5
- package/dist/live-sessions.js.map +1 -1
- package/dist/maestro-state.d.ts.map +1 -1
- package/dist/maestro-state.js +19 -4
- package/dist/maestro-state.js.map +1 -1
- package/dist/mobile-ui-context.d.ts +17 -1
- package/dist/mobile-ui-context.d.ts.map +1 -1
- package/dist/mobile-ui-context.js +35 -7
- package/dist/mobile-ui-context.js.map +1 -1
- package/dist/pi/pi-sdk-runtime.d.ts.map +1 -1
- package/dist/pi/pi-sdk-runtime.js +8 -7
- package/dist/pi/pi-sdk-runtime.js.map +1 -1
- package/dist/server/helpers.d.ts +0 -11
- package/dist/server/helpers.d.ts.map +1 -1
- package/dist/server/helpers.js +24 -44
- package/dist/server/helpers.js.map +1 -1
- package/dist/server/mobile-host-server.d.ts +88 -1
- package/dist/server/mobile-host-server.d.ts.map +1 -1
- package/dist/server/mobile-host-server.js +434 -54
- package/dist/server/mobile-host-server.js.map +1 -1
- package/dist/session-runner.d.ts +22 -1
- package/dist/session-runner.d.ts.map +1 -1
- package/dist/session-runner.js +67 -5
- package/dist/session-runner.js.map +1 -1
- package/dist/types.d.ts +1 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/usage-reader.d.ts +6 -2
- package/dist/usage-reader.d.ts.map +1 -1
- package/dist/usage-reader.js +122 -27
- package/dist/usage-reader.js.map +1 -1
- package/dist/vendor/shared/protocol.d.ts +13 -0
- package/dist/vendor/shared/protocol.d.ts.map +1 -1
- package/dist/vendor/shared/protocol.js.map +1 -1
- package/dist/vendor/shared/validation.d.ts.map +1 -1
- package/dist/vendor/shared/validation.js +10 -0
- package/dist/vendor/shared/validation.js.map +1 -1
- package/dist/workspace-peer-injector.d.ts +19 -0
- package/dist/workspace-peer-injector.d.ts.map +1 -0
- package/dist/workspace-peer-injector.js +63 -0
- package/dist/workspace-peer-injector.js.map +1 -0
- package/dist/workspace-telemetry.d.ts +4 -1
- package/dist/workspace-telemetry.d.ts.map +1 -1
- package/dist/workspace-telemetry.js +20 -6
- package/dist/workspace-telemetry.js.map +1 -1
- package/package.json +1 -1
- package/dist/jsonl-replay.d.ts +0 -9
- package/dist/jsonl-replay.d.ts.map +0 -1
- package/dist/jsonl-replay.js +0 -153
- package/dist/jsonl-replay.js.map +0 -1
- package/dist/server/ws-command-handlers.d.ts +0 -20
- package/dist/server/ws-command-handlers.d.ts.map +0 -1
- package/dist/server/ws-command-handlers.js +0 -176
- package/dist/server/ws-command-handlers.js.map +0 -1
|
@@ -9,6 +9,7 @@ import { projectMonitorState } from "../monitor-projection.js";
|
|
|
9
9
|
import { readSettingsOverview, updateSettingsJson } from "../maestro-settings.js";
|
|
10
10
|
import { validateClientCommand } from "../vendor/shared/index.js";
|
|
11
11
|
import { HostSessionListService } from "./helpers.js";
|
|
12
|
+
import { injectMessageToActiveTui } from "../workspace-peer-injector.js";
|
|
12
13
|
/**
|
|
13
14
|
* MobileHostServer — 移动端直连服务器(HTTP REST + WebSocket)
|
|
14
15
|
*
|
|
@@ -22,62 +23,285 @@ export class MobileHostServer {
|
|
|
22
23
|
controller;
|
|
23
24
|
options;
|
|
24
25
|
server;
|
|
25
|
-
webSocketServer
|
|
26
|
+
webSocketServer;
|
|
26
27
|
clients = new Set();
|
|
27
28
|
/** 单客户端并发命令上限:超过则拒绝,防命令洪泛(DoS) */
|
|
28
29
|
static MAX_CONNECTIONS = 32;
|
|
29
30
|
static MAX_CONCURRENT_COMMANDS = 8;
|
|
31
|
+
/** 软高水位:best_effort 帧越阈即丢;required 帧持续越阈达宽限期才断线。
|
|
32
|
+
* 必须显著低于硬顶,否则「required 宽限投递」区间被压缩到几乎不存在 */
|
|
33
|
+
static HIGH_WATER_MARK_BYTES = 1 * 1024 * 1024;
|
|
34
|
+
/** 硬上限:约束队列驻留(buffered + 新帧)。默认 32MB:实测合法最大帧(4000 条 snapshot)≈7.4MB,
|
|
35
|
+
* 留 4× 余量;单帧自身越顶不阻断(防重连死循环),只告警 */
|
|
36
|
+
static HARD_LIMIT_BYTES = 32 * 1024 * 1024;
|
|
37
|
+
static SLOW_GRACE_MS = 5_000;
|
|
38
|
+
static DROP_LOG_INTERVAL_MS = 60_000;
|
|
39
|
+
/** 连续未应答次数上限:3 = 容忍两次漏答(默认 30s 周期即约 90s 无响应/无数据才判定死连) */
|
|
40
|
+
static HEARTBEAT_MAX_MISSES = 3;
|
|
30
41
|
unsubscribeController;
|
|
42
|
+
/** listen() 等待中的错误回调;非空表示正在绑定端口 */
|
|
43
|
+
listenError;
|
|
31
44
|
boundHost = "0.0.0.0";
|
|
32
45
|
hostSessionList = new HostSessionListService({
|
|
33
46
|
indexPath: join(homedir(), ".pi", "agent", "mobile-session-index.json"),
|
|
34
47
|
});
|
|
48
|
+
/** 背压参数(构造时从 options 解析,默认取静态常量) */
|
|
49
|
+
highWaterMarkBytes;
|
|
50
|
+
hardLimitBytes;
|
|
51
|
+
slowGraceMs;
|
|
35
52
|
constructor(controller, options = {}) {
|
|
36
53
|
this.controller = controller;
|
|
37
54
|
this.options = options;
|
|
55
|
+
this.webSocketServer = new WebSocketServer({ noServer: true, maxPayload: this.options.maxPayload ?? 8 * 1024 * 1024 });
|
|
56
|
+
this.highWaterMarkBytes = this.options.highWaterMarkBytes ?? MobileHostServer.HIGH_WATER_MARK_BYTES;
|
|
57
|
+
this.hardLimitBytes = this.options.hardLimitBytes ?? MobileHostServer.HARD_LIMIT_BYTES;
|
|
58
|
+
this.slowGraceMs = this.options.slowGraceMs ?? MobileHostServer.SLOW_GRACE_MS;
|
|
38
59
|
this.server = createServer((request, response) => {
|
|
39
60
|
void this.handleHttp(request, response);
|
|
40
61
|
});
|
|
41
62
|
this.server.on("upgrade", (request, socket, head) => {
|
|
42
63
|
this.handleUpgrade(request, socket, head);
|
|
43
64
|
});
|
|
65
|
+
// 常驻单一 error 入口:启动期错误只交给 listen() 的 reject,以免与友好提示重复打印原始错误;
|
|
66
|
+
// listen 成功后的 server error(EMFILE 等)无监听者时会以 uncaughtException 崩掉守护进程,故仅记录不抛出。
|
|
67
|
+
this.server.on("error", (error) => {
|
|
68
|
+
const pending = this.listenError;
|
|
69
|
+
this.listenError = undefined;
|
|
70
|
+
if (pending)
|
|
71
|
+
pending(error);
|
|
72
|
+
else
|
|
73
|
+
console.error("[maestro-mobile] http server error:", error);
|
|
74
|
+
});
|
|
44
75
|
this.webSocketServer.on("connection", (ws) => {
|
|
45
76
|
if (this.clients.size >= MobileHostServer.MAX_CONNECTIONS) {
|
|
77
|
+
console.warn(`[maestro-mobile] ws rejected: too many connections (limit=${MobileHostServer.MAX_CONNECTIONS})`);
|
|
46
78
|
ws.close(1013, "too many connections");
|
|
47
79
|
return;
|
|
48
80
|
}
|
|
49
|
-
const client = { id: crypto.randomUUID(), ws, inflight: 0 };
|
|
81
|
+
const client = { id: crypto.randomUUID(), ws, inflight: 0, closing: false, droppedFrames: 0, lastDropLogAt: 0, slowSince: 0, heartbeatMisses: 0 };
|
|
50
82
|
this.clients.add(client);
|
|
51
|
-
//
|
|
52
|
-
|
|
53
|
-
|
|
83
|
+
// 故障隔离到连接粒度(本 run 主根因):此前无 error listener,超限/非法帧的 error 事件直接变
|
|
84
|
+
// uncaughtException → cli fatal() → 整个 host 退出(单手机一帧崩掉所有客户端)。现在只断该连接,
|
|
85
|
+
// 其余连接无感。listener 必须先于任何 send 注册。
|
|
86
|
+
ws.on("error", (error) => {
|
|
87
|
+
// 注意:ws 在协议错误路径(receiverOnError)已同步发过 close 帧,此处不得 terminate,
|
|
88
|
+
// 否则抢掉 1009 通知,客户端只能看到裸 TCP 断。幂等 closing 标记下走 graceful 重复 close(无副作用)。
|
|
89
|
+
console.error(`[maestro-mobile] ws error client=${client.id} code=${error.code ?? "-"} message=${sanitizeWsErrorMessage(error.message, [this.options.token ?? ""])} (isolated)`);
|
|
90
|
+
this.closeClient(client, "ws_error");
|
|
91
|
+
});
|
|
92
|
+
// 活性由标准 ping/pong 心跳维护(见 startHeartbeat):客户端栈(RN OkHttp/浏览器/Node ws)
|
|
93
|
+
// 均自动应答 pong,无需改协议。半开连接、死 socket 在下个周期被 terminate,
|
|
94
|
+
// 也封住 close() 等失联连接 graceful close 的≈30s 悬挂。
|
|
95
|
+
ws.on("pong", () => {
|
|
96
|
+
client.heartbeatMisses = 0;
|
|
97
|
+
});
|
|
54
98
|
ws.on("message", (data) => {
|
|
99
|
+
// 收到任何入站数据即证明连接存活,清零漏答计数(防止 iOS/RN 客户端因底层 pong 延迟被心跳误杀)
|
|
100
|
+
client.heartbeatMisses = 0;
|
|
55
101
|
if (client.inflight >= MobileHostServer.MAX_CONCURRENT_COMMANDS) {
|
|
56
|
-
|
|
102
|
+
// 限流拒绝也要回 in_reply_to:否则客户端那条命令挂 30s 超时。
|
|
103
|
+
// 此处解析一次只为取 id;解析失败回空(真正的 invalid_json 判定在 handleClientMessage 里)
|
|
104
|
+
let replyTo = "";
|
|
105
|
+
try {
|
|
106
|
+
const parsed = JSON.parse(data.toString());
|
|
107
|
+
if (typeof parsed?.id === "string")
|
|
108
|
+
replyTo = parsed.id;
|
|
109
|
+
}
|
|
110
|
+
catch { /* 非 JSON:无 id 可回 */ }
|
|
111
|
+
this.sendError(client, "too_many_commands", undefined, replyTo);
|
|
57
112
|
return;
|
|
58
113
|
}
|
|
59
114
|
client.inflight++;
|
|
60
|
-
|
|
115
|
+
// .catch 不可省:void 链上任何 rejection(含未来新增分支)都会经 unhandledRejection 冒到 cli fatal() 退进程
|
|
116
|
+
void this.handleClientMessage(client, data)
|
|
117
|
+
.catch((error) => {
|
|
118
|
+
console.error(`[maestro-mobile] ws command crashed client=${client.id}:`, error instanceof Error ? sanitizeWsErrorMessage(error.message, [this.options.token ?? ""]) : error);
|
|
119
|
+
})
|
|
120
|
+
.finally(() => {
|
|
61
121
|
client.inflight--;
|
|
62
122
|
});
|
|
63
123
|
});
|
|
64
|
-
ws.on("close", () => {
|
|
124
|
+
ws.on("close", (code, reason) => {
|
|
125
|
+
client.closing = true;
|
|
126
|
+
if (client.droppedFrames > 0) {
|
|
127
|
+
console.warn(`[maestro-mobile] ws closed client=${client.id} code=${code} droppedTotal=${client.droppedFrames}`);
|
|
128
|
+
}
|
|
129
|
+
else if (code !== 1000 && code !== 1001) {
|
|
130
|
+
console.warn(`[maestro-mobile] ws closed client=${client.id} code=${code} reason=${sanitizeWsErrorMessage(reason.toString(), [this.options.token ?? ""]).slice(0, 60)}`);
|
|
131
|
+
}
|
|
65
132
|
this.clients.delete(client);
|
|
66
133
|
});
|
|
134
|
+
// P2-1:host_status 契约是 status: string;HostStatus 对象走独立的 host_info 事件
|
|
135
|
+
this.sendFrame(client, { type: "host_status", status: "connected", seq: 0 }, "required");
|
|
136
|
+
this.sendFrame(client, { type: "host_info", info: this.controller.getStatus(), seq: 0 }, "required");
|
|
67
137
|
});
|
|
68
138
|
this.unsubscribeController = this.controller.onEvent((event) => {
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
139
|
+
let payload;
|
|
140
|
+
try {
|
|
141
|
+
payload = JSON.stringify(event);
|
|
142
|
+
}
|
|
143
|
+
catch (error) {
|
|
144
|
+
// 序列化失败(循环引用/BigInt)属事件自身缺陷:只记一次不抛出(controller 侧已有 per-listener 防护,
|
|
145
|
+
// 但本 listener 抛错会使本事件对其后客户端丢失——提前 return 避免半途截断)
|
|
146
|
+
console.error(`[maestro-mobile] ws broadcast serialize failed type=${event.type ?? "?"}:`, error instanceof Error ? error.message : error);
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
// 协议层可丢性(packages/shared/src/protocol.ts:324-328):只有 timeline_delta 声明「尚不存在可忽略,
|
|
150
|
+
// 终态由 timeline_item 补齐」。其余事件无 server 侧重放保证,一律 required(宁断不默丢)。
|
|
151
|
+
const delivery = event.type === "timeline_delta" ? "best_effort" : "required";
|
|
152
|
+
for (const client of [...this.clients]) {
|
|
153
|
+
this.sendFrame(client, payload, delivery, event.type);
|
|
74
154
|
}
|
|
75
155
|
});
|
|
76
156
|
}
|
|
157
|
+
remoteOf(ws) {
|
|
158
|
+
return ws._socket?.remoteAddress ?? "-";
|
|
159
|
+
}
|
|
160
|
+
/** 心跳周期句柄 */
|
|
161
|
+
heartbeatTimer;
|
|
162
|
+
/**
|
|
163
|
+
* 服务端心跳:每周期对无响应的连接 terminate(半开/死 socket),有响应则 ping。
|
|
164
|
+
* 替代此前没用的 ws.setTimeout(WebSocket 对象无此方法);也让 close():「graceful 等待」不再被失联连接拖≈30s。
|
|
165
|
+
*/
|
|
166
|
+
startHeartbeat(intervalMs) {
|
|
167
|
+
if (this.heartbeatTimer)
|
|
168
|
+
return; // 幂等:重复 listen 不得叠加 interval(否则多路 ping/terminate)
|
|
169
|
+
this.heartbeatTimer = setInterval(() => {
|
|
170
|
+
for (const client of [...this.clients]) {
|
|
171
|
+
// 必须容忍一次漏答:本机 event loop 卡顶(实测 load>150)会让 pong 排队跨过半个周期,
|
|
172
|
+
// 单次未应答即 terminate 会误杀健康连接(表现为手机无端断线,需重连+补拉)。
|
|
173
|
+
if (client.heartbeatMisses >= MobileHostServer.HEARTBEAT_MAX_MISSES) {
|
|
174
|
+
console.warn(`[maestro-mobile] ws heartbeat lost client=${client.id} remote=${this.remoteOf(client.ws)} misses=${client.heartbeatMisses} → terminate`);
|
|
175
|
+
this.closeClient(client, "heartbeat_lost");
|
|
176
|
+
continue;
|
|
177
|
+
}
|
|
178
|
+
client.heartbeatMisses++;
|
|
179
|
+
try {
|
|
180
|
+
client.ws.ping();
|
|
181
|
+
}
|
|
182
|
+
catch {
|
|
183
|
+
this.closeClient(client, "ping_failed");
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
}, intervalMs);
|
|
187
|
+
this.heartbeatTimer.unref?.(); // 不阻止进程退出
|
|
188
|
+
}
|
|
189
|
+
/**
|
|
190
|
+
* 单一发送出口(故障隔离 + 背压收口):所有 WS 写出必须经此,不得直接 ws.send。
|
|
191
|
+
* - required(command_result/握手/协议错误/timeline_item/session_updated/raw_event/…):
|
|
192
|
+
* 越阈不静默丢;持续越阈达宽限期 close(1013),由客户端重连 + P2-2 snapshot 补拉自愈。
|
|
193
|
+
* 不自研排队/drain 补发:消除 close×drain 竞态,局域网工具不值得该复杂度。
|
|
194
|
+
* - best_effort(仅 timeline_delta):越阈直接丢弃并计数,终态由后续 timeline_item 补齐,不断线。
|
|
195
|
+
*/
|
|
196
|
+
sendFrame(client, message, delivery, kind = "object") {
|
|
197
|
+
if (client.closing || client.ws.readyState !== client.ws.OPEN)
|
|
198
|
+
return false;
|
|
199
|
+
let payload;
|
|
200
|
+
try {
|
|
201
|
+
payload = typeof message === "string" ? message : JSON.stringify(message);
|
|
202
|
+
}
|
|
203
|
+
catch (error) {
|
|
204
|
+
console.error(`[maestro-mobile] ws sendFrame serialize failed kind=${kind} client=${client.id}:`, error instanceof Error ? error.message : error);
|
|
205
|
+
return false;
|
|
206
|
+
}
|
|
207
|
+
const buffered = client.ws.bufferedAmount;
|
|
208
|
+
// 硬顶语义:约束的是「队列驻留」,不是「单帧大小」。
|
|
209
|
+
// 实测合法最大帧 = 4000 条 timeline 的 snapshot ≈ 7.4MB(本机 164MB 会话),
|
|
210
|
+
// 若把超限单帧也断线,客户端重连后会再次拉到同一帧 → 重连死循环,比 OOM 更糟。
|
|
211
|
+
// 因此:单帧自身越顶 → 照发(它是唯一副本)+ 告警;积压+新帧越顶 → 断线重连自愈。
|
|
212
|
+
const payloadBytes = Buffer.byteLength(payload);
|
|
213
|
+
if (payloadBytes > this.hardLimitBytes) {
|
|
214
|
+
console.warn(`[maestro-mobile] ws oversized required frame client=${client.id} kind=${kind} bytes=${payloadBytes} limit=${this.hardLimitBytes} (sent anyway; data-layer budget is separate concern)`);
|
|
215
|
+
return this.rawSend(client, payload);
|
|
216
|
+
}
|
|
217
|
+
if (buffered + payloadBytes > this.hardLimitBytes) {
|
|
218
|
+
// 硬顶:无论何种投递都先断线;required 走到这里绝不静默丢数据(断线重连才是既定自愈路径)
|
|
219
|
+
console.warn(`[maestro-mobile] ws buffer hard limit client=${client.id} remote=${this.remoteOf(client.ws)} buffered=${buffered} frame=${payloadBytes} → close(1013)`);
|
|
220
|
+
this.closeClient(client, "slow_consumer");
|
|
221
|
+
return false;
|
|
222
|
+
}
|
|
223
|
+
if (buffered >= this.highWaterMarkBytes) {
|
|
224
|
+
if (delivery === "best_effort") {
|
|
225
|
+
client.droppedFrames++;
|
|
226
|
+
const now = Date.now();
|
|
227
|
+
if (now - client.lastDropLogAt >= MobileHostServer.DROP_LOG_INTERVAL_MS) {
|
|
228
|
+
client.lastDropLogAt = now;
|
|
229
|
+
console.warn(`[maestro-mobile] ws slow consumer client=${client.id} remote=${this.remoteOf(client.ws)} buffered=${buffered} droppedSinceLastLog (best_effort frames, continuing)`);
|
|
230
|
+
}
|
|
231
|
+
return false;
|
|
232
|
+
}
|
|
233
|
+
if (!client.slowSince) {
|
|
234
|
+
client.slowSince = Date.now();
|
|
235
|
+
}
|
|
236
|
+
else if (Date.now() - client.slowSince >= this.slowGraceMs) {
|
|
237
|
+
// required 帧无法送达:与其默默丢,不如在可重连边界上断线(1013 = retryable,与满载拒绝同码)
|
|
238
|
+
console.warn(`[maestro-mobile] ws slow consumer exceeded grace client=${client.id} remote=${this.remoteOf(client.ws)} buffered=${buffered} → close(1013)`);
|
|
239
|
+
this.closeClient(client, "slow_consumer");
|
|
240
|
+
}
|
|
241
|
+
// 宽限期内:不丢、不断,交给 ws 内部缓冲(软/硬双阈值 + 5s 宽限,驻留有界)
|
|
242
|
+
return this.rawSend(client, payload);
|
|
243
|
+
}
|
|
244
|
+
client.slowSince = 0;
|
|
245
|
+
return this.rawSend(client, payload);
|
|
246
|
+
}
|
|
247
|
+
rawSend(client, payload) {
|
|
248
|
+
// 关闭可能就在本次 sendFrame 判定内发生(硬顶/宽限到期):此时不得再写,
|
|
249
|
+
// 否则「已判定必须送达」的那一帧反而静默丢失(ws 对 CLOSING 只累加计数不报错)。
|
|
250
|
+
if (client.closing)
|
|
251
|
+
return false;
|
|
252
|
+
try {
|
|
253
|
+
client.ws.send(payload);
|
|
254
|
+
return true;
|
|
255
|
+
}
|
|
256
|
+
catch (error) {
|
|
257
|
+
// 理论上仅 CONNECTING 态会同步抛(服务端不可达);保留隔离防未来分支变化
|
|
258
|
+
console.error(`[maestro-mobile] ws send threw client=${client.id}:`, error instanceof Error ? error.message : error);
|
|
259
|
+
this.closeClient(client, "send_failed");
|
|
260
|
+
return false;
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
/**
|
|
264
|
+
* 幂等关闭:只由首个调用者真正关。默认 graceful close(让 close 帧送达,客户端能看到 1013/reason);
|
|
265
|
+
* ws 内部已在协议错误路径自行发过 close 帧(receiverOnError),重复 close 无副作用。
|
|
266
|
+
* heartbeat_lost/ping_failed 等对端已死的场景直接 terminate 强制拆。
|
|
267
|
+
*/
|
|
268
|
+
closeClient(client, reason) {
|
|
269
|
+
if (client.closing)
|
|
270
|
+
return;
|
|
271
|
+
client.closing = true;
|
|
272
|
+
this.clients.delete(client);
|
|
273
|
+
try {
|
|
274
|
+
if (reason === "heartbeat_lost" || reason === "ping_failed" || reason === "send_failed") {
|
|
275
|
+
client.ws.terminate();
|
|
276
|
+
}
|
|
277
|
+
else {
|
|
278
|
+
client.ws.close(1013, reason);
|
|
279
|
+
// graceful close 对端不应答时不等≈30s closeTimeout:2s 后强制拆
|
|
280
|
+
setTimeout(() => {
|
|
281
|
+
try {
|
|
282
|
+
client.ws.terminate();
|
|
283
|
+
}
|
|
284
|
+
catch { /* 已清理 */ }
|
|
285
|
+
}, 2_000).unref?.();
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
catch (error) {
|
|
289
|
+
console.error(`[maestro-mobile] ws close failed client=${client.id} reason=${reason}:`, error instanceof Error ? error.message : error);
|
|
290
|
+
}
|
|
291
|
+
}
|
|
77
292
|
listen(port, hostname = "0.0.0.0") {
|
|
78
293
|
this.boundHost = hostname;
|
|
79
|
-
return new Promise((resolve) => {
|
|
80
|
-
|
|
294
|
+
return new Promise((resolve, reject) => {
|
|
295
|
+
// EADDRINUSE / EACCES / 非法绑定地址都以 server 的 error 事件产生。此前无监听者:
|
|
296
|
+
// 该 Promise 永不 settle,错误以 uncaughtException 裸崩(而此时 cli 的 handler 尚未注册)。
|
|
297
|
+
this.listenError = reject;
|
|
298
|
+
this.server.listen(port, hostname, () => {
|
|
299
|
+
this.listenError = undefined;
|
|
300
|
+
// 心跳必须在绑定成功后启动:放在 listen() 之前时,EADDRINUSE 会留下一个无人 cleanup 的 interval,
|
|
301
|
+
// 且同实例重试 listen() 会覆盖 heartbeatTimer,使 close() 只能清最后一个。
|
|
302
|
+
this.startHeartbeat(this.options.heartbeatIntervalMs ?? 30_000);
|
|
303
|
+
resolve();
|
|
304
|
+
});
|
|
81
305
|
});
|
|
82
306
|
}
|
|
83
307
|
address() {
|
|
@@ -88,12 +312,30 @@ export class MobileHostServer {
|
|
|
88
312
|
return { port: address.port };
|
|
89
313
|
}
|
|
90
314
|
async close() {
|
|
315
|
+
if (this.heartbeatTimer) {
|
|
316
|
+
clearInterval(this.heartbeatTimer);
|
|
317
|
+
this.heartbeatTimer = undefined; // 必须置空:否则幂等守卫会阻止下次 listen() 重启心跳
|
|
318
|
+
}
|
|
91
319
|
this.unsubscribeController?.();
|
|
92
320
|
for (const client of this.clients) {
|
|
93
321
|
client.ws.close();
|
|
94
322
|
}
|
|
95
323
|
this.clients.clear();
|
|
96
324
|
this.webSocketServer.close();
|
|
325
|
+
// 失联/半开连接的 graceful close 帧无应答,wss.close 会等 ws 内部≈30s closeTimeout:
|
|
326
|
+
// 优雅等待有界(3s),超时后强拆残留 socket,shutdown 不再悬挂。
|
|
327
|
+
await new Promise((resolve) => {
|
|
328
|
+
const forceSockets = () => {
|
|
329
|
+
for (const socket of this.webSocketServer.clients) {
|
|
330
|
+
try {
|
|
331
|
+
socket.terminate();
|
|
332
|
+
}
|
|
333
|
+
catch { /* 已断开 */ }
|
|
334
|
+
}
|
|
335
|
+
};
|
|
336
|
+
const timer = setTimeout(() => { forceSockets(); resolve(); }, 3_000);
|
|
337
|
+
this.webSocketServer.once("close", () => { clearTimeout(timer); resolve(); });
|
|
338
|
+
});
|
|
97
339
|
await new Promise((resolve, reject) => {
|
|
98
340
|
this.server.close((error) => (error ? reject(error) : resolve()));
|
|
99
341
|
});
|
|
@@ -256,7 +498,26 @@ export class MobileHostServer {
|
|
|
256
498
|
this.webSocketServer.emit("connection", ws, request);
|
|
257
499
|
});
|
|
258
500
|
}
|
|
259
|
-
/**
|
|
501
|
+
/**
|
|
502
|
+
* WS 握手 Origin 白名单:scheme 限定 + loopback 变体 + 绑定 host(非 0.0.0.0 时)
|
|
503
|
+
* + 同源 Host 头 + 显式配置。
|
|
504
|
+
*
|
|
505
|
+
* ISS-20260910-005 收紧(以下均为实测 new URL() 行为,非推断):
|
|
506
|
+
* 1. scheme 必须落在 http/https/ws/wss:旧实现完全不看 protocol,
|
|
507
|
+
* `file://`、`data:` 等只要 hostname 对上就放行。
|
|
508
|
+
* 2. 同源比较改用 parsed.host。旧写法 `${hostname}:${parsed.port}` 在无端口时
|
|
509
|
+
* 产生尾冒号(`new URL("http://192.168.1.10").port === ""`)⇒ 永不匹配,
|
|
510
|
+
* 是无效果死比较。parsed.host 已含非默认端口、并规范掉默认端口
|
|
511
|
+
* (`https://a.example:443`.host === "a.example"),语义止于「主机+非默认端口」。
|
|
512
|
+
* 3. allowedOrigins 支持两种写法:完整 URL(可精确到端口)或裸 hostname。
|
|
513
|
+
* 旧实现用 new URL(o) 解析,而 `new URL("trusted.example.com")` 抛错 ⇒ 该配置
|
|
514
|
+
* 静默永不生效(只能配成完整 URL 才有效),属配置陷阱。
|
|
515
|
+
*
|
|
516
|
+
* 不变的两条(刻意的宽松,改它们会锁死现有使用):
|
|
517
|
+
* - 无 Origin 一律放行(:552 原样保留):RN/原生客户端不发 Origin,仍有 token 把关。
|
|
518
|
+
* - loopback 与 boundHost 分支只看 hostname、忽略 port:本地开发页面常在随机端口
|
|
519
|
+
* (http://localhost:3000 连 4739 的 host),纳port 比较会直接打断现有用法。
|
|
520
|
+
*/
|
|
260
521
|
isOriginAllowed(origin, requestHost = "") {
|
|
261
522
|
if (!origin)
|
|
262
523
|
return true; // 非浏览器客户端
|
|
@@ -267,29 +528,64 @@ export class MobileHostServer {
|
|
|
267
528
|
catch {
|
|
268
529
|
return false;
|
|
269
530
|
}
|
|
531
|
+
// scheme 限定:Origin 只可能是页面来源(http/https)或原生客户端回填的 WS URL 自身(ws/wss)
|
|
532
|
+
if (parsed.protocol !== "http:" && parsed.protocol !== "https:"
|
|
533
|
+
&& parsed.protocol !== "ws:" && parsed.protocol !== "wss:") {
|
|
534
|
+
return false;
|
|
535
|
+
}
|
|
270
536
|
const hostname = parsed.hostname.toLowerCase();
|
|
537
|
+
const originHost = parsed.host.toLowerCase(); // hostname + 非默认端口(无端口时即 hostname)
|
|
271
538
|
if (hostname === "localhost" || hostname === "127.0.0.1" || hostname === "::1" || hostname === "[::1]") {
|
|
272
539
|
return true;
|
|
273
540
|
}
|
|
274
541
|
// 原生客户端(React Native OkHttp 等)会把 Origin 设为 WS URL 自身(= 本机地址)。
|
|
275
542
|
// 这类请求 host 头与 origin 同源,放行;浏览器跨站 drive-by 的 origin 不会等于本机地址。
|
|
543
|
+
// 只比 originHost:实测 new URL("http://[::1]:80").hostname 已带方括号,
|
|
544
|
+
// 再拼 `[${hostname}]` 会得到 `[[::1]]` ⇒ 永不匹配(同族死比较,已删除);
|
|
545
|
+
// 而 hostHeader === hostname 也冗余(无端口时 parsed.host 本来就 === hostname)。
|
|
276
546
|
const hostHeader = requestHost.toLowerCase();
|
|
277
|
-
if (hostHeader &&
|
|
547
|
+
if (hostHeader && hostHeader === originHost) {
|
|
278
548
|
return true;
|
|
279
549
|
}
|
|
280
550
|
const bound = this.boundHost.toLowerCase();
|
|
281
|
-
if (bound !== "0.0.0.0" && bound !== "::" && hostname === bound) {
|
|
551
|
+
if (bound !== "0.0.0.0" && bound !== "::" && bareHost(hostname) === bareHost(bound)) {
|
|
552
|
+
// IPv6 归一:boundHost 来自 server.address()/命令行(无括号),Origin 的 hostname 带括号
|
|
282
553
|
return true;
|
|
283
554
|
}
|
|
284
555
|
const allowed = this.options.allowedOrigins ?? [];
|
|
285
|
-
return allowed.some((o) =>
|
|
286
|
-
|
|
287
|
-
|
|
556
|
+
return allowed.some((o) => this.matchesAllowedOrigin(o, hostname, parsed.port));
|
|
557
|
+
}
|
|
558
|
+
/**
|
|
559
|
+
* 白名单条目匹配:借助标准 new URL() 归一化解析 scheme/host/port。
|
|
560
|
+
* 裸 hostname 或未带 scheme 的写法统一垫入 "http://" 代理解析,避免易碎的手工切片。
|
|
561
|
+
*/
|
|
562
|
+
matchesAllowedOrigin(configured, hostname, originPort) {
|
|
563
|
+
const entry = configured.trim().toLowerCase();
|
|
564
|
+
if (!entry)
|
|
565
|
+
return false;
|
|
566
|
+
let urlStr = entry;
|
|
567
|
+
if (!urlStr.includes("://")) {
|
|
568
|
+
const colons = (urlStr.match(/:/g) || []).length;
|
|
569
|
+
if (colons > 1 && !urlStr.startsWith("[")) {
|
|
570
|
+
urlStr = `http://[${urlStr}]`;
|
|
288
571
|
}
|
|
289
|
-
|
|
290
|
-
|
|
572
|
+
else {
|
|
573
|
+
urlStr = `http://${urlStr}`;
|
|
291
574
|
}
|
|
292
|
-
}
|
|
575
|
+
}
|
|
576
|
+
try {
|
|
577
|
+
const u = new URL(urlStr);
|
|
578
|
+
const entryHost = bareHost(u.hostname);
|
|
579
|
+
const entryPort = u.port || undefined; // 默认端口规范化为空串即 undefined
|
|
580
|
+
if (entryHost !== bareHost(hostname))
|
|
581
|
+
return false;
|
|
582
|
+
if (entryPort !== undefined && entryPort !== originPort)
|
|
583
|
+
return false;
|
|
584
|
+
return true;
|
|
585
|
+
}
|
|
586
|
+
catch {
|
|
587
|
+
return false;
|
|
588
|
+
}
|
|
293
589
|
}
|
|
294
590
|
// ── WS 命令 ───────────────────────────────────────────────────────────────
|
|
295
591
|
async handleClientMessage(client, data) {
|
|
@@ -298,19 +594,38 @@ export class MobileHostServer {
|
|
|
298
594
|
command = JSON.parse(data.toString());
|
|
299
595
|
}
|
|
300
596
|
catch {
|
|
301
|
-
|
|
597
|
+
// seq 是 HostEvent 必填字段(protocol.ts:336),且客户端 host-client.ts:255 只派发
|
|
598
|
+
// typeof seq === "number" 的帧——缺 seq 会使本错误帧被客户端丢弃。与 :173-174 握手帧同用 0
|
|
599
|
+
// (已核:全客户端域无任何 seq 数值比较,0 不干扰回放语义)。
|
|
600
|
+
// 不进 EventLog:那是跳连接增量回放日志,单连接协议层错误不应回放给其他客户端。
|
|
601
|
+
this.sendFrame(client, { type: "error", code: "invalid_json", message: "Invalid JSON", seq: 0 }, "required", "error");
|
|
302
602
|
return;
|
|
303
603
|
}
|
|
304
604
|
// P3-2:分发前真正走 shared 校验(激活 validation 模块,拦截缺 type 的任意载荷)
|
|
305
605
|
try {
|
|
306
606
|
command = validateClientCommand(command);
|
|
307
607
|
}
|
|
308
|
-
catch {
|
|
309
|
-
|
|
608
|
+
catch (error) {
|
|
609
|
+
// JSON 已解析成功→可取 id:必须回 command_result 而非裸 error 事件,
|
|
610
|
+
// 否则客户端 pendingCommands 匹配不到,该命令挂满 30s 超时
|
|
611
|
+
const rawId = command?.id;
|
|
612
|
+
const replyTo = typeof rawId === "string" ? rawId : "";
|
|
613
|
+
// 文案跟随实际拒因(缺 type / id 非 string),不再硬编码 “missing type” 误报
|
|
614
|
+
const reason = error instanceof Error ? error.message : "Invalid ClientCommand";
|
|
615
|
+
this.sendFrame(client, {
|
|
616
|
+
type: "command_result",
|
|
617
|
+
in_reply_to: replyTo,
|
|
618
|
+
ok: false,
|
|
619
|
+
error: { code: "invalid_command", message: reason },
|
|
620
|
+
}, "required", "command_result");
|
|
310
621
|
return;
|
|
311
622
|
}
|
|
312
623
|
try {
|
|
313
624
|
switch (command.type) {
|
|
625
|
+
case "ping": {
|
|
626
|
+
this.sendAck(client, command, { pong: true });
|
|
627
|
+
break;
|
|
628
|
+
}
|
|
314
629
|
case "list_live_sessions": {
|
|
315
630
|
const list = await this.controller.listLiveSessions();
|
|
316
631
|
this.sendAck(client, command, list);
|
|
@@ -319,7 +634,7 @@ export class MobileHostServer {
|
|
|
319
634
|
case "load_more_history": {
|
|
320
635
|
const runner = this.controller.getSession(command.sessionId);
|
|
321
636
|
if (!runner) {
|
|
322
|
-
this.sendError(client, "session_not_found", command.id ?? "");
|
|
637
|
+
this.sendError(client, "session_not_found", undefined, command.id ?? "");
|
|
323
638
|
break;
|
|
324
639
|
}
|
|
325
640
|
const result = await runner.loadMoreHistory(command.count);
|
|
@@ -329,17 +644,20 @@ export class MobileHostServer {
|
|
|
329
644
|
case "search_history": {
|
|
330
645
|
const runner = this.controller.getSession(command.sessionId);
|
|
331
646
|
if (!runner) {
|
|
332
|
-
this.sendError(client, "session_not_found", command.id ?? "");
|
|
647
|
+
this.sendError(client, "session_not_found", undefined, command.id ?? "");
|
|
333
648
|
break;
|
|
334
649
|
}
|
|
335
|
-
|
|
650
|
+
// 服务端硬上限:客户端可传任意值(全扫 165MB 会话 + 无界结果集),不信任入参
|
|
651
|
+
const maxResults = clampCommandInt(command.maxResults, 50, MAX_SEARCH_RESULTS);
|
|
652
|
+
const previewLength = clampCommandInt(command.previewLength, 120, MAX_SEARCH_PREVIEW_LENGTH);
|
|
653
|
+
const result = await runner.searchHistory(command.keyword, maxResults, previewLength);
|
|
336
654
|
this.sendAck(client, command, result);
|
|
337
655
|
break;
|
|
338
656
|
}
|
|
339
657
|
case "list_models": {
|
|
340
658
|
const runner = this.controller.getSession(command.sessionId);
|
|
341
659
|
if (!runner) {
|
|
342
|
-
this.sendError(client, "session_not_found", command.id ?? "");
|
|
660
|
+
this.sendError(client, "session_not_found", undefined, command.id ?? "");
|
|
343
661
|
break;
|
|
344
662
|
}
|
|
345
663
|
const models = typeof runner.listModels === "function" ? runner.listModels() : [];
|
|
@@ -349,7 +667,7 @@ export class MobileHostServer {
|
|
|
349
667
|
case "list_skills": {
|
|
350
668
|
const runner = this.controller.getSession(command.sessionId);
|
|
351
669
|
if (!runner) {
|
|
352
|
-
this.sendError(client, "session_not_found", command.id ?? "");
|
|
670
|
+
this.sendError(client, "session_not_found", undefined, command.id ?? "");
|
|
353
671
|
break;
|
|
354
672
|
}
|
|
355
673
|
// 优先走 SDK resourceLoader(与 TUI 一致),回退到文件扫描
|
|
@@ -375,7 +693,7 @@ export class MobileHostServer {
|
|
|
375
693
|
case "set_model": {
|
|
376
694
|
const runner = this.controller.getSession(command.sessionId);
|
|
377
695
|
if (!runner) {
|
|
378
|
-
this.sendError(client, "session_not_found", command.id ?? "");
|
|
696
|
+
this.sendError(client, "session_not_found", undefined, command.id ?? "");
|
|
379
697
|
break;
|
|
380
698
|
}
|
|
381
699
|
if (typeof runner.setModel !== "function") {
|
|
@@ -389,7 +707,7 @@ export class MobileHostServer {
|
|
|
389
707
|
case "set_thinking": {
|
|
390
708
|
const runner = this.controller.getSession(command.sessionId);
|
|
391
709
|
if (!runner) {
|
|
392
|
-
this.sendError(client, "session_not_found", command.id ?? "");
|
|
710
|
+
this.sendError(client, "session_not_found", undefined, command.id ?? "");
|
|
393
711
|
break;
|
|
394
712
|
}
|
|
395
713
|
if (typeof runner.setThinking !== "function") {
|
|
@@ -403,7 +721,7 @@ export class MobileHostServer {
|
|
|
403
721
|
case "compact": {
|
|
404
722
|
const runner = this.controller.getSession(command.sessionId);
|
|
405
723
|
if (!runner) {
|
|
406
|
-
this.sendError(client, "session_not_found", command.id ?? "");
|
|
724
|
+
this.sendError(client, "session_not_found", undefined, command.id ?? "");
|
|
407
725
|
break;
|
|
408
726
|
}
|
|
409
727
|
if (typeof runner.compact !== "function") {
|
|
@@ -417,7 +735,7 @@ export class MobileHostServer {
|
|
|
417
735
|
case "rename_session": {
|
|
418
736
|
const runner = this.controller.getSession(command.sessionId);
|
|
419
737
|
if (!runner) {
|
|
420
|
-
this.sendError(client, "session_not_found", command.id ?? "");
|
|
738
|
+
this.sendError(client, "session_not_found", undefined, command.id ?? "");
|
|
421
739
|
break;
|
|
422
740
|
}
|
|
423
741
|
if (typeof runner.renameSession !== "function") {
|
|
@@ -450,7 +768,7 @@ export class MobileHostServer {
|
|
|
450
768
|
case "prompt": {
|
|
451
769
|
const runner = this.controller.getSession(command.sessionId);
|
|
452
770
|
if (!runner) {
|
|
453
|
-
this.sendError(client, "session_not_found", command.id ?? "");
|
|
771
|
+
this.sendError(client, "session_not_found", undefined, command.id ?? "");
|
|
454
772
|
break;
|
|
455
773
|
}
|
|
456
774
|
// P1-3:透传图片(此前被静默丢弃),非法元素显式报错而非静默丢失
|
|
@@ -459,14 +777,31 @@ export class MobileHostServer {
|
|
|
459
777
|
this.sendError(client, "invalid_image", "images 元素必须是 base64 data 与 mime 字段齐全的图片", command.id ?? "");
|
|
460
778
|
break;
|
|
461
779
|
}
|
|
462
|
-
|
|
463
|
-
|
|
780
|
+
// 方案 A 双端实时协同:
|
|
781
|
+
// 1. 优先检查当前会话所在的 cwd 是否正是当前桌面活跃的 TUI 窗口(通过 workspace-telemetry)
|
|
782
|
+
// 2. 如果是当前活跃桌面窗口,通过 teammate 跨进程信箱直接注入 steer 到桌面终端!
|
|
783
|
+
// 桌面终端屏幕立刻打字动起来并回答,写盘后由 Watcher 实时推回手机,实现真正的同屏双向同步!
|
|
784
|
+
// 3. 如果当前没有活跃桌面窗口,或者会话已在流式生成中,走已有 runner 驱动逻辑。
|
|
785
|
+
const activeTuiOwner = await this.controller.findActiveOwnerForCwd(runner.state.cwd);
|
|
786
|
+
let injectedToTui = false;
|
|
787
|
+
if (activeTuiOwner && (!images || images.length === 0)) {
|
|
788
|
+
injectedToTui = await injectMessageToActiveTui(activeTuiOwner, command.message);
|
|
789
|
+
}
|
|
790
|
+
if (!injectedToTui) {
|
|
791
|
+
if (runner.state.runState === "streaming") {
|
|
792
|
+
await runner.steer(command.message);
|
|
793
|
+
}
|
|
794
|
+
else {
|
|
795
|
+
await runner.prompt(command.message, undefined, images);
|
|
796
|
+
}
|
|
797
|
+
}
|
|
798
|
+
this.sendAck(client, command, { injectedToTui });
|
|
464
799
|
break;
|
|
465
800
|
}
|
|
466
801
|
case "steer": {
|
|
467
802
|
const runner = this.controller.getSession(command.sessionId);
|
|
468
803
|
if (!runner) {
|
|
469
|
-
this.sendError(client, "session_not_found", command.id ?? "");
|
|
804
|
+
this.sendError(client, "session_not_found", undefined, command.id ?? "");
|
|
470
805
|
break;
|
|
471
806
|
}
|
|
472
807
|
await runner.steer(command.message);
|
|
@@ -496,7 +831,7 @@ export class MobileHostServer {
|
|
|
496
831
|
case "follow_up": {
|
|
497
832
|
const runner = this.controller.getSession(command.sessionId);
|
|
498
833
|
if (!runner) {
|
|
499
|
-
this.sendError(client, "session_not_found", command.id ?? "");
|
|
834
|
+
this.sendError(client, "session_not_found", undefined, command.id ?? "");
|
|
500
835
|
break;
|
|
501
836
|
}
|
|
502
837
|
await runner.followUp(command.message);
|
|
@@ -506,7 +841,7 @@ export class MobileHostServer {
|
|
|
506
841
|
case "abort": {
|
|
507
842
|
const runner = this.controller.getSession(command.sessionId);
|
|
508
843
|
if (!runner) {
|
|
509
|
-
this.sendError(client, "session_not_found", command.id ?? "");
|
|
844
|
+
this.sendError(client, "session_not_found", undefined, command.id ?? "");
|
|
510
845
|
break;
|
|
511
846
|
}
|
|
512
847
|
await runner.abort();
|
|
@@ -519,7 +854,7 @@ export class MobileHostServer {
|
|
|
519
854
|
this.sendAck(client, command, {});
|
|
520
855
|
}
|
|
521
856
|
else {
|
|
522
|
-
this.sendError(client, "request_not_found", command.id ?? "");
|
|
857
|
+
this.sendError(client, "request_not_found", undefined, command.id ?? "");
|
|
523
858
|
}
|
|
524
859
|
break;
|
|
525
860
|
}
|
|
@@ -537,22 +872,22 @@ export class MobileHostServer {
|
|
|
537
872
|
case "get_snapshot": {
|
|
538
873
|
const runner = this.controller.getSession(command.sessionId);
|
|
539
874
|
if (!runner) {
|
|
540
|
-
this.sendError(client, "session_not_found", command.id ?? "");
|
|
875
|
+
this.sendError(client, "session_not_found", undefined, command.id ?? "");
|
|
541
876
|
break;
|
|
542
877
|
}
|
|
543
878
|
const snapshot = runner.snapshot();
|
|
544
|
-
|
|
879
|
+
this.sendFrame(client, {
|
|
545
880
|
type: "command_result",
|
|
546
881
|
in_reply_to: command.id ?? "",
|
|
547
882
|
ok: true,
|
|
548
883
|
result: snapshot,
|
|
549
|
-
})
|
|
884
|
+
}, "required", "command_result");
|
|
550
885
|
break;
|
|
551
886
|
}
|
|
552
887
|
case "get_session_usage": {
|
|
553
888
|
const runner = this.controller.getSession(command.sessionId);
|
|
554
889
|
if (!runner) {
|
|
555
|
-
this.sendError(client, "session_not_found", command.id ?? "");
|
|
890
|
+
this.sendError(client, "session_not_found", undefined, command.id ?? "");
|
|
556
891
|
break;
|
|
557
892
|
}
|
|
558
893
|
const usage = typeof runner.getUsage === "function" ? await runner.getUsage() : { entries: 0, input: 0, output: 0, cacheRead: 0, cacheWrite: 0, reasoning: 0, totalTokens: 0, cost: 0 };
|
|
@@ -561,30 +896,75 @@ export class MobileHostServer {
|
|
|
561
896
|
break;
|
|
562
897
|
}
|
|
563
898
|
default:
|
|
564
|
-
this.sendError(client, "unsupported_command", command.id ?? "");
|
|
899
|
+
this.sendError(client, "unsupported_command", undefined, command.id ?? "");
|
|
565
900
|
}
|
|
566
901
|
}
|
|
567
902
|
catch (error) {
|
|
568
903
|
const message = error instanceof Error ? error.message : String(error);
|
|
569
|
-
|
|
904
|
+
// 必须带 in_reply_to:客户端靠它匹配 pendingCommands,空值会让命令挂满 30s 超时
|
|
905
|
+
this.sendError(client, "command_failed", message, command.id ?? "");
|
|
570
906
|
}
|
|
571
907
|
}
|
|
572
908
|
sendAck(client, command, result) {
|
|
573
|
-
|
|
909
|
+
this.sendFrame(client, {
|
|
574
910
|
type: "command_result",
|
|
575
911
|
in_reply_to: command.id ?? "",
|
|
576
912
|
ok: true,
|
|
577
913
|
result,
|
|
578
|
-
})
|
|
914
|
+
}, "required", "command_result");
|
|
579
915
|
}
|
|
580
916
|
sendError(client, code, message, replyTo = "") {
|
|
581
|
-
|
|
917
|
+
this.sendFrame(client, {
|
|
582
918
|
type: "command_result",
|
|
583
919
|
in_reply_to: replyTo,
|
|
584
920
|
ok: false,
|
|
585
|
-
|
|
586
|
-
|
|
921
|
+
// 回传网络的唯一脉络:必须带上本实例 token 作为已知密串(底层错误文本可能回显含 ?token= 的 URL)
|
|
922
|
+
error: { code, message: message ? sanitizeWsErrorMessage(message, [this.options.token ?? ""]) : code },
|
|
923
|
+
}, "required", "command_result");
|
|
924
|
+
}
|
|
925
|
+
}
|
|
926
|
+
const MAX_SEARCH_RESULTS = 200;
|
|
927
|
+
const MAX_SEARCH_PREVIEW_LENGTH = 400;
|
|
928
|
+
/**
|
|
929
|
+
* 命令参数钳制(导出以供回归测试直接钉本函数,避免测试复制一份算式假通过):
|
|
930
|
+
* 非有限值(NaN/Infinity/null/字符串形态)落默认值,有限值夹到 [1, max]。
|
|
931
|
+
*/
|
|
932
|
+
export function clampCommandInt(raw, dflt, max) {
|
|
933
|
+
return Number.isFinite(raw) ? Math.min(Math.max(1, Math.floor(raw)), max) : dflt;
|
|
934
|
+
}
|
|
935
|
+
/**
|
|
936
|
+
* 去 IPv6 方括号。实测 new URL("http://[::1]:80").hostname === "[::1]"(带括号),
|
|
937
|
+
* 而 server.address().address / 命令行传入的绑定地址不带括号,两侧必须归一后才能比较。
|
|
938
|
+
*/
|
|
939
|
+
function bareHost(host) {
|
|
940
|
+
return host.replace(/^\[(.*)\]$/, "$1");
|
|
941
|
+
}
|
|
942
|
+
/**
|
|
943
|
+
* WS/日志/响应用错误文本脱敏:限长 + 去换行 + 抹掉已知敏感根。
|
|
944
|
+
* 背景:command_failed 曾把底层 fs/SDK 的 Error.message(含用户目录绝对路径)原样回传网络。
|
|
945
|
+
*
|
|
946
|
+
* 设计:只替换「已知敏感值」(token 本体、home 目录、cwd、盘符路径),不用泛用正则猜“这段像不像路径”——
|
|
947
|
+
* 后者会把 `/api/v1/users`、`http://host/api/v1` 这类诊断信息误删(独立评审 RV-002)。
|
|
948
|
+
* 完整堆栈只进服务端日志的结构化字段,不经此函数回传客户端。
|
|
949
|
+
*/
|
|
950
|
+
export function sanitizeWsErrorMessage(raw, secrets = []) {
|
|
951
|
+
if (!raw)
|
|
952
|
+
return "";
|
|
953
|
+
let out = raw.replace(/[ \t\r\n]+/g, " ").slice(0, 200);
|
|
954
|
+
// 1) 显式已知密串(如本实例 token):只要出现在文本里就抹掉,不限形态
|
|
955
|
+
for (const secret of secrets) {
|
|
956
|
+
if (secret && secret.length >= 8)
|
|
957
|
+
out = out.split(secret).join("[redacted]");
|
|
958
|
+
}
|
|
959
|
+
// 2) 用户主目录与进程 cwd:局域网工具里这两个值会泄露用户名/项目位置
|
|
960
|
+
for (const root of [homedir(), process.cwd()]) {
|
|
961
|
+
if (root && root.length > 1)
|
|
962
|
+
out = out.split(root).join("~");
|
|
587
963
|
}
|
|
964
|
+
// 3) Windows 盘符路径形态无歧义,可直接折叠到文件名。负向后顾防 URL scheme 误伤:
|
|
965
|
+
// "http://host/x" 里的 p:// 会被 [A-Za-z]:[\/] 命中,但它前面是字母 t → 排除
|
|
966
|
+
out = out.replace(/(?<![A-Za-z0-9])[A-Za-z]:[\\/][^\s"']*/g, (m) => m.split(/[\\/]/).filter(Boolean).pop() ?? "[path]");
|
|
967
|
+
return out;
|
|
588
968
|
}
|
|
589
969
|
function writeJson(response, status, body) {
|
|
590
970
|
response.writeHead(status, {
|