@mearl/client 2.0.2 → 2.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/README.md CHANGED
@@ -58,6 +58,7 @@ mearl browser_launch --payload '{"name":"account-b","query":"test_account"}'
58
58
  mearl browser_launch --payload '{"name":"cookie-copy","copyCookieDomains":["example.com"]}'
59
59
  mearl browser_launch --payload '{"name":"figma","userAgentMode":"desktop","copyCookieDomains":["figma.com"]}'
60
60
  mearl browser_list
61
+ mearl browser_release
61
62
  ```
62
63
 
63
64
  **通用选项:**
@@ -69,6 +70,7 @@ mearl browser_list
69
70
  | `--timeout <seconds>` | 请求超时时间 |
70
71
  | `--compact` | 紧凑输出 |
71
72
  | `--output <path>` | 将结果写入文件(如截图) |
73
+ | `--browser <id\|名称>` | 指定目标浏览器 |
72
74
 
73
75
  ## 支持的操作
74
76
 
@@ -80,11 +82,11 @@ mearl browser_list
80
82
  | 标签页 | `tab_open` `tab_close` `tab_list` |
81
83
  | 页面操作 | `page_click` `page_type` `page_scroll` `page_eval` `page_press` `page_wait` `page_navigate` `page_upload` |
82
84
  | 页面感知 | `page_snapshot` `page_screenshot` `page_selected_element` `page_frames` |
83
- | 环境模拟 | `set_device_emulation` `set_timezone` |
85
+ | 环境与状态 | `set_device_emulation` `set_timezone` `get_cookie` `set_cookie` |
84
86
  | 用户信息 | `get_user_info` |
85
87
  | 录制 | `record_start` `record_stop` |
86
88
  | TDBank | `tdbank_account` |
87
- | 浏览器 | `browser_list` `browser_launch` `browser_close` |
89
+ | 浏览器 | `browser_list` `browser_release` `browser_launch` `browser_close` |
88
90
 
89
91
  页面交互动作(`page_click` / `page_type` / `page_hover` / `page_scroll` / `page_press` / `page_upload`)默认内置观察:同一次调用内执行动作并等待异步稳定,返回 `{ action, observation }`;传 `observe: false` 可关闭观察仅执行裸动作。观察结果不替代完整页面理解:`mode: "delta"` 只返回主文档中的 `effects.notifications`、`effects.interactives` 和 `effects.focus` 等高置信度信号,并明确携带 `scope: "main-document"`;可交互节点会尽量携带真实 backend `node.ref`,后续动作优先使用 ref,缺少 ref 时使用 `node.selector`。`mode: "navigation"` 且 `ready: true` 时,页面已通过网络静默、骨架状态或保守的内容稳定判定,可在新页面重建快照。通常仅在 `fullSnapshotRecommended` 为 true 时根据 `snapshotReasons` 回退;滚动后若下一步需要读取新视口内容,可按需获取 viewport 快照。`page_eval` 默认裸执行,只有显式传入 `observe` 对象时才启用观察。
90
92
 
@@ -94,6 +96,8 @@ mearl browser_list
94
96
 
95
97
  `browser_list` 统一列出普通浏览器和托管浏览器。`type` 区分 `regular` / `managed`,`status` 区分 `connected` / `running_disconnected` / `stopped`;只有 `connected` 的浏览器可作为操作目标。
96
98
 
99
+ `browser_release` 释放当前目标浏览器的 debugger/CDP 控制和会话级临时状态,保留浏览器及全部标签页;后续浏览器操作可自动重新建立控制。
100
+
97
101
  托管浏览器使用独立 Profile 和动态 CDP 端口。控制浏览器需先登录 TDBank;生成的 SSO 地址在本地内部传递,新实例可以使用 `headless: true`(默认)完成测试账号登录。`copyCookieDomains` 可把控制浏览器指定域的 Cookie 复制到新实例,Cookie 值不会出现在命令结果或日志中;`userAgentMode: "desktop"` 可让实例使用匹配本机 Chrome 版本的桌面 UA。完整设计见 [托管浏览器与 TDBank 多账号设计](../../docs/managed-browsers.md)。
98
102
 
99
103
  ## 架构
@@ -35,6 +35,8 @@ export function resolveActionTimeoutSec(action, data, fallback = 15) {
35
35
  }
36
36
  if (action === 'tab_open')
37
37
  return Math.max(fallback, 40);
38
+ if (action === 'set_cookie' && data.reload === true)
39
+ return Math.max(fallback, 40);
38
40
  if (action === 'browser_launch')
39
41
  return Math.max(fallback, 90);
40
42
  if (action === 'browser_close' || action === 'tdbank_account')
package/dist/cli.js CHANGED
@@ -41,13 +41,16 @@ const CATEGORIES = [
41
41
  title: '页面感知',
42
42
  names: ['page_snapshot', 'page_screenshot', 'page_selected_element', 'page_frames'],
43
43
  },
44
- { title: '环境模拟', names: ['set_device_emulation', 'set_timezone'] },
44
+ {
45
+ title: '环境与 Cookie',
46
+ names: ['set_device_emulation', 'set_timezone', 'get_cookie', 'set_cookie'],
47
+ },
45
48
  { title: '用户信息', names: ['get_user_info'] },
46
49
  { title: '录制', names: ['record_start', 'record_stop'] },
47
50
  { title: 'TDBank', names: ['tdbank_account'] },
48
51
  {
49
52
  title: '浏览器',
50
- names: ['browser_list', 'browser_launch', 'browser_close'],
53
+ names: ['browser_list', 'browser_release', 'browser_launch', 'browser_close'],
51
54
  },
52
55
  ];
53
56
  function printUsage() {
@@ -14,5 +14,5 @@ export interface CommandDef {
14
14
  examples?: string[];
15
15
  }
16
16
  export declare const COMMANDS: CommandDef[];
17
- export type KnownAction = 'get_requests' | 'get_logs' | 'get_events' | 'get_api_schema' | 'set_mock' | 'get_mocks' | 'set_rule' | 'get_rules' | 'send_request' | 'send_mtop_request' | 'tab_open' | 'tab_close' | 'tab_list' | 'page_click' | 'page_type' | 'page_hover' | 'page_scroll' | 'page_eval' | 'page_press' | 'page_wait' | 'page_navigate' | 'page_upload' | 'page_snapshot' | 'page_screenshot' | 'page_selected_element' | 'page_frames' | 'set_device_emulation' | 'set_timezone' | 'record_start' | 'record_stop' | 'request_domain_permission' | 'get_user_info' | 'browser_list' | 'browser_launch' | 'browser_close' | 'run_actions';
17
+ export type KnownAction = 'get_requests' | 'get_logs' | 'get_events' | 'get_api_schema' | 'set_mock' | 'get_mocks' | 'set_rule' | 'get_rules' | 'send_request' | 'send_mtop_request' | 'tab_open' | 'tab_close' | 'tab_list' | 'page_click' | 'page_type' | 'page_hover' | 'page_scroll' | 'page_eval' | 'page_press' | 'page_wait' | 'page_navigate' | 'page_upload' | 'page_snapshot' | 'page_screenshot' | 'page_selected_element' | 'page_frames' | 'set_device_emulation' | 'set_timezone' | 'get_cookie' | 'set_cookie' | 'record_start' | 'record_stop' | 'request_domain_permission' | 'get_user_info' | 'browser_list' | 'browser_release' | 'browser_launch' | 'browser_close' | 'run_actions';
18
18
  export declare const COMMAND_MAP: Map<string, CommandDef>;
package/dist/commands.js CHANGED
@@ -23,6 +23,7 @@ export const COMMANDS = [
23
23
  name: 'get_logs',
24
24
  description: '获取浏览器控制台日志',
25
25
  params: [
26
+ { name: 'tabId', type: 'number', description: '目标标签页 ID,从 tab_list 获取' },
26
27
  { name: 'limit', type: 'number', description: '返回条数,默认 20' },
27
28
  { name: 'level', type: 'string', description: '按日志级别过滤:log/info/warn/error' },
28
29
  { name: 'filter', type: 'string', description: '按内容过滤,支持正则' },
@@ -69,6 +70,7 @@ export const COMMANDS = [
69
70
  name: 'set_mock',
70
71
  description: '设置 API mock 数据',
71
72
  params: [
73
+ { name: 'tabId', type: 'number', description: '目标标签页 ID,从 tab_list 获取' },
72
74
  { name: 'apiName', type: 'string', required: true, description: 'API 名称或正则表达式' },
73
75
  { name: 'mockData', type: 'object', description: '完整 mock 响应数据(与 fields 二选一)' },
74
76
  {
@@ -100,6 +102,10 @@ export const COMMANDS = [
100
102
  {
101
103
  name: 'get_mocks',
102
104
  description: '查看当前生效的 mock 列表',
105
+ params: [
106
+ { name: 'tabId', type: 'number', description: '目标标签页 ID,从 tab_list 获取' },
107
+ { name: 'apiName', type: 'string', description: '按 API 名称或正则表达式过滤' },
108
+ ],
103
109
  examples: ['get_mocks'],
104
110
  },
105
111
  {
@@ -147,6 +153,11 @@ export const COMMANDS = [
147
153
  name: 'tdbank_account',
148
154
  description: 'TDBank 账号操作:获取当前账号、账号列表、切换账号、快速借用并切换账号',
149
155
  params: [
156
+ {
157
+ name: 'tabId',
158
+ type: 'number',
159
+ description: 'CDP 模式下可指定用于读取 Cookie 的标签页 ID',
160
+ },
150
161
  {
151
162
  name: 'operation',
152
163
  type: '"current" | "list" | "switch" | "borrow"',
@@ -171,6 +182,11 @@ export const COMMANDS = [
171
182
  description: '列出所有浏览器及其连接状态、类型和托管实例信息',
172
183
  examples: ['browser_list'],
173
184
  },
185
+ {
186
+ name: 'browser_release',
187
+ description: '释放 Agent 对当前浏览器的调试控制,保留浏览器和全部标签页',
188
+ examples: ['browser_release', 'browser_release --browser chrome-a'],
189
+ },
174
190
  {
175
191
  name: 'browser_launch',
176
192
  description: '启动独立 Chrome,可在新实例中通过 TDBank 登录指定账号',
@@ -630,11 +646,13 @@ export const COMMANDS = [
630
646
  {
631
647
  name: 'record_start',
632
648
  description: '开始录制用户操作',
649
+ params: [{ name: 'tabId', type: 'number', description: '目标标签页 ID,从 tab_list 获取' }],
633
650
  examples: ['record_start'],
634
651
  },
635
652
  {
636
653
  name: 'record_stop',
637
654
  description: '停止录制并返回完整结果(操作 + 关联请求);未在录制时不报错,返回 wasRecording: false',
655
+ params: [{ name: 'tabId', type: 'number', description: '目标标签页 ID,从 tab_list 获取' }],
638
656
  examples: ['record_stop'],
639
657
  },
640
658
  // ── 移动端模拟 ────────────────────────────────────────────────────────────
@@ -685,6 +703,59 @@ export const COMMANDS = [
685
703
  `set_timezone --payload '{"tabId":12345,"timezone":""}'`,
686
704
  ],
687
705
  },
706
+ {
707
+ name: 'get_cookie',
708
+ description: '读取当前标签页的一个指定非分区 Cookie;拒绝返回 HttpOnly Cookie',
709
+ params: [
710
+ { name: 'tabId', type: 'number', required: true, description: '目标标签页 ID' },
711
+ {
712
+ name: 'name',
713
+ type: 'string',
714
+ required: true,
715
+ description: '要读取的 Cookie 名称;不支持列举全部 Cookie',
716
+ },
717
+ {
718
+ name: 'domain',
719
+ type: 'string',
720
+ description: '可选精确域过滤,只允许当前 host 或其父域',
721
+ },
722
+ { name: 'path', type: 'string', description: '可选精确 path 过滤' },
723
+ ],
724
+ examples: [
725
+ `get_cookie --payload '{"tabId":12345,"name":"feature_flag"}'`,
726
+ `get_cookie --payload '{"tabId":12345,"name":"edith_switch_br.rx-vehicle-search_home","domain":"taobao.com"}'`,
727
+ ],
728
+ },
729
+ {
730
+ name: 'set_cookie',
731
+ description: '设置当前标签页域下的一个指定非分区 session Cookie,可选刷新页面',
732
+ params: [
733
+ { name: 'tabId', type: 'number', required: true, description: '目标标签页 ID' },
734
+ {
735
+ name: 'name',
736
+ type: 'string',
737
+ required: true,
738
+ description: 'Cookie 名称',
739
+ },
740
+ {
741
+ name: 'value',
742
+ type: 'string',
743
+ required: true,
744
+ description: 'Cookie 值,可传空字符串;空字符串不会删除 Cookie',
745
+ },
746
+ {
747
+ name: 'domain',
748
+ type: 'string',
749
+ description: 'Cookie 域,默认当前 host;只允许当前 host 或其父域',
750
+ },
751
+ { name: 'path', type: 'string', description: 'Cookie path,默认 /' },
752
+ { name: 'reload', type: 'boolean', description: '设置后是否刷新页面,默认 false' },
753
+ ],
754
+ examples: [
755
+ `set_cookie --payload '{"tabId":12345,"name":"feature_flag","value":"enabled"}'`,
756
+ `set_cookie --payload '{"tabId":12345,"name":"edith_switch_br.rx-vehicle-search_home","value":"3.6.63","domain":"taobao.com","reload":true}'`,
757
+ ],
758
+ },
688
759
  // ── 其他 ──────────────────────────────────────────────────────────────────
689
760
  {
690
761
  name: 'page_frames',
package/dist/index.d.ts CHANGED
@@ -31,6 +31,7 @@ export interface GetRequestsParams {
31
31
  }
32
32
  export declare function getRequests(params?: GetRequestsParams): Promise<any>;
33
33
  export interface GetLogsParams {
34
+ tabId?: number;
34
35
  level?: 'log' | 'warn' | 'error' | 'info' | 'all';
35
36
  since?: number;
36
37
  limit?: number;
@@ -49,6 +50,7 @@ export interface GetEventsParams {
49
50
  }
50
51
  export declare function getEvents(params?: GetEventsParams): Promise<any>;
51
52
  export interface SetMockParams {
53
+ tabId?: number;
52
54
  apiName: string;
53
55
  mockData?: any;
54
56
  fields?: Array<{
@@ -63,6 +65,7 @@ export interface SetMockParams {
63
65
  }
64
66
  export declare function setMock(params: SetMockParams): Promise<any>;
65
67
  export interface GetMocksParams {
68
+ tabId?: number;
66
69
  apiName?: string;
67
70
  }
68
71
  export declare function getMocks(params?: GetMocksParams): Promise<any>;
@@ -154,6 +157,23 @@ export interface SelectBrowserResult {
154
157
  note?: string;
155
158
  }
156
159
  export declare function selectBrowser(params?: SelectBrowserParams): Promise<SelectBrowserResult>;
160
+ export interface BrowserReleaseFailure {
161
+ tabId: number;
162
+ stage: string;
163
+ error: string;
164
+ }
165
+ export interface BrowserReleaseResult {
166
+ released: boolean;
167
+ hadActiveControl: boolean;
168
+ transports: Array<'extension' | 'cdp'>;
169
+ affectedTabIds: number[];
170
+ failures?: BrowserReleaseFailure[];
171
+ }
172
+ /**
173
+ * 释放当前目标浏览器的 debugger/CDP 控制,保留浏览器和全部标签页。
174
+ * 下一次浏览器操作仍可自动重新建立控制。
175
+ */
176
+ export declare function browserRelease(): Promise<BrowserReleaseResult>;
157
177
  export interface BrowserLaunchParams {
158
178
  name: string;
159
179
  headless?: boolean;
@@ -418,6 +438,47 @@ export interface SetTimezoneResult {
418
438
  timezone: string | null;
419
439
  }
420
440
  export declare function setTimezone(params: SetTimezoneParams): Promise<SetTimezoneResult>;
441
+ export interface GetCookieParams {
442
+ tabId: number;
443
+ /** Exact non-partitioned cookie name. HttpOnly cookies cannot be read. */
444
+ name: string;
445
+ /** Optional exact domain filter. */
446
+ domain?: string;
447
+ /** Optional exact path filter. */
448
+ path?: string;
449
+ }
450
+ export interface GetCookieResult {
451
+ tabId?: number;
452
+ url: string;
453
+ name: string;
454
+ found: boolean;
455
+ value?: string;
456
+ domain?: string;
457
+ path?: string;
458
+ }
459
+ export declare function getCookie(params: GetCookieParams): Promise<GetCookieResult>;
460
+ export interface SetCookieParams {
461
+ tabId: number;
462
+ /** Cookie name. */
463
+ name: string;
464
+ /** Cookie value. An empty value does not delete the cookie. */
465
+ value: string;
466
+ /** Cookie domain; defaults to the current host. */
467
+ domain?: string;
468
+ /** Cookie path; defaults to "/". */
469
+ path?: string;
470
+ /** Reload the page after setting the cookie. Defaults to false. */
471
+ reload?: boolean;
472
+ }
473
+ export interface SetCookieResult {
474
+ tabId?: number;
475
+ url: string;
476
+ name: string;
477
+ domain: string;
478
+ path: string;
479
+ reloaded: boolean;
480
+ }
481
+ export declare function setCookie(params: SetCookieParams): Promise<SetCookieResult>;
421
482
  export interface GetUserInfoParams {
422
483
  detail?: boolean;
423
484
  }
package/dist/index.js CHANGED
@@ -50,6 +50,13 @@ export function browserList() {
50
50
  export function selectBrowser(params = {}) {
51
51
  return invoke('browser_select_browser', params);
52
52
  }
53
+ /**
54
+ * 释放当前目标浏览器的 debugger/CDP 控制,保留浏览器和全部标签页。
55
+ * 下一次浏览器操作仍可自动重新建立控制。
56
+ */
57
+ export function browserRelease() {
58
+ return invoke('browser_release', {});
59
+ }
53
60
  export function browserLaunch(params) {
54
61
  return invoke('browser_launch', params);
55
62
  }
@@ -117,6 +124,12 @@ export function setDeviceEmulation(params) {
117
124
  export function setTimezone(params) {
118
125
  return invoke('set_timezone', params);
119
126
  }
127
+ export function getCookie(params) {
128
+ return invoke('get_cookie', params);
129
+ }
130
+ export function setCookie(params) {
131
+ return invoke('set_cookie', params);
132
+ }
120
133
  export function getUserInfo(params = {}) {
121
134
  return invoke('get_user_info', params);
122
135
  }
package/dist/socket.d.ts CHANGED
@@ -20,6 +20,8 @@ export interface InvokeOptions {
20
20
  transport?: 'cdp';
21
21
  /** Target a specific browser by id / name (see the browser registry). */
22
22
  browser?: string;
23
+ /** Identifies whether browser control originated locally or through the cloud connector. */
24
+ controlSource?: 'local' | 'cloud';
23
25
  }
24
26
  /** Pin the default target browser for subsequent invokes (undefined clears it). */
25
27
  export declare function setDefaultBrowser(selector: string | undefined): void;
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.1.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",