@mearl/client 2.0.2 → 2.2.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/socket.js CHANGED
@@ -7,6 +7,7 @@
7
7
  * 当 Socket 文件不存在(ENOENT)时,自动尝试拉起 native host,稍后重试连接。
8
8
  */
9
9
  import net from 'node:net';
10
+ import fs from 'node:fs';
10
11
  import os from 'node:os';
11
12
  import path from 'node:path';
12
13
  import { createRequire } from 'node:module';
@@ -15,6 +16,7 @@ import { listLiveBrowsers, findBrowser, describeBrowser } from './browserRegistr
15
16
  import { resolveActionTimeoutSec } from './actionTimeouts.js';
16
17
  const require = createRequire(import.meta.url);
17
18
  export const CLIENT_VERSION = require('../package.json').version;
19
+ const MAX_SOCKET_BUFFER_SIZE = 64 * 1024 * 1024;
18
20
  /**
19
21
  * Legacy well-known socket. New native-hosts publish per-browser sockets in the
20
22
  * registry and we address those; this path is a fallback for pre-registry hosts
@@ -79,8 +81,8 @@ let spawnAttempted = false;
79
81
  * Inventory-only hosts serve one browser_list request without publishing a fake CDP target.
80
82
  * Returns true if a spawn was attempted (regardless of success).
81
83
  */
82
- async function trySpawnNativeHost(registerBrowser = true) {
83
- if (registerBrowser && spawnAttempted)
84
+ async function trySpawnNativeHost(registerBrowser = true, retryPreviousAttempt = false) {
85
+ if (registerBrowser && spawnAttempted && !retryPreviousAttempt)
84
86
  return false;
85
87
  if (registerBrowser)
86
88
  spawnAttempted = true;
@@ -128,20 +130,37 @@ export function invoke(action, data = {}, options = {}) {
128
130
  return Promise.reject(error);
129
131
  }
130
132
  }
131
- const { timeoutSec = resolveActionTimeoutSec(action, data), transport, browser } = options;
133
+ const { timeoutSec = resolveActionTimeoutSec(action, data), transport, browser, controlSource = 'local', } = options;
132
134
  // 统一解析 payload 顶层字段中的 `@<path>` 文件引用(所有 action 通用)
133
135
  const resolvedData = resolveFileRefs(data);
134
136
  if (action === 'browser_list') {
135
- return dispatchInvoke(action, resolvedData, timeoutSec, undefined, undefined).then(decorateBrowserListResult);
137
+ return dispatchInvoke(action, resolvedData, timeoutSec, undefined, undefined, controlSource).then(decorateBrowserListResult);
136
138
  }
137
139
  const selector = browser ?? defaultBrowser ?? process.env.MEARL_BROWSER ?? undefined;
138
- return dispatchInvoke(action, resolvedData, timeoutSec, transport, selector);
140
+ return dispatchInvoke(action, resolvedData, timeoutSec, transport, selector, controlSource);
139
141
  }
140
142
  function isConnError(error) {
141
143
  const code = error?.code;
142
144
  return code === 'ENOENT' || code === 'ECONNREFUSED';
143
145
  }
