@mearl/client 2.17.0 → 2.18.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.
@@ -1,8 +1,9 @@
1
1
  import { randomUUID } from 'node:crypto';
2
- import { mkdirSync, readFileSync, readdirSync, renameSync, rmSync, statSync, writeFileSync, } from 'node:fs';
2
+ import { closeSync, mkdirSync, openSync, readFileSync, readSync, readdirSync, renameSync, rmSync, statSync, writeFileSync, } from 'node:fs';
3
3
  import { homedir } from 'node:os';
4
4
  import { isAbsolute, join, resolve } from 'node:path';
5
5
  import { COMMAND_MAP } from './commands.js';
6
+ import { buildPluginPageBundle, PLUGIN_PAGE_CHUNK_BYTES, } from './pluginPageBundle.js';
6
7
  const RESERVED_NAMES = new Set([
7
8
  'check',
8
9
  'help',
@@ -21,11 +22,196 @@ export class PluginError extends Error {
21
22
  export function pluginDirectory() {
22
23
  return resolve(process.env.MEARL_PLUGIN_DIR || join(homedir(), '.mearl', 'plugins'));
23
24
  }
25
+ function pluginPageDirectory() {
26
+ return join(pluginDirectory(), '.pages');
27
+ }
28
+ function pluginPageBundlePath(name, hash) {
29
+ return join(pluginPageDirectory(), `${name}-${hash}.html`);
30
+ }
31
+ function removePluginPageBundles(name, exceptHash) {
32
+ let files;
33
+ try {
34
+ files = readdirSync(pluginPageDirectory());
35
+ }
36
+ catch (error) {
37
+ if (error.code === 'ENOENT')
38
+ return;
39
+ throw error;
40
+ }
41
+ const keep = exceptHash ? `${name}-${exceptHash}.html` : undefined;
42
+ for (const file of files) {
43
+ if (file === keep || !file.startsWith(`${name}-`) || !file.endsWith('.html'))
44
+ continue;
45
+ rmSync(join(pluginPageDirectory(), file), { force: true });
46
+ }
47
+ }
48
+ function normalizePageBundle(value) {
49
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
50
+ throw new Error('invalid page bundle');
51
+ }
52
+ const candidate = value;
53
+ if (typeof candidate.hash !== 'string' ||
54
+ !/^[a-f0-9]{64}$/.test(candidate.hash) ||
55
+ !Number.isInteger(candidate.size) ||
56
+ (candidate.size ?? 0) <= 0 ||
57
+ !Number.isInteger(candidate.chunks) ||
58
+ candidate.chunks !== Math.ceil((candidate.size ?? 0) / PLUGIN_PAGE_CHUNK_BYTES)) {
59
+ throw new Error('invalid page bundle');
60
+ }
61
+ return {
62
+ hash: candidate.hash,
63
+ size: candidate.size,
64
+ chunks: candidate.chunks,
65
+ };
66
+ }
67
+ function persistPluginPageBundle(name, bundle) {
68
+ const directory = pluginPageDirectory();
69
+ mkdirSync(directory, { recursive: true, mode: 0o700 });
70
+ const target = pluginPageBundlePath(name, bundle.hash);
71
+ try {
72
+ if (statSync(target).isFile())
73
+ return;
74
+ }
75
+ catch {
76
+ // 页面内容哈希变化时创建新的不可变产物。
77
+ }
78
+ const temporary = `${target}.${randomUUID()}.tmp`;
79
+ try {
80
+ writeFileSync(temporary, bundle.content, { mode: 0o600, flag: 'wx' });
81
+ renameSync(temporary, target);
82
+ }
83
+ finally {
84
+ rmSync(temporary, { force: true });
85
+ }
86
+ }
24
87
  export function assertPluginName(name) {
25
88
  if (!/^[a-z][a-z0-9-]*$/.test(name) || RESERVED_NAMES.has(name) || COMMAND_MAP.has(name)) {
26
89
  throw new PluginError('PLUGIN_INVALID_NAME', `不可用的插件名称: ${name}`);
27
90
  }
28
91
  }
92
+ export function normalizePluginToolboxContribution(value) {
93
+ if (value === undefined)
94
+ return undefined;
95
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
96
+ throw new Error('toolbox 必须是对象');
97
+ }
98
+ const candidate = value;
99
+ const title = typeof candidate.title === 'string' ? candidate.title.trim() : '';
100
+ const description = typeof candidate.description === 'string' ? candidate.description.trim() : undefined;
101
+ const icon = typeof candidate.icon === 'string' ? candidate.icon.trim() : undefined;
102
+ if (candidate.version !== 1)
103
+ throw new Error('toolbox.version 必须为 1');
104
+ if (!title || title.length > 80)
105
+ throw new Error('toolbox.title 必须是 1-80 个字符');
106
+ if (description && description.length > 240) {
107
+ throw new Error('toolbox.description 不能超过 240 个字符');
108
+ }
109
+ if (icon && (!/^[a-z][a-z0-9-]*$/.test(icon) || icon.length > 40)) {
110
+ throw new Error('toolbox.icon 必须是短横线命名的图标名称');
111
+ }
112
+ let locales;
113
+ if (candidate.locales !== undefined) {
114
+ if (!candidate.locales ||
115
+ typeof candidate.locales !== 'object' ||
116
+ Array.isArray(candidate.locales)) {
117
+ throw new Error('toolbox.locales 必须是对象');
118
+ }
119
+ const entries = Object.entries(candidate.locales);
120
+ if (entries.length === 0 || entries.length > 20) {
121
+ throw new Error('toolbox.locales 必须包含 1-20 个语言');
122
+ }
123
+ locales = Object.fromEntries(entries.map(([locale, localized]) => {
124
+ if (!/^[a-z]{2,3}(?:[_-][A-Za-z0-9]{2,8})*$/.test(locale)) {
125
+ throw new Error(`toolbox.locales 包含无效语言: ${locale}`);
126
+ }
127
+ if (!localized || typeof localized !== 'object' || Array.isArray(localized)) {
128
+ throw new Error(`toolbox.locales.${locale} 必须是对象`);
129
+ }
130
+ const localizedTitle = typeof localized.title === 'string' ? localized.title.trim() : undefined;
131
+ const localizedDescription = typeof localized.description === 'string' ? localized.description.trim() : undefined;
132
+ if (localizedTitle !== undefined && (!localizedTitle || localizedTitle.length > 80)) {
133
+ throw new Error(`toolbox.locales.${locale}.title 必须是 1-80 个字符`);
134
+ }
135
+ if (localizedDescription !== undefined && localizedDescription.length > 240) {
136
+ throw new Error(`toolbox.locales.${locale}.description 不能超过 240 个字符`);
137
+ }
138
+ if (!localizedTitle && !localizedDescription) {
139
+ throw new Error(`toolbox.locales.${locale} 至少声明 title 或 description`);
140
+ }
141
+ return [
142
+ locale,
143
+ {
144
+ ...(localizedTitle ? { title: localizedTitle } : {}),
145
+ ...(localizedDescription ? { description: localizedDescription } : {}),
146
+ },
147
+ ];
148
+ }));
149
+ }
150
+ if (!candidate.page || typeof candidate.page !== 'object' || Array.isArray(candidate.page)) {
151
+ throw new Error('toolbox.page 必须是对象');
152
+ }
153
+ const pageCandidate = candidate.page;
154
+ if (pageCandidate.type !== 'sandbox') {
155
+ throw new Error('toolbox.page.type 必须为 sandbox');
156
+ }
157
+ if (pageCandidate.runtime !== undefined && pageCandidate.runtime !== 'preact') {
158
+ throw new Error('toolbox.page.runtime 仅支持 preact');
159
+ }
160
+ const pageEntry = typeof pageCandidate.entry === 'string' ? pageCandidate.entry.trim() : '';
161
+ if (!pageEntry ||
162
+ pageEntry.length > 240 ||
163
+ isAbsolute(pageEntry) ||
164
+ pageEntry.includes('\\') ||
165
+ pageEntry.split('/').some(segment => !segment || segment === '.' || segment === '..')) {
166
+ throw new Error('toolbox.page.entry 必须是插件目录内的相对 HTML 路径');
167
+ }
168
+ if (!Array.isArray(pageCandidate.commands)) {
169
+ throw new Error('toolbox.page.commands 必须是命令名称数组');
170
+ }
171
+ const commands = pageCandidate.commands.map(command => typeof command === 'string' ? command.trim() : '');
172
+ if (commands.length === 0 ||
173
+ commands.length > 64 ||
174
+ new Set(commands).size !== commands.length ||
175
+ commands.some(command => !/^[a-z][a-z0-9-]*$/.test(command))) {
176
+ throw new Error('toolbox.page.commands 必须包含 1-64 个不重复的插件命令名称');
177
+ }
178
+ const page = {
179
+ type: 'sandbox',
180
+ ...(pageCandidate.runtime ? { runtime: pageCandidate.runtime } : {}),
181
+ entry: pageEntry,
182
+ commands,
183
+ };
184
+ let agent;
185
+ if (candidate.agent !== undefined) {
186
+ if (!candidate.agent || typeof candidate.agent !== 'object' || Array.isArray(candidate.agent)) {
187
+ throw new Error('toolbox.agent 必须是对象');
188
+ }
189
+ const agentCandidate = candidate.agent;
190
+ const skill = typeof agentCandidate.skill === 'string' ? agentCandidate.skill.trim() : '';
191
+ if (!skill || skill.length > 128 || !/^[A-Za-z0-9][A-Za-z0-9._:@/-]*$/.test(skill)) {
192
+ throw new Error('toolbox.agent.skill 必须是有效的 Skill 名称');
193
+ }
194
+ const layout = agentCandidate.layout;
195
+ if (layout !== undefined && layout !== 'drawer' && layout !== 'workspace') {
196
+ throw new Error('toolbox.agent.layout 必须是 drawer 或 workspace');
197
+ }
198
+ agent = { skill, ...(layout ? { layout } : {}) };
199
+ }
200
+ if (candidate.order !== undefined &&
201
+ (!Number.isInteger(candidate.order) || candidate.order < 0 || candidate.order > 10_000)) {
202
+ throw new Error('toolbox.order 必须是 0-10000 的整数');
203
+ }
204
+ return {
205
+ version: 1,
206
+ title,
207
+ ...(description ? { description } : {}),
208
+ ...(icon ? { icon } : {}),
209
+ ...(locales ? { locales } : {}),
210
+ ...(agent ? { agent } : {}),
211
+ page,
212
+ ...(candidate.order !== undefined ? { order: candidate.order } : {}),
213
+ };
214
+ }
29
215
  export function findPluginDirectoryEntry(directory) {
30
216
  for (const filename of ['main.mjs', 'main.py']) {
31
217
  const entry = join(directory, 'scripts', filename);
@@ -63,10 +249,31 @@ export function registerPlugin(name, target, description, options = {}) {
63
249
  const temporary = `${file}.${randomUUID()}.tmp`;
64
250
  const summary = description?.trim();
65
251
  const managedBy = options.managedBy?.trim();
252
+ let toolbox;
253
+ try {
254
+ toolbox = normalizePluginToolboxContribution(options.toolbox);
255
+ }
256
+ catch (error) {
257
+ throw new PluginError('PLUGIN_INVALID_TOOLBOX', error instanceof Error ? error.message : String(error));
258
+ }
259
+ let builtPage;
260
+ if (toolbox) {
261
+ try {
262
+ builtPage = buildPluginPageBundle(entry, toolbox.page.entry);
263
+ }
264
+ catch (error) {
265
+ throw new PluginError('PLUGIN_INVALID_TOOLBOX_PAGE', error instanceof Error ? error.message : String(error));
266
+ }
267
+ persistPluginPageBundle(name, builtPage);
268
+ }
66
269
  const record = {
67
270
  entry,
68
271
  ...(summary ? { description: summary } : {}),
69
272
  ...(managedBy ? { managedBy } : {}),
273
+ ...(toolbox ? { toolbox } : {}),
274
+ ...(builtPage
275
+ ? { pageBundle: { hash: builtPage.hash, size: builtPage.size, chunks: builtPage.chunks } }
276
+ : {}),
70
277
  };
71
278
  try {
72
279
  writeFileSync(temporary, `${JSON.stringify(record)}\n`, { mode: 0o600, flag: 'wx' });
@@ -75,6 +282,7 @@ export function registerPlugin(name, target, description, options = {}) {
75
282
  finally {
76
283
  rmSync(temporary, { force: true });
77
284
  }
285
+ removePluginPageBundles(name, builtPage?.hash);
78
286
  return entry;
79
287
  }
80
288
  export function unregisterPlugin(name) {
@@ -82,6 +290,7 @@ export function unregisterPlugin(name) {
82
290
  const file = join(pluginDirectory(), `${name}.json`);
83
291
  try {
84
292
  rmSync(file);
293
+ removePluginPageBundles(name);
85
294
  return true;
86
295
  }
87
296
  catch (error) {
@@ -113,16 +322,67 @@ export function findPluginRegistration(name) {
113
322
  const managedBy = typeof record.managedBy === 'string' && record.managedBy.trim()
114
323
  ? record.managedBy.trim()
115
324
  : undefined;
325
+ const toolbox = normalizePluginToolboxContribution(record.toolbox);
326
+ const pageBundle = toolbox ? normalizePageBundle(record.pageBundle) : undefined;
116
327
  return {
117
328
  entry: record.entry,
118
329
  ...(description ? { description } : {}),
119
330
  ...(managedBy ? { managedBy } : {}),
331
+ ...(toolbox ? { toolbox } : {}),
332
+ ...(pageBundle ? { pageBundle } : {}),
120
333
  };
121
334
  }
122
335
  catch {
123
336
  throw new PluginError('PLUGIN_REGISTRY_INVALID', `无效的插件记录: ${file}`);
124
337
  }
125
338
  }
339
+ export function readPluginPageBundleChunk(name, hash, index) {
340
+ assertPluginName(name);
341
+ const registration = findPluginRegistration(name);
342
+ const bundle = registration?.pageBundle;
343
+ if (!bundle || bundle.hash !== hash) {
344
+ throw new PluginError('PLUGIN_PAGE_CHANGED', `插件页面已更新: ${name}`);
345
+ }
346
+ if (!Number.isInteger(index) || index < 0 || index >= bundle.chunks) {
347
+ throw new PluginError('PLUGIN_PAGE_CHUNK_INVALID', `插件页面分块无效: ${index}`);
348
+ }
349
+ const path = pluginPageBundlePath(name, hash);
350
+ let size;
351
+ try {
352
+ size = statSync(path).size;
353
+ }
354
+ catch {
355
+ throw new PluginError('PLUGIN_PAGE_INVALID', `插件页面产物不存在: ${name}`);
356
+ }
357
+ if (size !== bundle.size) {
358
+ throw new PluginError('PLUGIN_PAGE_INVALID', `插件页面产物损坏: ${name}`);
359
+ }
360
+ const start = index * PLUGIN_PAGE_CHUNK_BYTES;
361
+ const content = Buffer.allocUnsafe(Math.min(PLUGIN_PAGE_CHUNK_BYTES, bundle.size - start));
362
+ let descriptor;
363
+ try {
364
+ descriptor = openSync(path, 'r');
365
+ }
366
+ catch {
367
+ throw new PluginError('PLUGIN_PAGE_INVALID', `插件页面产物无法读取: ${name}`);
368
+ }
369
+ let bytesRead;
370
+ try {
371
+ bytesRead = readSync(descriptor, content, 0, content.byteLength, start);
372
+ }
373
+ finally {
374
+ closeSync(descriptor);
375
+ }
376
+ if (bytesRead !== content.byteLength) {
377
+ throw new PluginError('PLUGIN_PAGE_INVALID', `插件页面产物损坏: ${name}`);
378
+ }
379
+ return {
380
+ data: content.toString('base64'),
381
+ hash,
382
+ index,
383
+ total: bundle.chunks,
384
+ };
385
+ }
126
386
  export function findPluginEntry(name) {
127
387
  return findPluginRegistration(name)?.entry;
128
388
  }
@@ -7,6 +7,7 @@ export interface PythonPluginDescriptor {
7
7
  commands: PythonPluginCommand[];
8
8
  description?: string;
9
9
  name?: string;
10
+ toolbox?: unknown;
10
11
  }
11
12
  export interface PythonPluginApp {
12
13
  readonly options: Readonly<PluginOptions>;
@@ -143,10 +143,13 @@ try:
143
143
 
144
144
  plugin_name = getattr(module, "name", None)
145
145
  plugin_description = getattr(module, "description", None)
146
+ plugin_toolbox = getattr(module, "toolbox", None)
146
147
  if plugin_name is not None and not isinstance(plugin_name, str):
147
148
  raise TypeError("入口的 name 必须为字符串")
148
149
  if plugin_description is not None and not isinstance(plugin_description, str):
149
150
  raise TypeError("入口的 description 必须为字符串")
151
+ if plugin_toolbox is not None and not isinstance(plugin_toolbox, dict):
152
+ raise TypeError("入口的 toolbox 必须为对象")
150
153
  register = getattr(module, "register", None)
151
154
  if not callable(register):
152
155
  raise TypeError("入口必须导出 register(app) 函数")
@@ -168,6 +171,8 @@ try:
168
171
  descriptor["name"] = plugin_name
169
172
  if plugin_description is not None:
170
173
  descriptor["description"] = plugin_description
174
+ if plugin_toolbox is not None:
175
+ descriptor["toolbox"] = plugin_toolbox
171
176
  _send(descriptor)
172
177
 
173
178
  if mode == "run":
@@ -233,6 +238,7 @@ function parseDescriptor(message) {
233
238
  commands,
234
239
  ...(typeof message.name === 'string' ? { name: message.name } : {}),
235
240
  ...(typeof message.description === 'string' ? { description: message.description } : {}),
241
+ ...(message.toolbox !== undefined ? { toolbox: message.toolbox } : {}),
236
242
  };
237
243
  }
238
244
  function bridgedError(message) {
package/dist/socket.js CHANGED
@@ -13,7 +13,8 @@ import os from 'node:os';
13
13
  import path from 'node:path';
14
14
  import { createRequire } from 'node:module';
15
15
  import { StringDecoder } from 'node:string_decoder';
16
- import { ensureInstalledProvidersRunning } from '@mearl/provider/runtime';
16
+ import { listInstalledProviders } from '@mearl/provider/host';
17
+ import { readLiveProvider } from '@mearl/provider/runtime';
17
18
  import { resolveFileRefs } from './fileRefs.js';
18
19
  import { listLiveBrowsers, findBrowser, describeBrowser, } from './browserRegistry.js';
19
20
  import { resolveActionTimeoutSec } from './actionTimeouts.js';
@@ -95,12 +96,8 @@ function browserListFromProviderRecords(browsers) {
95
96
  browsers: providerBrowsers,
96
97
  };
97
98
  }
98
- function mergeWarnings(result, warnings) {
99
- const resultWarnings = Array.isArray(result?.warnings)
100
- ? result.warnings.filter((warning) => typeof warning === 'string')
101
- : [];
102
- const merged = [...new Set([...resultWarnings, ...warnings])];
103
- return { ...result, ...(merged.length > 0 ? { warnings: merged } : {}) };
99
+ function areInstalledProvidersRunning() {
100
+ return listInstalledProviders().every(provider => readLiveProvider(provider.providerId));
104
101
  }
105
102
  function isConnectedBrowser(browser) {
106
103
  return browser.status === undefined || browser.status === 'connected';
@@ -117,6 +114,9 @@ function supportsAction(browser, action, data) {
117
114
  const required = data.deleteProfile === true ? 'deleteData' : 'close';
118
115
  return browser.operations?.includes(required) === true;
119
116
  }
117
+ if (action === 'browser_release') {
118
+ return browser.transport !== 'provider' || browser.operations?.includes('detach') === true;
119
+ }
120
120
  if (!isConnectedBrowser(browser))
121
121
  return false;
122
122
  return (browser.transport !== 'provider' ||
@@ -240,11 +240,7 @@ export function invoke(action, data = {}, options = {}) {
240
240
  // 统一解析 payload 顶层字段中的 `@<path>` 文件引用(所有 action 通用)
241
241
  const resolvedData = resolveFileRefs(data);
242
242
  if (action === 'browser_list') {
243
- const hasNativeAggregator = listLiveBrowsers().some(liveBrowser => liveBrowser.transport !== 'provider');
244
- const providerStartup = hasNativeAggregator
245
- ? Promise.resolve([])
246
- : ensureInstalledProvidersRunning();
247
- return providerStartup.then(providerWarnings => dispatchInvoke(action, resolvedData, timeoutSec, undefined, undefined, controlSource).then(result => decorateBrowserListResult(mergeWarnings(result, providerWarnings))));
243
+ return dispatchInvoke(action, resolvedData, timeoutSec, undefined, undefined, controlSource).then(result => decorateBrowserListResult(result));
248
244
  }
249
245
  const selector = browser ?? defaultBrowser ?? process.env.MEARL_BROWSER ?? undefined;
250
246
  return dispatchInvoke(action, resolvedData, timeoutSec, transport, selector, controlSource);
@@ -282,7 +278,10 @@ async function dispatchInvoke(action, data, timeoutSec, transport, selector, con
282
278
  if (transport === 'cdp' && match.transport === 'provider') {
283
279
  throw new Error(`Browser ${describeBrowser(match)} does not support the CDP transport`);
284
280
  }
285
- if (match.status === 'pairing' && action !== 'get_versions' && action !== 'browser_close') {
281
+ if (match.status === 'pairing' &&
282
+ action !== 'get_versions' &&
283
+ action !== 'browser_close' &&
284
+ action !== 'browser_release') {
286
285
  throw new Error(`Browser ${describeBrowser(match)} is waiting for device pairing`);
287
286
  }
288
287
  if (!supportsAction(match, action, data)) {
@@ -319,7 +318,9 @@ async function dispatchInvoke(action, data, timeoutSec, transport, selector, con
319
318
  throw error;
320
319
  }
321
320
  }
322
- if (action === 'browser_list' && browsers.some(browser => browser.transport === 'provider')) {
321
+ if (action === 'browser_list' &&
322
+ browsers.some(browser => browser.transport === 'provider') &&
323
+ areInstalledProvidersRunning()) {
323
324
  return browserListFromProviderRecords(browsers);
324
325
  }
325
326
  return invokeViaLegacy(action, data, timeoutSec, transport, controlSource, /* isRetry */ false);
package/dist/unified.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import WebSocket from 'ws';
2
2
  import { DEFAULT_CONNECT_TIMEOUT, DEFAULT_HEARTBEAT_INTERVAL, DEFAULT_REQUEST_TIMEOUT, MAX_BUFFER_SIZE, isHeartbeatMessage, resolveCloudConnectorSelector, } from '@mearl/cloud-types';
3
3
  import { resolveActionTimeoutSec } from './actionTimeouts.js';
4
+ import { validateRequiredActionTabId, } from './generated-browser-action-protocol.js';
4
5
  import { invoke as invokeLocalBrowser } from './socket.js';
5
6
  import { loadCloudServerConfig, loadLocalConnectorId } from './unifiedConfig.js';
6
7
  const RESPONSE_GRACE_MS = 1_000;
@@ -126,6 +127,7 @@ export class UnifiedClient {
126
127
  }, options);
127
128
  }
128
129
  async invoke(action, data = {}, options = {}) {
130
+ validateRequiredActionTabId(action, data);
129
131
  const timeoutSec = options.timeoutSec ?? resolveActionTimeoutSec(action, data, this.options.requestTimeoutSec);
130
132
  if (action === 'connector_list')
131
133
  return this.listConnectors(timeoutSec);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mearl/client",
3
- "version": "2.17.0",
3
+ "version": "2.18.0",
4
4
  "description": "Unified Mearl SDK & CLI for local and remote browsers",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -59,8 +59,8 @@
59
59
  "commander": "^15.0.0",
60
60
  "fastest-levenshtein": "^1.0.16",
61
61
  "ws": "^8.18.0",
62
- "@mearl/provider": "2.17.0",
63
- "@mearl/cloud-types": "2.17.0"
62
+ "@mearl/cloud-types": "2.18.0",
63
+ "@mearl/provider": "2.18.0"
64
64
  },
65
65
  "devDependencies": {
66
66
  "@types/node": "^24.9.1",