@mearl/client 2.19.3 → 2.20.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
@@ -72,6 +72,29 @@ mearl browser_list
72
72
  mearl browser_release
73
73
  ```
74
74
 
75
+ ### 控制会话与文件
76
+
77
+ 多步骤任务首次 `tab_open` 或 `page_snapshot` 使用 `--session new`,响应在原结果旁附带 `session`,后续用其 `sessionId` 自动路由,无需额外创建/查询 action。Session 绑定一个浏览器与传输;页面首次使用自动关联,复用页面与新建页面分别记录。同一页面只允许一个会话控制,旧调用不能绕过占用;Session 不隔离 Cookie 或存储。
78
+
79
+ 旧调用不必传 `--session`,现有动作、`run_actions` 批次和上传 `filePaths` 参数继续可用。活动会话存在时,旧调用不能操作其占用页面、执行浏览器级释放/关闭或修改共享设置;旧批次中的开页/关闭浏览器需改为独立调用。CDP Mock 按标签页生效,跨页使用同一 Mock 时需分别配置。
80
+
81
+ ```bash
82
+ mearl tab_open --session new --payload '{"url":"https://example.com"}'
83
+ mearl page_snapshot --session '<sessionId>' --payload '{"tabId":12345}'
84
+ mearl browser_control --session '<sessionId>' --payload '{"operation":"handoff","reason":"请完成扫码"}'
85
+ mearl page_download --session '<sessionId>' --payload '{"tabId":12345,"trigger":{"selector":"#export"}}' --output /absolute/path/report.csv
86
+ mearl page_upload --session '<sessionId>' --payload '{"tabId":12345,"selector":"input[type=file]","files":[{"filePath":"/absolute/path/report.csv"}]}'
87
+ mearl browser_release --session '<sessionId>' --payload '{"closeCreatedTabs":true,"keepTabIds":[12345]}'
88
+ ```
89
+
90
+ 用户在 Popup 的浏览器管理页接管、交还或结束会话。交接后排队旧请求和批次后续步骤停止;已派发动作可能仍然生效。交还后重新取快照。CDP-only 环境可在用户明确确认后用同一 `browser_control` 的 `operation:"resume"` 交还,无需额外 action。会话跨 CLI 连接保留,宿主进程退出后失效;不会根据空闲时间清理用户正在操作的会话。
91
+
92
+ 带 Session 的释放只影响该会话,默认保留页面及环境;`closeCreatedTabs` 只关闭新建页,`keepTabIds` 保留结果页,复用的用户页面始终保留。`preserveEnvironment:false` 清理这些页面的环境。活动会话存在时,浏览器级释放与关闭会被拒绝。
93
+
94
+ `page_download` 先监听再点击,返回浏览器文件产物;CLI `--output` 或 SDK `saveArtifact(artifact, absolutePath, { session })` 以 256 KiB 分块导出到调用方。`page_upload.files` 接受调用方绝对路径 `{filePath}` 或浏览器 `{artifactId}`,`filePaths` 仍指浏览器宿主路径。单文件最多 256 MiB;保存到调用方的结果独立于临时产物。会话释放清理临时产物,`keepArtifactIds` 可保留指定产物;无会话产物空闲一小时回收,宿主退出清理暂存。
95
+
96
+ 扩展捕获下载需要匹配来源页 referrer 或明确的预期 `url`,无法归属时不会认领最近下载。CDP 只在托管浏览器中使用临时下载目录,共享浏览器保持默认目录,Chrome 未返回完成文件路径时明确报错。下载超时可能已经触发,需检查现场再决定下一步。当前会话与下载适配 Extension/CDP;直接处理页面动作的外部 Provider 对不支持的能力明确报错。
97
+
75
98
  ### 外部浏览器 Provider
76
99
 
77
100
  Provider 浏览器与扩展、CDP 浏览器一起出现在 `browser_list` 中,并使用同一个
@@ -29,6 +29,8 @@ export function resolveActionTimeoutSec(action, data, fallback = 15) {
29
29
  if (action === 'send_mtop_request') {
30
30
  return Math.max(fallback, Math.ceil(((data.timeout ?? 10000) + 5000) / 1000));
31
31
  }
32
+ if (action === 'page_download')
33
+ return Math.max(fallback, Math.ceil((Number(data.timeout ?? 30000) + 10000) / 1000));
32
34
  if (action === 'page_wait') {
33
35
  const waitMs = data.time ?? data.timeout ?? 10000;
34
36
  return Math.max(fallback, Math.ceil((Number(waitMs) + 5000) / 1000));
@@ -0,0 +1,17 @@
1
+ import type { BrowserArtifact } from './generated-browser-action-protocol.js';
2
+ import type { UnifiedInvokeOptions } from './unified.js';
3
+ interface Invoker {
4
+ invoke(action: string, data?: Record<string, any>, options?: UnifiedInvokeOptions): Promise<any>;
5
+ }
6
+ export declare function stageFile(client: Invoker, filePath: string, options: UnifiedInvokeOptions): Promise<{
7
+ artifactId: string;
8
+ options: UnifiedInvokeOptions;
9
+ }>;
10
+ /** Atomically export browser-host data to the calling machine without large JSON responses. */
11
+ export declare function exportArtifact(client: Invoker, artifact: BrowserArtifact & {
12
+ browserId?: string;
13
+ }, outputPath: string, options?: UnifiedInvokeOptions): Promise<{
14
+ outputPath: string;
15
+ bytes: number;
16
+ }>;
17
+ export {};
@@ -0,0 +1,80 @@
1
+ import { open, mkdir, rename, rm, stat } from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import { randomUUID } from 'node:crypto';
4
+ const CHUNK_BYTES = 256 * 1024;
5
+ export async function stageFile(client, filePath, options) {
6
+ if (!path.isAbsolute(filePath))
7
+ throw new Error('Caller-local file paths must be absolute');
8
+ const source = await open(filePath, 'r');
9
+ let id;
10
+ let pinned = options;
11
+ try {
12
+ const metadata = await source.stat();
13
+ if (!metadata.isFile() || metadata.size > 256 * 1024 * 1024)
14
+ throw new Error('Upload requires a regular file of at most 256 MiB');
15
+ const created = await client.invoke('__artifact_init', { filename: path.basename(filePath) }, options);
16
+ pinned = { ...options, browser: created.browserId };
17
+ id = created.artifact.artifactId;
18
+ let offset = 0;
19
+ for await (const chunk of source.createReadStream({
20
+ highWaterMark: CHUNK_BYTES,
21
+ autoClose: false,
22
+ })) {
23
+ await client.invoke('__artifact_write', { artifactId: id, offset, base64: chunk.toString('base64') }, pinned);
24
+ offset += chunk.length;
25
+ }
26
+ await client.invoke('__artifact_complete', { artifactId: id, bytes: offset }, pinned);
27
+ return { artifactId: id, options: pinned };
28
+ }
29
+ catch (error) {
30
+ if (id)
31
+ await client.invoke('__artifact_remove', { artifactId: id }, pinned).catch(() => { });
32
+ throw error;
33
+ }
34
+ finally {
35
+ await source.close();
36
+ }
37
+ }
38
+ /** Atomically export browser-host data to the calling machine without large JSON responses. */
39
+ export async function exportArtifact(client, artifact, outputPath, options = {}) {
40
+ if (!Number.isSafeInteger(artifact.bytes) || artifact.bytes < 0)
41
+ throw new Error('Invalid artifact size');
42
+ if (!path.isAbsolute(outputPath))
43
+ throw new Error('Artifact output paths must be absolute');
44
+ if (!options.session && !artifact.browserId && !options.browser)
45
+ throw new Error('An artifact requires its originating browser');
46
+ const pinned = { ...options, ...(artifact.browserId ? { browser: artifact.browserId } : {}) };
47
+ await mkdir(path.dirname(outputPath), { recursive: true });
48
+ const temporary = `${outputPath}.${randomUUID()}.part`;
49
+ const target = await open(temporary, 'wx', 0o600);
50
+ try {
51
+ let offset = 0;
52
+ if (artifact.bytes === 0) {
53
+ const empty = await client.invoke('__artifact_read', { artifactId: artifact.artifactId, offset: 0 }, pinned);
54
+ if (empty.totalBytes !== 0 || empty.bytes !== 0 || empty.base64 !== '')
55
+ throw new Error('Artifact export size mismatch');
56
+ }
57
+ while (offset < artifact.bytes) {
58
+ const chunk = await client.invoke('__artifact_read', { artifactId: artifact.artifactId, offset }, pinned);
59
+ const bytes = Buffer.from(chunk.base64, 'base64');
60
+ if (!bytes.length ||
61
+ bytes.length !== chunk.bytes ||
62
+ chunk.totalBytes !== artifact.bytes ||
63
+ offset + bytes.length > artifact.bytes)
64
+ throw new Error('Invalid artifact chunk or truncated transfer');
65
+ await target.write(bytes);
66
+ offset += bytes.length;
67
+ }
68
+ await target.sync();
69
+ await target.close();
70
+ if ((await stat(temporary)).size !== artifact.bytes)
71
+ throw new Error('Artifact export size mismatch');
72
+ await rename(temporary, outputPath);
73
+ return { outputPath, bytes: artifact.bytes };
74
+ }
75
+ catch (error) {
76
+ await target.close().catch(() => { });
77
+ await rm(temporary, { force: true });
78
+ throw error;
79
+ }
80
+ }
package/dist/cli.d.ts CHANGED
@@ -3,6 +3,7 @@ export interface MearlCliOptions {
3
3
  clientVersion?: string;
4
4
  }
5
5
  export interface ParsedArgs {
6
+ session?: string;
6
7
  action: string;
7
8
  browser?: string;
8
9
  compact: boolean;
package/dist/cli.js CHANGED
@@ -38,6 +38,7 @@ const CATEGORIES = [
38
38
  'page_wait',
39
39
  'page_navigate',
40
40
  'page_upload',
41
+ 'page_download',
41
42
  'page_eval',
42
43
  ],
43
44
  },
@@ -48,7 +49,13 @@ const CATEGORIES = [
48
49
  },
49
50
  {
50
51
  title: '浏览器',
51
- names: ['browser_list', 'browser_launch', 'browser_release', 'browser_close'],
52
+ names: [
53
+ 'browser_list',
54
+ 'browser_launch',
55
+ 'browser_release',
56
+ 'browser_close',
57
+ 'browser_control',
58
+ ],
52
59
  },
53
60
  ];
54
61
  const BUILTIN_COMMANDS = [
@@ -119,6 +126,7 @@ function printUsage(commandName, version) {
119
126
  ' --timeout <seconds> 超时时间(默认按 action 推导)',
120
127
  ' --compact stdout 输出紧凑格式 JSON',
121
128
  ' --output <path> 保存输出或截图',
129
+ ' --session <new|id> 首次建立或复用控制会话',
122
130
  ' --browser <id|名称> 指定 browser_list 返回的浏览器',
123
131
  '',
124
132
  '其他选项:',
@@ -422,6 +430,7 @@ export function parseClientArgs(args, plugin = false) {
422
430
  .option('--json')
423
431
  .option('--output <path>')
424
432
  .option('--browser <id-or-name>')
433
+ .option('--session <new-or-id>')
425
434
  .option('--connector <id-or-name>')
426
435
  .option('--local')
427
436
  .option('--updates')
@@ -484,6 +493,7 @@ export function parseClientArgs(args, plugin = false) {
484
493
  updates: options.updates === true,
485
494
  ...(outputPath ? { outputPath } : {}),
486
495
  ...(options.browser ? { browser: options.browser } : {}),
496
+ ...(options.session ? { session: options.session } : {}),
487
497
  ...(connector ? { connector } : {}),
488
498
  ...resolveServer(options.server),
489
499
  ...(plugin
@@ -512,6 +522,7 @@ async function runPluginCli(args, commandName) {
512
522
  const invokeOptions = {
513
523
  ...(parsed.timeoutOption ? { timeoutSec: parsed.timeoutSec } : {}),
514
524
  ...(parsed.browser ? { browser: parsed.browser } : {}),
525
+ ...(parsed.session ? { session: parsed.session } : {}),
515
526
  ...(parsed.connector ? { connector: parsed.connector } : {}),
516
527
  ...(parsed.local ? { local: true } : {}),
517
528
  ...(parsed.forceCdp ? { transport: 'cdp' } : {}),
@@ -665,6 +676,7 @@ export async function runMearlCli(args, options = {}) {
665
676
  ...(parsed.serverUrl ? { serverUrl: parsed.serverUrl } : {}),
666
677
  ...(parsed.serverUrlSource ? { serverUrlSource: parsed.serverUrlSource } : {}),
667
678
  ...(parsed.browser ? { browser: parsed.browser } : {}),
679
+ ...(parsed.session ? { session: parsed.session } : {}),
668
680
  ...(parsed.connector ? { connector: parsed.connector } : {}),
669
681
  ...(parsed.local ? { local: true } : {}),
670
682
  ...(parsed.forceCdp ? { transport: 'cdp' } : {}),
@@ -684,12 +696,25 @@ export async function runMearlCli(args, options = {}) {
684
696
  const result = await client.invoke(parsed.action, parsed.payload, {
685
697
  timeoutSec: parsed.timeoutSec,
686
698
  ...(parsed.browser ? { browser: parsed.browser } : {}),
699
+ ...(parsed.session ? { session: parsed.session } : {}),
687
700
  ...(parsed.connector ? { connector: parsed.connector } : {}),
688
701
  ...(parsed.local ? { local: true } : {}),
689
702
  ...(parsed.forceCdp ? { transport: 'cdp' } : {}),
690
703
  });
691
- if (parsed.outputPath) {
704
+ if (parsed.outputPath && parsed.action === 'page_download') {
705
+ await client.saveArtifact(result.download, resolve(parsed.outputPath), {
706
+ ...(parsed.session ? { session: parsed.session } : {}),
707
+ });
708
+ process.stdout.write(JSON.stringify({ ...result, outputPath: resolve(parsed.outputPath) }) + '\n');
709
+ if (!parsed.session)
710
+ await client.invoke('__artifact_remove', { artifactId: result.download.artifactId }, { browser: result.download.browserId });
711
+ }
712
+ else if (parsed.outputPath) {
692
713
  writeOutput(parsed.action, result, parsed.outputPath);
714
+ if (parsed.action === 'page_screenshot' && result?.session) {
715
+ const { data: _image, ...metadata } = result;
716
+ process.stdout.write(JSON.stringify({ ...metadata, outputPath: resolve(parsed.outputPath) }) + '\n');
717
+ }
693
718
  }
694
719
  else {
695
720
  process.stdout.write(`${parsed.compact ? JSON.stringify(result) : JSON.stringify(result, null, 2)}\n`);
@@ -23,6 +23,48 @@ export interface CommandDef {
23
23
  result?: readonly string[];
24
24
  }
25
25
  export declare const COMMANDS: readonly [{
26
+ readonly name: "browser_control";
27
+ readonly description: "把控制会话交给用户;用户在 Mearl 浏览器管理页交还后重新观察页面";
28
+ readonly params: [{
29
+ readonly name: "operation";
30
+ readonly type: "\"handoff\" | \"resume\"";
31
+ readonly required: true;
32
+ readonly description: "交给用户操作;resume 仅在用户明确交还后使用";
33
+ }, {
34
+ readonly name: "tabId";
35
+ readonly type: "number";
36
+ readonly description: "可选的目标页面";
37
+ }, {
38
+ readonly name: "reason";
39
+ readonly type: "string";
40
+ readonly description: "用户需要处理的事项";
41
+ }];
42
+ readonly notes: readonly ["必须携带 --session;接管停止后续派发,已经发出的操作可能仍然生效。"];
43
+ }, {
44
+ readonly name: "page_download";
45
+ readonly description: "先监听再点击,等待下载完成并创建浏览器文件产物";
46
+ readonly outputFlag: true;
47
+ readonly params: [{
48
+ readonly name: "tabId";
49
+ readonly type: "number";
50
+ readonly required: true;
51
+ readonly description: "下载来源页面";
52
+ }, {
53
+ readonly name: "trigger";
54
+ readonly type: "object";
55
+ readonly required: true;
56
+ readonly description: "page_click 参数,不含 tabId";
57
+ }, {
58
+ readonly name: "url";
59
+ readonly type: "string";
60
+ readonly description: "预期下载 URL;扩展无法通过 referrer 归属时需显式指定";
61
+ }, {
62
+ readonly name: "timeout";
63
+ readonly type: "number";
64
+ readonly description: "下载等待毫秒,默认 30000,范围 1000–300000";
65
+ }];
66
+ readonly notes: readonly ["--output 把文件分块保存到调用方机器;未指定时返回 artifactId,可供 page_upload 复用。", "共享 CDP 浏览器保持原下载目录;Chrome 未返回文件路径时明确报错。超时不能直接重复点击。"];
67
+ }, {
26
68
  readonly name: "tab_checkpoint";
27
69
  readonly description: "为目标标签页建立日志、请求和埋点增量边界";
28
70
  readonly params: [{
@@ -354,6 +396,24 @@ export declare const COMMANDS: readonly [{
354
396
  }, {
355
397
  readonly name: "browser_release";
356
398
  readonly description: "释放当前浏览器的调试控制和会话级临时效果(含设备/时区模拟);不用于普通任务收尾,Provider 实例的行为以 mearl <providerId> 为准";
399
+ readonly params: [{
400
+ readonly name: "closeCreatedTabs";
401
+ readonly type: "boolean";
402
+ readonly description: "带 --session 时关闭会话创建的过程页,默认 false";
403
+ }, {
404
+ readonly name: "keepTabIds";
405
+ readonly type: "number[]";
406
+ readonly description: "保留的结果页面;复用的用户页面始终保留";
407
+ }, {
408
+ readonly name: "preserveEnvironment";
409
+ readonly type: "boolean";
410
+ readonly description: "带 --session 时默认 true,保留 Mock 与模拟现场;false 清理这些页面的环境";
411
+ }, {
412
+ readonly name: "keepArtifactIds";
413
+ readonly type: "string[]";
414
+ readonly description: "保留在浏览器宿主的文件产物;其余会话临时文件删除";
415
+ }];
416
+ readonly notes: readonly ["带 --session 时可用于任务收尾,只释放该会话;未带会话时保留原浏览器级释放行为,活动会话存在时拒绝全局释放。"];
357
417
  readonly summary: "释放浏览器调试控制和会话级临时效果";
358
418
  readonly examples: ["browser_release", "browser_release --browser chrome-a"];
359
419
  }, {
@@ -808,6 +868,10 @@ export declare const COMMANDS: readonly [{
808
868
  readonly name: "page_upload";
809
869
  readonly description: "向 <input type=\"file\"> 元素上传一个或多个文件";
810
870
  readonly params: [{
871
+ readonly name: "files";
872
+ readonly type: "object[]";
873
+ readonly description: "调用方绝对路径 {filePath} 或浏览器产物 {artifactId};与 filePaths 二选一";
874
+ }, {
811
875
  readonly name: "selector";
812
876
  readonly type: "string";
813
877
  readonly required: true;
@@ -815,8 +879,7 @@ export declare const COMMANDS: readonly [{
815
879
  }, {
816
880
  readonly name: "filePaths";
817
881
  readonly type: "string[]";
818
- readonly required: true;
819
- readonly description: "本地文件路径数组";
882
+ readonly description: "浏览器宿主上的绝对路径数组;与 files 二选一";
820
883
  }, ParamDef, ParamDef, {
821
884
  readonly name: "tabId";
822
885
  readonly type: "number";
package/dist/commands.js CHANGED
@@ -36,6 +36,49 @@ const PAGE_ACTION_RESULT = [
36
36
  '显式开启的诊断通道在 diagnostics 中返回动作期间增量,与 observe 独立。',
37
37
  ];
38
38
  export const COMMANDS = [
39
+ {
40
+ name: 'browser_control',
41
+ description: '把控制会话交给用户;用户在 Mearl 浏览器管理页交还后重新观察页面',
42
+ params: [
43
+ {
44
+ name: 'operation',
45
+ type: '"handoff" | "resume"',
46
+ required: true,
47
+ description: '交给用户操作;resume 仅在用户明确交还后使用',
48
+ },
49
+ { name: 'tabId', type: 'number', description: '可选的目标页面' },
50
+ { name: 'reason', type: 'string', description: '用户需要处理的事项' },
51
+ ],
52
+ notes: ['必须携带 --session;接管停止后续派发,已经发出的操作可能仍然生效。'],
53
+ },
54
+ {
55
+ name: 'page_download',
56
+ description: '先监听再点击,等待下载完成并创建浏览器文件产物',
57
+ outputFlag: true,
58
+ params: [
59
+ { name: 'tabId', type: 'number', required: true, description: '下载来源页面' },
60
+ {
61
+ name: 'trigger',
62
+ type: 'object',
63
+ required: true,
64
+ description: 'page_click 参数,不含 tabId',
65
+ },
66
+ {
67
+ name: 'url',
68
+ type: 'string',
69
+ description: '预期下载 URL;扩展无法通过 referrer 归属时需显式指定',
70
+ },
71
+ {
72
+ name: 'timeout',
73
+ type: 'number',
74
+ description: '下载等待毫秒,默认 30000,范围 1000–300000',
75
+ },
76
+ ],
77
+ notes: [
78
+ '--output 把文件分块保存到调用方机器;未指定时返回 artifactId,可供 page_upload 复用。',
79
+ '共享 CDP 浏览器保持原下载目录;Chrome 未返回文件路径时明确报错。超时不能直接重复点击。',
80
+ ],
81
+ },
39
82
  // ── API 调试 ──────────────────────────────────────────────────────────────
40
83
  {
41
84
  name: 'tab_checkpoint',
@@ -337,6 +380,31 @@ export const COMMANDS = [
337
380
  {
338
381
  name: 'browser_release',
339
382
  description: '释放当前浏览器的调试控制和会话级临时效果(含设备/时区模拟);不用于普通任务收尾,Provider 实例的行为以 mearl <providerId> 为准',
383
+ params: [
384
+ {
385
+ name: 'closeCreatedTabs',
386
+ type: 'boolean',
387
+ description: '带 --session 时关闭会话创建的过程页,默认 false',
388
+ },
389
+ {
390
+ name: 'keepTabIds',
391
+ type: 'number[]',
392
+ description: '保留的结果页面;复用的用户页面始终保留',
393
+ },
394
+ {
395
+ name: 'preserveEnvironment',
396
+ type: 'boolean',
397
+ description: '带 --session 时默认 true,保留 Mock 与模拟现场;false 清理这些页面的环境',
398
+ },
399
+ {
400
+ name: 'keepArtifactIds',
401
+ type: 'string[]',
402
+ description: '保留在浏览器宿主的文件产物;其余会话临时文件删除',
403
+ },
404
+ ],
405
+ notes: [
406
+ '带 --session 时可用于任务收尾,只释放该会话;未带会话时保留原浏览器级释放行为,活动会话存在时拒绝全局释放。',
407
+ ],
340
408
  summary: '释放浏览器调试控制和会话级临时效果',
341
409
  examples: ['browser_release', 'browser_release --browser chrome-a'],
342
410
  },
@@ -882,13 +950,22 @@ export const COMMANDS = [
882
950
  name: 'page_upload',
883
951
  description: '向 <input type="file"> 元素上传一个或多个文件',
884
952
  params: [
953
+ {
954
+ name: 'files',
955
+ type: 'object[]',
956
+ description: '调用方绝对路径 {filePath} 或浏览器产物 {artifactId};与 filePaths 二选一',
957
+ },
885
958
  {
886
959
  name: 'selector',
887
960
  type: 'string',
888
961
  required: true,
889
962
  description: '原生 CSS 选择器,或 page_snapshot / 页面动作 observation 返回的 @eN ref(必须带 @ 前缀),必须指向 input[type=file]',
890
963
  },
891
- { name: 'filePaths', type: 'string[]', required: true, description: '本地文件路径数组' },
964
+ {
965
+ name: 'filePaths',
966
+ type: 'string[]',
967
+ description: '浏览器宿主上的绝对路径数组;与 files 二选一',
968
+ },
892
969
  PAGE_OBSERVE_PARAM,
893
970
  PAGE_DIAGNOSTICS_PARAM,
894
971
  { name: 'tabId', type: 'number', required: true, description: '目标标签页 ID' },
@@ -173,7 +173,7 @@ export interface BrowserInfoBase {
173
173
  }
174
174
  export type BrowserScreenshotKind = 'native' | 'reconstructed' | 'none';
175
175
  /** Browser-targeted actions that an external provider may implement. */
176
- export type BrowserCapabilityAction = ExtensionBrowserAction;
176
+ export type BrowserCapabilityAction = ExtensionBrowserAction | 'page_download';
177
177
  /** Capabilities advertised by an external browser-control provider. */
178
178
  export interface BrowserCapabilities {
179
179
  actions: BrowserCapabilityAction[];
@@ -222,6 +222,52 @@ export interface BrowserReleaseResult {
222
222
  affectedTabIds: number[];
223
223
  failures?: BrowserReleaseFailure[];
224
224
  }
225
+ export interface ControlSessionInfo {
226
+ sessionId: string;
227
+ browserId: string;
228
+ transport: BrowserTransport;
229
+ status: 'active' | 'user' | 'releasing' | 'released';
230
+ generation: number;
231
+ tabs: Array<{
232
+ tabId: number;
233
+ source: 'created' | 'attached';
234
+ title?: string;
235
+ url?: string;
236
+ }>;
237
+ reason?: string;
238
+ }
239
+ export interface BrowserReleaseParams {
240
+ /** Close only session-created pages; attached user pages are protected. */
241
+ closeCreatedTabs?: boolean;
242
+ keepTabIds?: number[];
243
+ /** Preserve the browser environment by default for a scoped release. */
244
+ preserveEnvironment?: boolean;
245
+ keepArtifactIds?: string[];
246
+ }
247
+ export interface BrowserControlParams {
248
+ operation: 'handoff' | 'resume';
249
+ tabId?: number;
250
+ reason?: string;
251
+ }
252
+ export interface BrowserArtifact {
253
+ artifactId: string;
254
+ filename: string;
255
+ bytes: number;
256
+ location: 'browser';
257
+ /** Global browser route is attached by the unified client. */
258
+ browserId?: string;
259
+ }
260
+ export interface PageDownloadParams {
261
+ tabId: number;
262
+ trigger: Omit<PageClickParams, 'tabId'>;
263
+ /** Restrict the expected download URL; useful when the browser cannot identify its source. */
264
+ url?: string;
265
+ timeout?: number;
266
+ }
267
+ export interface PageDownloadResult {
268
+ download: BrowserArtifact;
269
+ action: unknown;
270
+ }
225
271
  export interface CookieCopyScope {
226
272
  /** Maintained authentication-domain presets to include. */
227
273
  presets?: Array<'mearl-services'>;
@@ -595,7 +641,14 @@ export interface PageNavigateParams {
595
641
  export interface PageUploadParams {
596
642
  tabId: number;
597
643
  selector: string;
598
- filePaths: string[];
644
+ /** Absolute paths available to the browser host. */
645
+ filePaths?: string[];
646
+ /** SDK/CLI stages caller-local files before dispatch. Mutually exclusive with filePaths. */
647
+ files?: Array<{
648
+ filePath: string;
649
+ } | {
650
+ artifactId: string;
651
+ }>;
599
652
  observe?: PageObservationWaitOptions | false;
600
653
  diagnostics?: PageDiagnosticsSetting;
601
654
  }
@@ -719,9 +772,19 @@ export interface BrowserActionMap {
719
772
  response: SelectBrowserResult;
720
773
  };
721
774
  browser_release: {
722
- request: undefined;
775
+ request: BrowserReleaseParams;
723
776
  response: BrowserReleaseResult;
724
777
  };
778
+ browser_control: {
779
+ request: BrowserControlParams;
780
+ response: {
781
+ session: ControlSessionInfo;
782
+ };
783
+ };
784
+ page_download: {
785
+ request: PageDownloadParams;
786
+ response: PageDownloadResult;
787
+ };
725
788
  browser_launch: {
726
789
  request: BrowserLaunchParams;
727
790
  response: ManagedBrowserInfo;
@@ -833,10 +896,12 @@ export interface BrowserActionMap {
833
896
  }
834
897
  export type BrowserAction = keyof BrowserActionMap;
835
898
  export type BrowserActionRequest<A extends BrowserAction> = BrowserActionMap[A]['request'];
836
- export type BrowserActionResponse<A extends BrowserAction> = BrowserActionMap[A]['response'];
899
+ export type BrowserActionResponse<A extends BrowserAction> = BrowserActionMap[A]['response'] & {
900
+ session?: ControlSessionInfo;
901
+ };
837
902
  export type BrowserCommandAction = Exclude<BrowserAction, 'browser_select_browser'>;
838
- export type ExtensionBrowserAction = Exclude<BrowserCommandAction, 'browser_list' | 'browser_launch' | 'browser_close'>;
839
- export declare const TAB_ID_REQUIRED_ACTIONS: readonly ["tab_checkpoint", "capture_checkpoint", "get_requests", "get_logs", "get_events", "set_mock", "get_mocks", "page_screenshot", "tab_close", "page_click", "page_drag", "page_type", "page_scroll", "page_hover", "page_eval", "page_inspect", "page_snapshot", "page_press", "page_wait", "page_navigate", "page_upload", "page_frames", "set_device_emulation", "set_app_profile", "set_timezone", "run_actions"];
903
+ export type ExtensionBrowserAction = Exclude<BrowserCommandAction, 'browser_list' | 'browser_launch' | 'browser_close' | 'browser_control' | 'page_download'>;
904
+ export declare const TAB_ID_REQUIRED_ACTIONS: readonly ["tab_checkpoint", "capture_checkpoint", "get_requests", "get_logs", "get_events", "set_mock", "get_mocks", "page_screenshot", "tab_close", "page_click", "page_drag", "page_type", "page_scroll", "page_hover", "page_eval", "page_inspect", "page_snapshot", "page_press", "page_wait", "page_navigate", "page_upload", "page_download", "page_frames", "set_device_emulation", "set_app_profile", "set_timezone", "run_actions"];
840
905
  export type TabIdRequiredAction = (typeof TAB_ID_REQUIRED_ACTIONS)[number];
841
906
  export declare function actionRequiresTabId(action: unknown): action is TabIdRequiredAction;
842
907
  export declare function validateRequiredActionTabId(action: string, data: unknown): void;
@@ -857,6 +922,8 @@ export declare const BROWSER_ACTIONS: {
857
922
  readonly browser_list: true;
858
923
  readonly browser_select_browser: true;
859
924
  readonly browser_release: true;
925
+ readonly browser_control: true;
926
+ readonly page_download: true;
860
927
  readonly browser_launch: true;
861
928
  readonly browser_close: true;
862
929
  readonly page_screenshot: true;
@@ -899,6 +966,8 @@ export declare const BROWSER_COMMAND_ACTIONS: {
899
966
  readonly send_mtop_request: true;
900
967
  readonly browser_list: true;
901
968
  readonly browser_release: true;
969
+ readonly browser_control: true;
970
+ readonly page_download: true;
902
971
  readonly browser_launch: true;
903
972
  readonly browser_close: true;
904
973
  readonly page_screenshot: true;
@@ -966,6 +1035,46 @@ export declare const EXTENSION_BROWSER_ACTIONS: {
966
1035
  readonly request_domain_permission: true;
967
1036
  readonly run_actions: true;
968
1037
  };
1038
+ export declare const BROWSER_CAPABILITY_ACTIONS: {
1039
+ readonly page_download: true;
1040
+ readonly tab_checkpoint: true;
1041
+ readonly capture_checkpoint: true;
1042
+ readonly get_requests: true;
1043
+ readonly get_logs: true;
1044
+ readonly get_events: true;
1045
+ readonly set_mock: true;
1046
+ readonly get_mocks: true;
1047
+ readonly set_rule: true;
1048
+ readonly get_rules: true;
1049
+ readonly send_request: true;
1050
+ readonly send_mtop_request: true;
1051
+ readonly browser_release: true;
1052
+ readonly page_screenshot: true;
1053
+ readonly tab_open: true;
1054
+ readonly tab_close: true;
1055
+ readonly tab_list: true;
1056
+ readonly tab_group: true;
1057
+ readonly page_click: true;
1058
+ readonly page_drag: true;
1059
+ readonly page_type: true;
1060
+ readonly page_scroll: true;
1061
+ readonly page_hover: true;
1062
+ readonly page_eval: true;
1063
+ readonly page_inspect: true;
1064
+ readonly page_snapshot: true;
1065
+ readonly page_press: true;
1066
+ readonly page_wait: true;
1067
+ readonly page_navigate: true;
1068
+ readonly page_upload: true;
1069
+ readonly page_frames: true;
1070
+ readonly set_device_emulation: true;
1071
+ readonly set_app_profile: true;
1072
+ readonly set_timezone: true;
1073
+ readonly get_cookie: true;
1074
+ readonly set_cookie: true;
1075
+ readonly request_domain_permission: true;
1076
+ readonly run_actions: true;
1077
+ };
969
1078
  export declare function isBrowserAction(value: unknown): value is BrowserAction;
970
1079
  export declare function isBrowserCommandAction(value: unknown): value is BrowserCommandAction;
971
1080
  export declare function isBrowserCapabilityAction(value: unknown): value is BrowserCapabilityAction;
@@ -72,6 +72,7 @@ export const TAB_ID_REQUIRED_ACTIONS = [
72
72
  'page_wait',
73
73
  'page_navigate',
74
74
  'page_upload',
75
+ 'page_download',
75
76
  'page_frames',
76
77
  'set_device_emulation',
77
78
  'set_app_profile',
@@ -128,6 +129,8 @@ export const BROWSER_ACTIONS = {
128
129
  browser_list: true,
129
130
  browser_select_browser: true,
130
131
  browser_release: true,
132
+ browser_control: true,
133
+ page_download: true,
131
134
  browser_launch: true,
132
135
  browser_close: true,
133
136
  page_screenshot: true,
@@ -170,6 +173,8 @@ export const BROWSER_COMMAND_ACTIONS = {
170
173
  send_mtop_request: true,
171
174
  browser_list: true,
172
175
  browser_release: true,
176
+ browser_control: true,
177
+ page_download: true,
173
178
  browser_launch: true,
174
179
  browser_close: true,
175
180
  page_screenshot: true,
@@ -237,6 +242,10 @@ export const EXTENSION_BROWSER_ACTIONS = {
237
242
  request_domain_permission: true,
238
243
  run_actions: true,
239
244
  };
245
+ export const BROWSER_CAPABILITY_ACTIONS = {
246
+ ...EXTENSION_BROWSER_ACTIONS,
247
+ page_download: true,
248
+ };
240
249
  export function isBrowserAction(value) {
241
250
  return typeof value === 'string' && Object.hasOwn(BROWSER_ACTIONS, value);
242
251
  }
@@ -244,7 +253,7 @@ export function isBrowserCommandAction(value) {
244
253
  return typeof value === 'string' && Object.hasOwn(BROWSER_COMMAND_ACTIONS, value);
245
254
  }
246
255
  export function isBrowserCapabilityAction(value) {
247
- return isExtensionBrowserAction(value);
256
+ return typeof value === 'string' && Object.hasOwn(BROWSER_CAPABILITY_ACTIONS, value);
248
257
  }
249
258
  export function isExtensionBrowserAction(value) {
250
259
  return typeof value === 'string' && Object.hasOwn(EXTENSION_BROWSER_ACTIONS, value);
package/dist/index.d.ts CHANGED
@@ -7,7 +7,7 @@
7
7
  export { UnifiedClient, UnifiedClient as MearlClient, decodeBrowserTarget, invoke, invokePlugin, listCloudConnectors, } from './unified.js';
8
8
  export type { UnifiedClientOptions, UnifiedInvokeOptions, UnifiedInvokeOptions as InvokeOptions, } from './unified.js';
9
9
  export type { PluginInvocation, PluginInvokeOptions, PluginProgress, StructuredPluginProgress, } from './plugin.js';
10
- export { APP_PROFILE_NAMES, BROWSER_DEVICE_PRESET_NAMES, BROWSER_ACTIONS, BROWSER_COMMAND_ACTIONS, EXTENSION_BROWSER_ACTIONS, PAGE_ACT_ACTIONS, SEND_REQUEST_MAX_FILES, SEND_REQUEST_MAX_TOTAL_BASE64_FILE_BYTES, SEND_REQUEST_MAX_TOTAL_FILE_BYTES, isBrowserAction, isBrowserCapabilityAction, isBrowserCommandAction, isExtensionBrowserAction, } from './generated-browser-action-protocol.js';
10
+ export { APP_PROFILE_NAMES, BROWSER_DEVICE_PRESET_NAMES, BROWSER_ACTIONS, BROWSER_CAPABILITY_ACTIONS, BROWSER_COMMAND_ACTIONS, EXTENSION_BROWSER_ACTIONS, PAGE_ACT_ACTIONS, SEND_REQUEST_MAX_FILES, SEND_REQUEST_MAX_TOTAL_BASE64_FILE_BYTES, SEND_REQUEST_MAX_TOTAL_FILE_BYTES, isBrowserAction, isBrowserCapabilityAction, isBrowserCommandAction, isExtensionBrowserAction, } from './generated-browser-action-protocol.js';
11
11
  export type { ActionStep, AppProfileName, BrowserAction, BrowserActionMap, BrowserActionRequest, BrowserActionResponse, BrowserCapabilities, BrowserCapabilityAction, BrowserCloseParams, BrowserCommandAction, BrowserInfo, BrowserInfoBase, BrowserJsonValue, BrowserLaunchParams, BrowserProviderOperation, BrowserListResult, BrowserPairingInfo, BrowserReleaseFailure, BrowserReleaseResult, BrowserProviderDefinition, BrowserProviderIdentity, BrowserProviderInputSchema, BrowserProviderListResult, BrowserReference, BrowserDetails, BrowserSource, BrowserStatus, BrowserScreenshotKind, BrowserTransport, CaptureCheckpointParams, CaptureCheckpointResult, BrowserDevicePresetName, BrowserEmulationOptions, ExtensionBrowserAction, GetCookieParams, GetCookieResult, GetEventsParams, GetLogsParams, GetMocksParams, GetRequestsParams, ManagedBrowserInfo, PageClickParams, PageDragEndpoint, PageDragParams, PageDragPoint, PageActAction, PageDiagnosticsOptions, PageDiagnosticsSetting, PageEvalParams, PageFramesParams, PageHoverParams, PageInspectFilter, PageInspectParams, PageNavigateParams, PageObservationWaitOptions, PagePressParams, PageScreenshotParams, PageScrollParams, PageSnapshotParams, PageSnapshotQuery, PageTypeParams, PageUploadParams, PageWaitParams, RegularBrowserInfo, RequestDomainPermissionParams, RulesResult, RunActionName, RunActionsParams, SelectBrowserParams, SelectBrowserResult, SendRequestFile, SendRequestFormValue, SendMtopRequestParams, SendRequestParams, SetAppProfileParams, SetCookieParams, SetCookieResult, SetDeviceEmulationParams, SetMockParams, SetRuleParams, SetTimezoneParams, SetTimezoneResult, TabCloseParams, TabOpenParams, } from './generated-browser-action-protocol.js';
12
12
  import { type UnifiedInvokeOptions } from './unified.js';
13
13
  import type * as Protocol from './generated-browser-action-protocol.js';
@@ -24,6 +24,7 @@ export declare function sendMtopRequest(params: Protocol.SendMtopRequestParams,
24
24
  export declare function browserList(options?: UnifiedInvokeOptions): Promise<Protocol.BrowserActionResponse<'browser_list'>>;
25
25
  export declare function selectBrowser(params?: Protocol.SelectBrowserParams, options?: UnifiedInvokeOptions): Promise<Protocol.BrowserActionResponse<'browser_select_browser'>>;
26
26
  export declare function browserRelease(options?: UnifiedInvokeOptions): Promise<Protocol.BrowserActionResponse<'browser_release'>>;
27
+ export declare function browserRelease(params: Protocol.BrowserReleaseParams, options: UnifiedInvokeOptions): Promise<Protocol.BrowserActionResponse<'browser_release'>>;
27
28
  export declare function browserLaunch(params: Protocol.BrowserLaunchParams, options?: UnifiedInvokeOptions): Promise<Protocol.BrowserActionResponse<'browser_launch'>>;
28
29
  export declare function browserClose(params: Protocol.BrowserCloseParams, options?: UnifiedInvokeOptions): Promise<Protocol.BrowserActionResponse<'browser_close'>>;
29
30
  export declare function setRule(params: Protocol.SetRuleParams, options?: UnifiedInvokeOptions): Promise<Protocol.BrowserActionResponse<'set_rule'>>;
@@ -52,3 +53,11 @@ export declare function setTimezone(params: Protocol.SetTimezoneParams, options?
52
53
  export declare function getCookie(params: Protocol.GetCookieParams, options?: UnifiedInvokeOptions): Promise<Protocol.BrowserActionResponse<'get_cookie'>>;
53
54
  export declare function setCookie(params: Protocol.SetCookieParams, options?: UnifiedInvokeOptions): Promise<Protocol.BrowserActionResponse<'set_cookie'>>;
54
55
  export declare function runActions(params: Protocol.RunActionsParams, options?: UnifiedInvokeOptions): Promise<Protocol.BrowserActionResponse<'run_actions'>>;
56
+ export declare function browserControl(params: Protocol.BrowserControlParams, options?: UnifiedInvokeOptions): Promise<any>;
57
+ export declare function pageDownload(params: Protocol.PageDownloadParams, options?: UnifiedInvokeOptions): Promise<any>;
58
+ export declare function saveArtifact(artifact: Protocol.BrowserArtifact & {
59
+ browserId?: string;
60
+ }, outputPath: string, options?: UnifiedInvokeOptions): Promise<{
61
+ outputPath: string;
62
+ bytes: number;
63
+ }>;
package/dist/index.js CHANGED
@@ -5,7 +5,7 @@
5
5
  * then routes actions using the global browser ids returned by browser_list.
6
6
  */
7
7
  export { UnifiedClient, UnifiedClient as MearlClient, decodeBrowserTarget, invoke, invokePlugin, listCloudConnectors, } from './unified.js';
8
- export { APP_PROFILE_NAMES, BROWSER_DEVICE_PRESET_NAMES, BROWSER_ACTIONS, BROWSER_COMMAND_ACTIONS, EXTENSION_BROWSER_ACTIONS, PAGE_ACT_ACTIONS, SEND_REQUEST_MAX_FILES, SEND_REQUEST_MAX_TOTAL_BASE64_FILE_BYTES, SEND_REQUEST_MAX_TOTAL_FILE_BYTES, isBrowserAction, isBrowserCapabilityAction, isBrowserCommandAction, isExtensionBrowserAction, } from './generated-browser-action-protocol.js';
8
+ export { APP_PROFILE_NAMES, BROWSER_DEVICE_PRESET_NAMES, BROWSER_ACTIONS, BROWSER_CAPABILITY_ACTIONS, BROWSER_COMMAND_ACTIONS, EXTENSION_BROWSER_ACTIONS, PAGE_ACT_ACTIONS, SEND_REQUEST_MAX_FILES, SEND_REQUEST_MAX_TOTAL_BASE64_FILE_BYTES, SEND_REQUEST_MAX_TOTAL_FILE_BYTES, isBrowserAction, isBrowserCapabilityAction, isBrowserCommandAction, isExtensionBrowserAction, } from './generated-browser-action-protocol.js';
9
9
  import { invoke } from './unified.js';
10
10
  export function tabCheckpoint(params, options) {
11
11
  return invoke('tab_checkpoint', params, options);
@@ -41,8 +41,10 @@ export function browserList(options) {
41
41
  export function selectBrowser(params = {}, options) {
42
42
  return invoke('browser_select_browser', params, options);
43
43
  }
44
- export function browserRelease(options) {
45
- return invoke('browser_release', {}, options);
44
+ export function browserRelease(paramsOrOptions = {}, options) {
45
+ return options
46
+ ? invoke('browser_release', paramsOrOptions, options)
47
+ : invoke('browser_release', {}, paramsOrOptions);
46
48
  }
47
49
  export function browserLaunch(params, options) {
48
50
  return invoke('browser_launch', params, options);
@@ -128,3 +130,13 @@ export function setCookie(params, options) {
128
130
  export function runActions(params, options) {
129
131
  return invoke('run_actions', params, options);
130
132
  }
133
+ export function browserControl(params, options) {
134
+ return invoke('browser_control', params, options);
135
+ }
136
+ export function pageDownload(params, options) {
137
+ return invoke('page_download', params, options);
138
+ }
139
+ export async function saveArtifact(artifact, outputPath, options = {}) {
140
+ const { exportArtifact } = await import('./artifacts.js');
141
+ return exportArtifact({ invoke }, artifact, outputPath, options);
142
+ }
@@ -32,6 +32,40 @@ const PAGE_BRIDGE_SCRIPT = String.raw `(() => {
32
32
  let resolveReady;
33
33
  const ready = new Promise(resolve => { resolveReady = resolve; });
34
34
 
35
+ let pageModalOpen = null;
36
+ let modalRestoreFrame = null;
37
+ const syncPageModal = () => {
38
+ if (!port) return;
39
+ const open = Boolean(document.querySelector?.('dialog:modal'));
40
+ if (open && modalRestoreFrame !== null) {
41
+ cancelAnimationFrame(modalRestoreFrame);
42
+ modalRestoreFrame = null;
43
+ }
44
+ if (open === pageModalOpen) return;
45
+ if (!open && pageModalOpen === true) {
46
+ if (modalRestoreFrame !== null) return;
47
+ // Closing a dialog restores focus and can scroll the anchors before the next paint.
48
+ modalRestoreFrame = requestAnimationFrame(() => {
49
+ modalRestoreFrame = null;
50
+ if (document.querySelector?.('dialog:modal')) return;
51
+ const positions = [];
52
+ for (const { presentation } of inlineAgentPanels.values()) {
53
+ if (!presentation.element.isConnected) continue;
54
+ const rect = presentation.element.getBoundingClientRect();
55
+ if (![rect.top, rect.left, rect.width, rect.height].every(Number.isFinite)) continue;
56
+ const bounds = { top: rect.top, left: rect.left, width: rect.width, height: rect.height };
57
+ presentation.bounds = bounds;
58
+ positions.push({ key: presentation.key, bounds });
59
+ }
60
+ pageModalOpen = false;
61
+ port.postMessage({ type: 'page-modal', open: false, positions });
62
+ });
63
+ return;
64
+ }
65
+ pageModalOpen = open;
66
+ port.postMessage({ type: 'page-modal', open });
67
+ };
68
+
35
69
  const requestHost = async (type, payload = {}) => {
36
70
  await ready;
37
71
  const requestId = String(++requestSequence);
@@ -469,6 +503,19 @@ const PAGE_BRIDGE_SCRIPT = String.raw `(() => {
469
503
  port.start();
470
504
  resolveReady(context);
471
505
  port.postMessage({ type: 'ready' });
506
+ // Dialogs in the sandbox cannot cover Agent overlays in the parent document.
507
+ syncPageModal();
508
+ const modalObserver = typeof MutationObserver === 'function'
509
+ ? new MutationObserver(syncPageModal)
510
+ : null;
511
+ modalObserver?.observe(document.documentElement, {
512
+ attributes: true,
513
+ attributeFilter: ['open'],
514
+ childList: true,
515
+ subtree: true,
516
+ });
517
+ document.addEventListener('toggle', syncPageModal, true);
518
+ document.addEventListener('close', syncPageModal, true);
472
519
  };
473
520
  window.addEventListener('message', connect);
474
521
  window.parent.postMessage({ type: 'mearl-plugin-ready' }, '*');
package/dist/socket.d.ts CHANGED
@@ -17,6 +17,7 @@ export declare const SOCKET_PATH: string;
17
17
  export type ControlSource = 'local' | 'remote';
18
18
  type CompatibleControlSource = ControlSource | 'cloud';
19
19
  export interface InvokeOptions {
20
+ controlSessionId?: string;
20
21
  /** 超时时间(秒),默认 15 */
21
22
  timeoutSec?: number;
22
23
  /** Force CDP transport instead of extension */
package/dist/socket.js CHANGED
@@ -13,7 +13,7 @@ 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 { listInstalledProviders } from '@mearl/provider/host';
16
+ import { ensureProviderControlHost, listInstalledProviders } from '@mearl/provider/host';
17
17
  import { readLiveProvider } from '@mearl/provider/runtime';
18
18
  import { resolveFileRefs } from './fileRefs.js';
19
19
  import { listLiveBrowsers, findBrowser, describeBrowser, } from './browserRegistry.js';
@@ -32,6 +32,7 @@ export const SOCKET_PATH = process.platform === 'win32'
32
32
  function toControlSourceWireValue(source) {
33
33
  return source === 'remote' || source === 'cloud' ? 'cloud' : 'local';
34
34
  }
35
+ const SESSION_OPTION = Symbol('control-session');
35
36
  /**
36
37
  * Process-wide default browser selector. Lets a long-lived host (e.g. the MCP
37
38
  * server) pin a target once via `select_browser` without threading it through
@@ -129,6 +130,17 @@ function isOrdinaryLocalBrowser(browser) {
129
130
  (browser.transport === 'extension' || browser.transport === 'cdp'));
130
131
  }
131
132
  function supportsAction(browser, action, data) {
133
+ if (browser.transport === 'provider') {
134
+ if (action === 'browser_control' ||
135
+ (action === 'browser_release' && data[SESSION_OPTION]))
136
+ return true;
137
+ if (action.startsWith('__artifact_'))
138
+ return (browser.capabilities?.actions.includes('page_upload') === true ||
139
+ ((action === '__artifact_read' || action === '__artifact_remove') &&
140
+ browser.capabilities?.actions.includes('page_download') === true));
141
+ if (action === 'run_actions' && data[SESSION_OPTION])
142
+ return true;
143
+ }
132
144
  if (action === 'get_versions')
133
145
  return true;
134
146
  if (action === 'get_browser_access_url') {
@@ -328,6 +340,7 @@ export function invoke(action, data = {}, options = {}) {
328
340
  const { timeoutSec = resolveActionTimeoutSec(action, data), transport, browser, controlSource = 'local', } = options;
329
341
  // 统一解析 payload 顶层字段中的 `@<path>` 文件引用(所有 action 通用)
330
342
  const resolvedData = resolveFileRefs(data);
343
+ Object.defineProperty(resolvedData, SESSION_OPTION, { value: options.controlSessionId });
331
344
  if (action === 'browser_list') {
332
345
  return dispatchInvoke(action, resolvedData, timeoutSec, transport, undefined, controlSource).then(result => decorateBrowserListResult(transport === 'cdp' ? cdpOnlyBrowserList(result) : result));
333
346
  }
@@ -413,14 +426,15 @@ async function dispatchInvoke(action, data, timeoutSec, transport, selector, con
413
426
  if (match.status === 'pairing' &&
414
427
  action !== 'get_versions' &&
415
428
  action !== 'browser_close' &&
416
- action !== 'browser_release') {
429
+ action !== 'browser_release' &&
430
+ action !== 'browser_control') {
417
431
  throw new Error(`Browser ${describeBrowser(match)} is waiting for device pairing`);
418
432
  }
419
433
  if (!supportsAction(match, action, data)) {
420
434
  throw new Error(`Browser ${describeBrowser(match)} does not support ${action}`);
421
435
  }
422
436
  try {
423
- return await invokeOnce(action, data, timeoutSec, transport, match.socketPath, controlSource, match.providerBrowserId);
437
+ return await invokeOnce(action, data, timeoutSec, transport, match.socketPath, controlSource, match.transport === 'provider' ? match.browserId : undefined);
424
438
  }
425
439
  catch (error) {
426
440
  if (isConnError(error) || isSocketSandboxError(error)) {
@@ -439,7 +453,7 @@ async function dispatchInvoke(action, data, timeoutSec, transport, selector, con
439
453
  if (routableBrowsers.length > 0) {
440
454
  const target = routableBrowsers[0];
441
455
  try {
442
- return await invokeOnce(action, data, timeoutSec, transport, target.socketPath, controlSource, target.providerBrowserId);
456
+ return await invokeOnce(action, data, timeoutSec, transport, target.socketPath, controlSource, target.transport === 'provider' ? target.browserId : undefined);
443
457
  }
444
458
  catch (error) {
445
459
  if (isSocketSandboxError(error)) {
@@ -486,7 +500,7 @@ async function invokeViaLegacy(action, data, timeoutSec, transport, controlSourc
486
500
  !(transport === 'cdp' && browser.transport === 'provider') &&
487
501
  supportsAction(browser, action, data));
488
502
  if (target) {
489
- return invokeOnce(action, data, timeoutSec, transport, target.socketPath, controlSource, target.providerBrowserId);
503
+ return invokeOnce(action, data, timeoutSec, transport, target.socketPath, controlSource, target.transport === 'provider' ? target.browserId : undefined);
490
504
  }
491
505
  return invokeViaLegacy(action, data, timeoutSec, transport, controlSource,
492
506
  /* isRetry */ true);
@@ -497,7 +511,11 @@ async function invokeViaLegacy(action, data, timeoutSec, transport, controlSourc
497
511
  throw new Error(formatConnectError(err, SOCKET_PATH), { cause: error });
498
512
  }
499
513
  }
500
- function invokeOnce(action, data, timeoutSec, transport, socketPath, controlSource, providerBrowserId) {
514
+ async function invokeOnce(action, data, timeoutSec, transport, socketPath, controlSource, providerTarget) {
515
+ if (providerTarget) {
516
+ const host = await ensureProviderControlHost(Math.min(timeoutSec * 1000, 10_000));
517
+ socketPath = host.socketPath;
518
+ }
501
519
  const id = `${Date.now()}_${Math.random().toString(36).slice(2, 10)}`;
502
520
  return new Promise((resolve, reject) => {
503
521
  if (process.platform !== 'win32' && fs.existsSync(socketPath) && process.getuid) {
@@ -536,10 +554,12 @@ function invokeOnce(action, data, timeoutSec, transport, socketPath, controlSour
536
554
  version: CLIENT_VERSION,
537
555
  controlSource: toControlSourceWireValue(controlSource),
538
556
  };
557
+ if (data[SESSION_OPTION])
558
+ message.controlSessionId = data[SESSION_OPTION];
539
559
  if (transport)
540
560
  message.transport = transport;
541
- if (providerBrowserId)
542
- message.browserId = providerBrowserId;
561
+ if (providerTarget)
562
+ message.providerTarget = providerTarget;
543
563
  socket.write(JSON.stringify(message) + '\n');
544
564
  });
545
565
  socket.on('data', chunk => {
package/dist/unified.d.ts CHANGED
@@ -1,6 +1,11 @@
1
+ import type { BrowserArtifact } from './generated-browser-action-protocol.js';
1
2
  import { type CloudConnectorListResult } from '@mearl/cloud-types';
2
3
  import type { PluginInvocation, PluginInvokeOptions } from './plugin.js';
3
4
  export interface UnifiedInvokeOptions {
5
+ /** Use new on the first tab_open/page_snapshot, then reuse the returned id. */
6
+ session?: string;
7
+ /** Source-local session id used by transport adapters. */
8
+ controlSessionId?: string;
4
9
  timeoutSec?: number;
5
10
  /** Global browser id, source-local browser id, or unique browser name. */
6
11
  browser?: string;
@@ -25,6 +30,11 @@ interface BrowserTarget {
25
30
  }
26
31
  export declare function decodeBrowserTarget(selector: string | undefined): BrowserTarget | undefined;
27
32
  export declare function normalizeBrowserSelector(selector: string): string;
33
+ export declare function decodeControlSessionId(id: string): {
34
+ browser: string;
35
+ id: string;
36
+ transport: 'extension' | 'cdp' | 'provider';
37
+ };
28
38
  export declare class UnifiedClient {
29
39
  private readonly options;
30
40
  private defaultBrowser;
@@ -39,6 +49,13 @@ export declare class UnifiedClient {
39
49
  disconnect(): void;
40
50
  invokePlugin(name: string, invocation: PluginInvocation, options?: PluginInvokeOptions): Promise<unknown>;
41
51
  invoke(action: string, data?: Record<string, any>, options?: UnifiedInvokeOptions): Promise<any>;
52
+ saveArtifact(artifact: BrowserArtifact & {
53
+ browserId?: string;
54
+ }, outputPath: string, options?: UnifiedInvokeOptions): Promise<{
55
+ outputPath: string;
56
+ bytes: number;
57
+ }>;
58
+ private invokeRouted;
42
59
  private invokeExplicitLocal;
43
60
  private invokeLaunchWithoutControlBrowser;
44
61
  private selectDefaultBrowser;
package/dist/unified.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { stageFile, exportArtifact } from './artifacts.js';
1
2
  import WebSocket from 'ws';
2
3
  import { DEFAULT_CONNECT_TIMEOUT, DEFAULT_HEARTBEAT_INTERVAL, DEFAULT_REQUEST_TIMEOUT, MAX_BUFFER_SIZE, isHeartbeatMessage, resolveCloudConnectorSelector, } from '@mearl/cloud-types';
3
4
  import { resolveActionTimeoutSec } from './actionTimeouts.js';
@@ -48,6 +49,16 @@ export function normalizeBrowserSelector(selector) {
48
49
  const decodedTarget = decodeBrowserTarget(selector);
49
50
  return (decodedTarget ? encodeBrowserTarget(decodedTarget) : selector.trim()).toLowerCase();
50
51
  }
52
+ export function decodeControlSessionId(id) {
53
+ const match = /^mearl-session:([^:]+):(cs_[a-f0-9-]+):(extension|cdp|provider)$/.exec(id);
54
+ if (!match)
55
+ throw new Error('Invalid session id; reuse session.sessionId from the first response');
56
+ return {
57
+ browser: decodeURIComponent(match[1]),
58
+ id: match[2],
59
+ transport: match[3],
60
+ };
61
+ }
51
62
  function decorateBrowser(browser, source) {
52
63
  const safeBrowser = omitTransientBrowserSecrets(browser);
53
64
  const originalBrowserId = String(browser.browserId);
@@ -127,6 +138,67 @@ export class UnifiedClient {
127
138
  }, options);
128
139
  }
129
140
  async invoke(action, data = {}, options = {}) {
141
+ if (options.session && options.session !== 'new') {
142
+ const session = decodeControlSessionId(options.session);
143
+ const selected = decodeBrowserTarget(options.browser);
144
+ const pinned = decodeBrowserTarget(session.browser);
145
+ if (options.browser &&
146
+ (!selected ||
147
+ selected.browser !== pinned?.browser ||
148
+ selected.connector !== pinned?.connector)) {
149
+ throw new Error('The selected browser does not match the control session');
150
+ }
151
+ if (options.transport && session.transport !== options.transport)
152
+ throw new Error('A control session cannot change transport');
153
+ options = {
154
+ ...options,
155
+ browser: session.browser,
156
+ controlSessionId: session.id,
157
+ ...(session.transport === 'cdp' && !pinned?.connector ? { transport: 'cdp' } : {}),
158
+ };
159
+ }
160
+ else if (options.session === 'new') {
161
+ if (!['tab_open', 'page_snapshot', 'page_screenshot'].includes(action))
162
+ throw new Error('--session new requires tab_open, page_snapshot or page_screenshot');
163
+ options = { ...options, controlSessionId: 'new' };
164
+ }
165
+ if (action === 'page_upload' &&
166
+ data.files !== undefined &&
167
+ (!Array.isArray(data.files) ||
168
+ !data.files.length ||
169
+ data.files.some((file) => !file || (typeof file.filePath === 'string') === (typeof file.artifactId === 'string'))))
170
+ throw new Error('files requires exactly one filePath or artifactId per entry');
171
+ if (action === 'page_upload' &&
172
+ Array.isArray(data.files) &&
173
+ data.files.some((file) => file.filePath)) {
174
+ if (data.filePaths)
175
+ throw new Error('Use files or filePaths, not both');
176
+ const staged = [];
177
+ const temporary = [];
178
+ try {
179
+ for (const file of data.files) {
180
+ if (file.filePath) {
181
+ const uploaded = await stageFile(this, file.filePath, options);
182
+ options = uploaded.options;
183
+ temporary.push(uploaded.artifactId);
184
+ staged.push({ artifactId: uploaded.artifactId });
185
+ }
186
+ else
187
+ staged.push(file);
188
+ }
189
+ return await this.invokeRouted(action, { ...data, files: staged }, options);
190
+ }
191
+ finally {
192
+ for (const id of temporary)
193
+ await this.invoke('__artifact_remove', { artifactId: id }, options).catch(() => { });
194
+ }
195
+ }
196
+ return this.invokeRouted(action, data, options);
197
+ }
198
+ saveArtifact(artifact, outputPath, options = {}) {
199
+ return exportArtifact(this, artifact, outputPath, options);
200
+ }
201
+ async invokeRouted(action, data = {}, options = {}) {
130
202
  validateRequiredActionTabId(action, data);
131
203
  const timeoutSec = options.timeoutSec ?? resolveActionTimeoutSec(action, data, this.options.requestTimeoutSec);
132
204
  if (action === 'connector_list')
@@ -162,7 +234,7 @@ export class UnifiedClient {
162
234
  if (options.connector) {
163
235
  throw new Error('A local browser cannot be used together with --connector.');
164
236
  }
165
- return this.invokeLocal(action, requestData, timeoutSec, target.browser);
237
+ return this.invokeLocal(action, requestData, timeoutSec, target.browser, undefined, options.controlSessionId);
166
238
  }
167
239
  if (connector) {
168
240
  const selected = target?.connector && !options.connector && !this.defaultConnector
@@ -171,10 +243,10 @@ export class UnifiedClient {
171
243
  if (target?.connector && selected.connectorId !== target.connector) {
172
244
  throw new Error('The selected browser belongs to a different cloud connector.');
173
245
  }
174
- return this.invokeRemote(action, requestData, timeoutSec, browser, selected);
246
+ return this.invokeRemote(action, requestData, timeoutSec, browser, selected, options.controlSessionId);
175
247
  }
176
248
  if (!this.options.serverUrl) {
177
- return this.invokeLocal(action, requestData, timeoutSec, options.browser ?? this.defaultBrowser);
249
+ return this.invokeLocal(action, requestData, timeoutSec, options.browser ?? this.defaultBrowser, undefined, options.controlSessionId);
178
250
  }
179
251
  const selector = options.browser ??
180
252
  this.defaultBrowser ??
@@ -204,7 +276,7 @@ export class UnifiedClient {
204
276
  }
205
277
  const browser = optionTarget?.browser ?? defaultTarget?.browser ?? options.browser ?? this.defaultBrowser;
206
278
  const requestData = closeTarget ? { ...data, browser: closeTarget.browser } : data;
207
- return this.invokeLocal(action, requestData, timeoutSec, browser, options.transport);
279
+ return this.invokeLocal(action, requestData, timeoutSec, browser, options.transport, options.controlSessionId);
208
280
  }
209
281
  async invokeLaunchWithoutControlBrowser(data, timeoutSec) {
210
282
  const local = await this.tryLocalBrowserList(timeoutSec);
@@ -410,17 +482,18 @@ export class UnifiedClient {
410
482
  ...(warnings.length > 0 ? { warnings } : {}),
411
483
  });
412
484
  }
413
- async invokeLocal(action, data, timeoutSec, browser, transport) {
485
+ async invokeLocal(action, data, timeoutSec, browser, transport, controlSessionId) {
414
486
  const result = await invokeLocalBrowser(action, data, {
415
487
  timeoutSec,
416
488
  ...(browser ? { browser } : {}),
417
489
  ...(transport ? { transport } : {}),
418
490
  controlSource: 'local',
491
+ ...(controlSessionId ? { controlSessionId } : {}),
419
492
  });
420
493
  return this.decorateActionResult(action, result, { kind: 'local' });
421
494
  }
422
- async invokeRemote(action, data, timeoutSec, browser, connector) {
423
- const result = await this.requestRemote(action, data, timeoutSec, browser, connector.connectorId);
495
+ async invokeRemote(action, data, timeoutSec, browser, connector, controlSessionId) {
496
+ const result = await this.requestRemote(action, data, timeoutSec, browser, connector.connectorId, controlSessionId);
424
497
  return this.decorateActionResult(action, result, {
425
498
  kind: 'remote',
426
499
  connectorId: connector.connectorId,
@@ -428,6 +501,40 @@ export class UnifiedClient {
428
501
  });
429
502
  }
430
503
  decorateActionResult(action, result, source) {
504
+ if (result?.session?.sessionId && !result.session.sessionId.startsWith('mearl-session:')) {
505
+ const browserId = encodeBrowserTarget({
506
+ browser: result.session.browserId,
507
+ ...(source.connectorId ? { connector: source.connectorId } : {}),
508
+ });
509
+ result = {
510
+ ...result,
511
+ session: {
512
+ ...result.session,
513
+ browserId,
514
+ sessionId: `mearl-session:${encodeURIComponent(browserId)}:${result.session.sessionId}:${result.session.transport}`,
515
+ },
516
+ };
517
+ }
518
+ if (typeof result?.browserId === 'string' && action.startsWith('__artifact_')) {
519
+ return {
520
+ ...result,
521
+ browserId: encodeBrowserTarget({
522
+ browser: result.browserId,
523
+ ...(source.connectorId ? { connector: source.connectorId } : {}),
524
+ }),
525
+ };
526
+ }
527
+ if (result?.download?.browserId)
528
+ result = {
529
+ ...result,
530
+ download: {
531
+ ...result.download,
532
+ browserId: encodeBrowserTarget({
533
+ browser: result.download.browserId,
534
+ ...(source.connectorId ? { connector: source.connectorId } : {}),
535
+ }),
536
+ },
537
+ };
431
538
  if (action !== 'browser_launch' || typeof result?.browserId !== 'string')
432
539
  return result;
433
540
  return decorateBrowser(result, source);
@@ -526,7 +633,7 @@ export class UnifiedClient {
526
633
  else
527
634
  pending.reject(new Error(response.error || 'Unknown error from cloud-server'));
528
635
  }
529
- async requestRemote(action, data, timeoutSec, browser, connector) {
636
+ async requestRemote(action, data, timeoutSec, browser, connector, controlSessionId) {
530
637
  await this.ensureConnected();
531
638
  const ws = this.ws;
532
639
  if (!ws || ws.readyState !== WebSocket.OPEN)
@@ -537,6 +644,8 @@ export class UnifiedClient {
537
644
  message.browser = browser;
538
645
  if (connector)
539
646
  message.connector = connector;
647
+ if (controlSessionId)
648
+ message.controlSessionId = controlSessionId;
540
649
  const serialized = `${JSON.stringify(message)}\n`;
541
650
  const bytes = Buffer.byteLength(serialized, 'utf8');
542
651
  if (bytes > MAX_BUFFER_SIZE) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mearl/client",
3
- "version": "2.19.3",
3
+ "version": "2.20.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/cloud-types": "2.19.3",
63
- "@mearl/provider": "2.19.3"
62
+ "@mearl/cloud-types": "2.20.0",
63
+ "@mearl/provider": "2.20.0"
64
64
  },
65
65
  "devDependencies": {
66
66
  "@types/node": "^24.9.1",