144
- async function dispatchInvoke(action, data, timeoutSec, transport, selector) {
146
+ function isSocketSandboxError(error) {
147
+ return error?.code === 'EPERM';
148
+ }
149
+ async function dispatchInvoke(action, data, timeoutSec, transport, selector, controlSource) {
150
+ const configuredSocketPath = process.env.MEARL_SOCKET_PATH?.trim();
151
+ if (!selector && configuredSocketPath) {
152
+ try {
153
+ return await invokeOnce(action, data, timeoutSec, transport, configuredSocketPath, controlSource);
154
+ }
155
+ catch (error) {
156
+ if (isConnError(error) || isSocketSandboxError(error)) {
157
+ throw new Error(formatConnectError(error, configuredSocketPath), {
158
+ cause: error,
159
+ });
160
+ }
161
+ throw error;
162
+ }
163
+ }
145
164
  const browsers = listLiveBrowsers();
146
165
  // Explicit target: must match a live browser; do not silently fall back.
147
166
  if (selector) {
@@ -150,10 +169,10 @@ async function dispatchInvoke(action, data, timeoutSec, transport, selector) {
150
169
  throw new Error(formatNoMatchError(selector, browsers));
151
170
  }
152
171
  try {
153
- return await invokeOnce(action, data, timeoutSec, transport, match.socketPath);
172
+ return await invokeOnce(action, data, timeoutSec, transport, match.socketPath, controlSource);
154
173
  }
155
174
  catch (error) {
156
- if (isConnError(error)) {
175
+ if (isConnError(error) || isSocketSandboxError(error)) {
157
176
  throw new Error(formatConnectError(error, match.socketPath), {
158
177
  cause: error,
159
178
  });
@@ -165,42 +184,56 @@ async function dispatchInvoke(action, data, timeoutSec, transport, selector) {
165
184
  // stale socket, fall through to the legacy path + auto-spawn.
166
185
  if (browsers.length > 0) {
167
186
  try {
168
- return await invokeOnce(action, data, timeoutSec, transport, browsers[0].socketPath);
187
+ return await invokeOnce(action, data, timeoutSec, transport, browsers[0].socketPath, controlSource);
169
188
  }
170
189
  catch (error) {
190
+ if (isSocketSandboxError(error)) {
191
+ throw new Error(formatConnectError(error, browsers[0].socketPath), { cause: error });
192
+ }
171
193
  if (!isConnError(error))
172
194
  throw error;
173
195
  }
174
196
  }
175
- return invokeViaLegacy(action, data, timeoutSec, transport, /* isRetry */ false);
197
+ return invokeViaLegacy(action, data, timeoutSec, transport, controlSource, /* isRetry */ false);
176
198
  }
177
199
  /** Legacy well-known socket, auto-spawning a native-host on ENOENT and retrying once. */
178
- async function invokeViaLegacy(action, data, timeoutSec, transport, isRetry) {
200
+ async function invokeViaLegacy(action, data, timeoutSec, transport, controlSource, isRetry) {
179
201
  try {
180
- return await invokeOnce(action, data, timeoutSec, transport, SOCKET_PATH);
202
+ return await invokeOnce(action, data, timeoutSec, transport, SOCKET_PATH, controlSource);
181
203
  }
182
204
  catch (error) {
183
205
  const err = error;
184
- if (err.code === 'ENOENT' && !isRetry) {
185
- const spawned = await trySpawnNativeHost(action !== 'browser_list');
206
+ const shouldAttemptSpawn = err.code === 'ENOENT' || (action === 'browser_release' && err.code === 'ECONNREFUSED');
207
+ if (shouldAttemptSpawn && !isRetry) {
208
+ const spawned = await trySpawnNativeHost(action !== 'browser_list', action === 'browser_release');
186
209
  if (spawned) {
187
210
  // Wait for the native host to start listening; persistent hosts also publish their registry record.
188
211
  await new Promise(r => setTimeout(r, 1200));
189
212
  const browsers = listLiveBrowsers();
190
213
  if (browsers.length > 0) {
191
- return invokeOnce(action, data, timeoutSec, transport, browsers[0].socketPath);
214
+ return invokeOnce(action, data, timeoutSec, transport, browsers[0].socketPath, controlSource);
192
215
  }
193
- return invokeViaLegacy(action, data, timeoutSec, transport, /* isRetry */ true);
216
+ return invokeViaLegacy(action, data, timeoutSec, transport, controlSource,
217
+ /* isRetry */ true);
194
218
  }
195
219
  }
196
- if (err instanceof Error && !isConnError(err))
220
+ if (err instanceof Error && !isConnError(err) && !isSocketSandboxError(err))
197
221
  throw err;
198
222
  throw new Error(formatConnectError(err, SOCKET_PATH), { cause: error });
199
223
  }
200
224
  }
201
- function invokeOnce(action, data, timeoutSec, transport, socketPath) {
225
+ function invokeOnce(action, data, timeoutSec, transport, socketPath, controlSource) {
202
226
  const id = `${Date.now()}_${Math.random().toString(36).slice(2, 10)}`;
203
227
  return new Promise((resolve, reject) => {
228
+ if (process.platform !== 'win32' && fs.existsSync(socketPath) && process.getuid) {
229
+ const ownerUid = fs.statSync(socketPath).uid;
230
+ if (ownerUid !== process.getuid()) {
231
+ const error = new Error(`拒绝连接不属于当前用户的 Socket: ${socketPath}`);
232
+ Object.assign(error, { code: 'EACCES' });
233
+ reject(error);
234
+ return;
235
+ }
236
+ }
204
237
  const socket = net.createConnection(socketPath);
205
238
  let settled = false;
206
239
  let buffer = '';
@@ -220,13 +253,23 @@ function invokeOnce(action, data, timeoutSec, transport, socketPath) {
220
253
  settle(new Error(`请求超时,已等待 ${timeoutSec} 秒`));
221
254
  }, timeoutSec * 1000);
222
255
  socket.on('connect', () => {
223
- const message = { id, action, data, version: CLIENT_VERSION };
256
+ const message = {
257
+ id,
258
+ action,
259
+ data,
260
+ version: CLIENT_VERSION,
261
+ controlSource,
262
+ };
224
263
  if (transport)
225
264
  message.transport = transport;
226
265
  socket.write(JSON.stringify(message) + '\n');
227
266
  });
228
267
  socket.on('data', chunk => {
229
268
  buffer += chunk.toString('utf-8');
269
+ if (Buffer.byteLength(buffer, 'utf-8') > MAX_SOCKET_BUFFER_SIZE) {
270
+ settle(new Error(`Socket 响应超过 ${MAX_SOCKET_BUFFER_SIZE} 字节限制`));
271
+ return;
272
+ }
230
273
  let newlineIndex;
231
274
  while ((newlineIndex = buffer.indexOf('\n')) !== -1) {
232
275
  const line = buffer.slice(0, newlineIndex);
@@ -289,6 +332,14 @@ function formatConnectError(error, socketPath) {
289
332
  installHint,
290
333
  ].join('\n');
291
334
  }
335
+ if (error.code === 'EPERM') {
336
+ return [
337
+ `Socket 连接被执行环境拦截: ${socketPath}`,
338
+ '当前进程没有访问该 Mearl Unix Socket 的权限(EPERM)。',
339
+ '如果命令运行在 Agent 沙箱中,请为这个 Socket 配置精确的 Unix Socket 白名单后重试。',
340
+ '无需重新安装浏览器插件,也不要使用 sudo 或改写 Socket 权限。',
341
+ ].join('\n');
342
+ }
292
343
  return [`Socket 连接失败: ${error.message}`, '', installHint].join('\n');
293
344
  }
294
345
  /** Error message when an explicit --browser selector matches no live browser. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mearl/client",
3
- "version": "2.0.2",
3
+ "version": "2.2.0",
4
4
  "description": "Client SDK & CLI for Mearl — communicate with Chrome Extension via Unix Socket",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -36,9 +36,9 @@
36
36
  "typescript": "^5.8.3"
37
37
  },
38
38
  "scripts": {
39
- "build": "rm -rf dist/ && tsc && chmod +x dist/cli.js",
39
+ "build": "node ../../scripts/generate-client-action-protocol.mjs --check && rm -rf dist/ && tsc && chmod +x dist/cli.js",
40
40
  "dev": "tsc --watch",
41
- "typecheck": "tsc --noEmit",
41
+ "typecheck": "node ../../scripts/generate-client-action-protocol.mjs --check && tsc --noEmit",
42
42
  "test": "vitest run"
43
43
  }
44
44
  }