🎩 You're Invited:Meet the Socket team at Black Hat in Las Vegas, August 3-6.RSVP
Sign In

@mearl/client

Package Overview
Dependencies
Maintainers
1
Versions
8
Alerts
File Explorer

Advanced tools

Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

@mearl/client - npm Package Compare versions

Comparing version
2.0.2
to
2.1.0
+2
-0
dist/actionTimeouts.js

@@ -38,2 +38,4 @@ function boundedNumber(value, fallback, minimum, maximum) {

return Math.max(fallback, 40);
if (action === 'set_cookie' && data.reload === true)
return Math.max(fallback, 40);
if (action === 'browser_launch')

@@ -40,0 +42,0 @@ return Math.max(fallback, 90);

+5
-2

@@ -44,3 +44,6 @@ #!/usr/bin/env node

},
{ title: '环境模拟', names: ['set_device_emulation', 'set_timezone'] },
{
title: '环境与 Cookie',
names: ['set_device_emulation', 'set_timezone', 'get_cookie', 'set_cookie'],
},
{ title: '用户信息', names: ['get_user_info'] },

@@ -51,3 +54,3 @@ { title: '录制', names: ['record_start', 'record_stop'] },

title: '浏览器',
names: ['browser_list', 'browser_launch', 'browser_close'],
names: ['browser_list', 'browser_release', 'browser_launch', 'browser_close'],
},

@@ -54,0 +57,0 @@ ];

