@mearl/client
Advanced tools
@@ -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>; |
+71
-0
@@ -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 @@ { |
+61
-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; |
+13
-0
@@ -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); |
+2
-0
@@ -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). */ |
+70
-19
@@ -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 @@ } |
+1
-1
| { | ||
| "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", |
+6
-2
@@ -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 @@ |
Environment variable access
Supply chain riskPackage accesses environment variables, which may be a sign of credential stuffing or data theft.
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
AI-detected potential code anomaly
Supply chain riskAI has identified unusual behaviors that may pose a security risk.
Found 2 instances
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
116474
8.13%2581
8.54%126
3.28%