@@ -17,3 +17,3 @@ export interface ParamDef {

export declare const COMMANDS: CommandDef[];
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';
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';
export declare const COMMAND_MAP: Map<string, CommandDef>;

@@ -26,2 +26,3 @@ export const COMMANDS = [

params: [
{ name: 'tabId', type: 'number', description: '目标标签页 ID,从 tab_list 获取' },
{ name: 'limit', type: 'number', description: '返回条数,默认 20' },

@@ -73,2 +74,3 @@ { name: 'level', type: 'string', description: '按日志级别过滤:log/info/warn/error' },

params: [
{ name: 'tabId', type: 'number', description: '目标标签页 ID,从 tab_list 获取' },
{ name: 'apiName', type: 'string', required: true, description: 'API 名称或正则表达式' },

@@ -105,2 +107,6 @@ { name: 'mockData', type: 'object', description: '完整 mock 响应数据(与 fields 二选一)' },

description: '查看当前生效的 mock 列表',
params: [
{ name: 'tabId', type: 'number', description: '目标标签页 ID,从 tab_list 获取' },
{ name: 'apiName', type: 'string', description: '按 API 名称或正则表达式过滤' },
],
examples: ['get_mocks'],

@@ -154,2 +160,7 @@ },

{
name: 'tabId',
type: 'number',
description: 'CDP 模式下可指定用于读取 Cookie 的标签页 ID',
},
{
name: 'operation',

@@ -179,2 +190,7 @@ type: '"current" | "list" | "switch" | "borrow"',

{
name: 'browser_release',
description: '释放 Agent 对当前浏览器的调试控制,保留浏览器和全部标签页',
examples: ['browser_release', 'browser_release --browser chrome-a'],
},
{
name: 'browser_launch',

@@ -638,2 +654,3 @@ description: '启动独立 Chrome,可在新实例中通过 TDBank 登录指定账号',

description: '开始录制用户操作',
params: [{ name: 'tabId', type: 'number', description: '目标标签页 ID,从 tab_list 获取' }],
examples: ['record_start'],

@@ -644,2 +661,3 @@ },

description: '停止录制并返回完整结果(操作 + 关联请求);未在录制时不报错,返回 wasRecording: false',
params: [{ name: 'tabId', type: 'number', description: '目标标签页 ID,从 tab_list 获取' }],
examples: ['record_stop'],

@@ -695,2 +713,55 @@ },

},
{
name: 'get_cookie',
description: '读取当前标签页的一个指定非分区 Cookie;拒绝返回 HttpOnly Cookie',
params: [
{ name: 'tabId', type: 'number', required: true, description: '目标标签页 ID' },
{
name: 'name',
type: 'string',
required: true,
description: '要读取的 Cookie 名称;不支持列举全部 Cookie',
},
{
name: 'domain',
type: 'string',
description: '可选精确域过滤,只允许当前 host 或其父域',
},
{ name: 'path', type: 'string', description: '可选精确 path 过滤' },
],
examples: [
`get_cookie --payload '{"tabId":12345,"name":"feature_flag"}'`,
`get_cookie --payload '{"tabId":12345,"name":"edith_switch_br.rx-vehicle-search_home","domain":"taobao.com"}'`,
],
},
{
name: 'set_cookie',
description: '设置当前标签页域下的一个指定非分区 session Cookie,可选刷新页面',
params: [
{ name: 'tabId', type: 'number', required: true, description: '目标标签页 ID' },
{
name: 'name',
type: 'string',
required: true,
description: 'Cookie 名称',
},
{
name: 'value',
type: 'string',
required: true,
description: 'Cookie 值,可传空字符串;空字符串不会删除 Cookie',
},
{
name: 'domain',
type: 'string',
description: 'Cookie 域,默认当前 host;只允许当前 host 或其父域',
},
{ name: 'path', type: 'string', description: 'Cookie path,默认 /' },
{ name: 'reload', type: 'boolean', description: '设置后是否刷新页面,默认 false' },
],
examples: [
`set_cookie --payload '{"tabId":12345,"name":"feature_flag","value":"enabled"}'`,
`set_cookie --payload '{"tabId":12345,"name":"edith_switch_br.rx-vehicle-search_home","value":"3.6.63","domain":"taobao.com","reload":true}'`,
],
},
// ── 其他 ──────────────────────────────────────────────────────────────────

@@ -697,0 +768,0 @@ {

@@ -34,2 +34,3 @@ /**

export interface GetLogsParams {
tabId?: number;
level?: 'log' | 'warn' | 'error' | 'info' | 'all';

@@ -53,2 +54,3 @@ since?: number;

export interface SetMockParams {
tabId?: number;
apiName: string;

@@ -68,2 +70,3 @@ mockData?: any;

export interface GetMocksParams {
tabId?: number;
apiName?: string;

@@ -160,2 +163,19 @@ }

export declare function selectBrowser(params?: SelectBrowserParams): Promise<SelectBrowserResult>;
export interface BrowserReleaseFailure {
tabId: number;
stage: string;
error: string;
}
export interface BrowserReleaseResult {
released: boolean;
hadActiveControl: boolean;
transports: Array<'extension' | 'cdp'>;
affectedTabIds: number[];
failures?: BrowserReleaseFailure[];
}
/**
* 释放当前目标浏览器的 debugger/CDP 控制,保留浏览器和全部标签页。
* 下一次浏览器操作仍可自动重新建立控制。
*/
export declare function browserRelease(): Promise<BrowserReleaseResult>;
export interface BrowserLaunchParams {

@@ -425,2 +445,43 @@ name: string;

export declare function setTimezone(params: SetTimezoneParams): Promise<SetTimezoneResult>;
export interface GetCookieParams {
tabId: number;
/** Exact non-partitioned cookie name. HttpOnly cookies cannot be read. */
name: string;
/** Optional exact domain filter. */
domain?: string;
/** Optional exact path filter. */
path?: string;
}
export interface GetCookieResult {
tabId?: number;
url: string;
name: string;
found: boolean;
value?: string;
domain?: string;
path?: string;
}
export declare function getCookie(params: GetCookieParams): Promise<GetCookieResult>;
export interface SetCookieParams {
tabId: number;
/** Cookie name. */
name: string;
/** Cookie value. An empty value does not delete the cookie. */
value: string;
/** Cookie domain; defaults to the current host. */
domain?: string;
/** Cookie path; defaults to "/". */
path?: string;
/** Reload the page after setting the cookie. Defaults to false. */
reload?: boolean;
}
export interface SetCookieResult {
tabId?: number;
url: string;
name: string;
domain: string;
path: string;
reloaded: boolean;
}
export declare function setCookie(params: SetCookieParams): Promise<SetCookieResult>;
export interface GetUserInfoParams {

@@ -427,0 +488,0 @@ detail?: boolean;

@@ -53,2 +53,9 @@ /**

}
/**
* 释放当前目标浏览器的 debugger/CDP 控制,保留浏览器和全部标签页。
* 下一次浏览器操作仍可自动重新建立控制。
*/
export function browserRelease() {
return invoke('browser_release', {});
}
export function browserLaunch(params) {

@@ -121,2 +128,8 @@ return invoke('browser_launch', params);

}
export function getCookie(params) {
return invoke('get_cookie', params);
}
export function setCookie(params) {
return invoke('set_cookie', params);
}
export function getUserInfo(params = {}) {

@@ -123,0 +136,0 @@ return invoke('get_user_info', params);

@@ -23,2 +23,4 @@ /**

browser?: string;
/** Identifies whether browser control originated locally or through the cloud connector. */
controlSource?: 'local' | 'cloud';
}

@@ -25,0 +27,0 @@ /** Pin the default target browser for subsequent invokes (undefined clears it). */

@@ -10,2 +10,3 @@ /**

import net from 'node:net';
import fs from 'node:fs';
import os from 'node:os';

@@ -19,2 +20,3 @@ import path from 'node:path';

export const CLIENT_VERSION = require('../package.json').version;
const MAX_SOCKET_BUFFER_SIZE = 64 * 1024 * 1024;
/**

@@ -84,4 +86,4 @@ * Legacy well-known socket. New native-hosts publish per-browser sockets in the

*/
async function trySpawnNativeHost(registerBrowser = true) {
if (registerBrowser && spawnAttempted)
async function trySpawnNativeHost(registerBrowser = true, retryPreviousAttempt = false) {
if (registerBrowser && spawnAttempted && !retryPreviousAttempt)
return false;

@@ -134,10 +136,10 @@ if (registerBrowser)

}
const { timeoutSec = resolveActionTimeoutSec(action, data), transport, browser } = options;
const { timeoutSec = resolveActionTimeoutSec(action, data), transport, browser, controlSource = 'local', } = options;
// 统一解析 payload 顶层字段中的 `@<path>` 文件引用(所有 action 通用)
const resolvedData = resolveFileRefs(data);
if (action === 'browser_list') {
return dispatchInvoke(action, resolvedData, timeoutSec, undefined, undefined).then(decorateBrowserListResult);
return dispatchInvoke(action, resolvedData, timeoutSec, undefined, undefined, controlSource).then(decorateBrowserListResult);
}
const selector = browser ?? defaultBrowser ?? process.env.MEARL_BROWSER ?? undefined;
return dispatchInvoke(action, resolvedData, timeoutSec, transport, selector);
return dispatchInvoke(action, resolvedData, timeoutSec, transport, selector, controlSource);
}

@@ -148,3 +150,20 @@ function isConnError(error) {

}
async function dispatchInvoke(action, data, timeoutSec, transport, selector) {
function isSocketSandboxError(error) {
return error?.code === 'EPERM';
}
async function dispatchInvoke(action, data, timeoutSec, transport, selector, controlSource) {
const configuredSocketPath = process.env.MEARL_SOCKET_PATH?.trim();
if (!selector && configuredSocketPath) {
try {
return await invokeOnce(action, data, timeoutSec, transport, configuredSocketPath, controlSource);
}
catch (error) {
if (isConnError(error) || isSocketSandboxError(error)) {
throw new Error(formatConnectError(error, configuredSocketPath), {
cause: error,
});
}
throw error;
}
}
const browsers = listLiveBrowsers();

@@ -158,6 +177,6 @@ // Explicit target: must match a live browser; do not silently fall back.

try {
return await invokeOnce(action, data, timeoutSec, transport, match.socketPath);
return await invokeOnce(action, data, timeoutSec, transport, match.socketPath, controlSource);
}
catch (error) {
if (isConnError(error)) {
if (isConnError(error) || isSocketSandboxError(error)) {
throw new Error(formatConnectError(error, match.socketPath), {

@@ -174,5 +193,8 @@ cause: error,

try {
return await invokeOnce(action, data, timeoutSec, transport, browsers[0].socketPath);
return await invokeOnce(action, data, timeoutSec, transport, browsers[0].socketPath, controlSource);
}
catch (error) {
if (isSocketSandboxError(error)) {
throw new Error(formatConnectError(error, browsers[0].socketPath), { cause: error });
}
if (!isConnError(error))

@@ -182,13 +204,14 @@ throw error;

}
return invokeViaLegacy(action, data, timeoutSec, transport, /* isRetry */ false);
return invokeViaLegacy(action, data, timeoutSec, transport, controlSource, /* isRetry */ false);
}
/** Legacy well-known socket, auto-spawning a native-host on ENOENT and retrying once. */
async function invokeViaLegacy(action, data, timeoutSec, transport, isRetry) {
async function invokeViaLegacy(action, data, timeoutSec, transport, controlSource, isRetry) {
try {
return await invokeOnce(action, data, timeoutSec, transport, SOCKET_PATH);
return await invokeOnce(action, data, timeoutSec, transport, SOCKET_PATH, controlSource);
}
catch (error) {
const err = error;
if (err.code === 'ENOENT' && !isRetry) {
const spawned = await trySpawnNativeHost(action !== 'browser_list');
const shouldAttemptSpawn = err.code === 'ENOENT' || (action === 'browser_release' && err.code === 'ECONNREFUSED');
if (shouldAttemptSpawn && !isRetry) {
const spawned = await trySpawnNativeHost(action !== 'browser_list', action === 'browser_release');
if (spawned) {

@@ -199,8 +222,9 @@ // Wait for the native host to start listening; persistent hosts also publish their registry record.

if (browsers.length > 0) {
return invokeOnce(action, data, timeoutSec, transport, browsers[0].socketPath);
return invokeOnce(action, data, timeoutSec, transport, browsers[0].socketPath, controlSource);
}
return invokeViaLegacy(action, data, timeoutSec, transport, /* isRetry */ true);
return invokeViaLegacy(action, data, timeoutSec, transport, controlSource,
/* isRetry */ true);
}
}
if (err instanceof Error && !isConnError(err))
if (err instanceof Error && !isConnError(err) && !isSocketSandboxError(err))
throw err;

@@ -210,5 +234,14 @@ throw new Error(formatConnectError(err, SOCKET_PATH), { cause: error });

}
function invokeOnce(action, data, timeoutSec, transport, socketPath) {
function invokeOnce(action, data, timeoutSec, transport, socketPath, controlSource) {
const id = `${Date.now()}_${Math.random().toString(36).slice(2, 10)}`;
return new Promise((resolve, reject) => {
if (process.platform !== 'win32' && fs.existsSync(socketPath) && process.getuid) {
const ownerUid = fs.statSync(socketPath).uid;
if (ownerUid !== process.getuid()) {
const error = new Error(`拒绝连接不属于当前用户的 Socket: ${socketPath}`);
Object.assign(error, { code: 'EACCES' });
reject(error);
return;
}
}
const socket = net.createConnection(socketPath);

@@ -233,3 +266,9 @@ let settled = false;

socket.on('connect', () => {
const message = { id, action, data, version: CLIENT_VERSION };
const message = {
id,
action,
data,
version: CLIENT_VERSION,
controlSource,
};
if (transport)

@@ -241,2 +280,6 @@ message.transport = transport;

buffer += chunk.toString('utf-8');
if (Buffer.byteLength(buffer, 'utf-8') > MAX_SOCKET_BUFFER_SIZE) {
settle(new Error(`Socket 响应超过 ${MAX_SOCKET_BUFFER_SIZE} 字节限制`));
return;
}
let newlineIndex;

@@ -304,2 +347,10 @@ while ((newlineIndex = buffer.indexOf('\n')) !== -1) {

}
if (error.code === 'EPERM') {
return [
`Socket 连接被执行环境拦截: ${socketPath}`,
'当前进程没有访问该 Mearl Unix Socket 的权限(EPERM)。',
'如果命令运行在 Agent 沙箱中,请为这个 Socket 配置精确的 Unix Socket 白名单后重试。',
'无需重新安装浏览器插件,也不要使用 sudo 或改写 Socket 权限。',
].join('\n');
}
return [`Socket 连接失败: ${error.message}`, '', installHint].join('\n');

@@ -306,0 +357,0 @@ }

{
"name": "@mearl/client",
"version": "2.0.2",
"version": "2.1.0",
"description": "Client SDK & CLI for Mearl — communicate with Chrome Extension via Unix Socket",

@@ -5,0 +5,0 @@ "type": "module",

@@ -61,2 +61,3 @@ # @mearl/client

mearl browser_list
mearl browser_release
```

@@ -73,2 +74,3 @@

| `--output <path>` | 将结果写入文件(如截图) |
| `--browser <id\|名称>` | 指定目标浏览器 |

@@ -85,7 +87,7 @@ ## 支持的操作

| 页面感知 | `page_snapshot` `page_screenshot` `page_selected_element` `page_frames` |
| 环境模拟 | `set_device_emulation` `set_timezone` |
| 环境与状态 | `set_device_emulation` `set_timezone` `get_cookie` `set_cookie` |
| 用户信息 | `get_user_info` |
| 录制 | `record_start` `record_stop` |
| TDBank | `tdbank_account` |
| 浏览器 | `browser_list` `browser_launch` `browser_close` |
| 浏览器 | `browser_list` `browser_release` `browser_launch` `browser_close` |

@@ -100,2 +102,4 @@ 页面交互动作(`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` 对象时才启用观察。

`browser_release` 释放当前目标浏览器的 debugger/CDP 控制和会话级临时状态,保留浏览器及全部标签页;后续浏览器操作可自动重新建立控制。
托管浏览器使用独立 Profile 和动态 CDP 端口。控制浏览器需先登录 TDBank;生成的 SSO 地址在本地内部传递,新实例可以使用 `headless: true`(默认)完成测试账号登录。`copyCookieDomains` 可把控制浏览器指定域的 Cookie 复制到新实例,Cookie 值不会出现在命令结果或日志中;`userAgentMode: "desktop"` 可让实例使用匹配本机 Chrome 版本的桌面 UA。完整设计见 [托管浏览器与 TDBank 多账号设计](../../docs/managed-browsers.md)。

@@ -102,0 +106,0 @@