@angelky7/dsh-browser-control 0.0.0-stage → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,25 @@
1
+ //#region lib/types/invariant.js
2
+ /**
3
+ * Package-owned invariant companion for `@deepseek-ai/dsh-browser-bridge`.
4
+ * @module @deepseek-ai/dsh-browser-bridge/invariant
5
+ */
6
+ const PACKAGE_NAME = "@deepseek-ai/dsh-browser-bridge";
7
+ /** Cordis companion plugin name. */
8
+ const name = "browser-bridge-invariant";
9
+ /** Service required before the companion can reserve package ownership. */
10
+ const inject = ["invariants"];
11
+ /**
12
+ * No runtime invariant: the bridge owns no durable session events or shared
13
+ * mutable state; its socket lifecycle relations (dispose releases the port,
14
+ * pending commands reject on link loss) are asserted by unit tests against
15
+ * the private server instance.
16
+ */
17
+ const install = () => {};
18
+ /**
19
+ * Register this package's invariant companion.
20
+ * @param ctx - Cordis context carrying the invariant service.
21
+ * @returns the installed registration's disposer after setup succeeds.
22
+ */
23
+ const apply = (ctx) => Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install));
24
+ //#endregion
25
+ export { apply, inject, name };
@@ -0,0 +1,64 @@
1
+ /**
2
+ * 工具调用失败时的结构化契约。
3
+ *
4
+ * Agent 拿到一句 `Error("...")` 文本时,其实无法决定下一步:该重试、该放弃、还是
5
+ * 该先去确认页面状态?真正需要区分的是两件事——
6
+ *
7
+ * 1. `code`:失败的可分类原因(链路没起、扩展没连、超时、扩展报错……)
8
+ * 2. `effectState`:这次调用的副作用**是否可能已经落到页面上**
9
+ *
10
+ * 第二条是重点:`click` 超时和"扩展中途断开"都属于**结果未知**——鼠标事件也许已经
11
+ * 发出去了。这时让 agent 盲目重试,就会点两次、提交两次表单。所以对写操作,
12
+ * 我们宁可报 `unknown` 并要求它先观察,也不假装"没发生"。
13
+ *
14
+ * 读操作(截图、读文本、查元素、列标签……)不改变页面,任何失败都是 `not_started`,
15
+ * 重试安全。
16
+ */
17
+ /** 失败原因分类。 */
18
+ export type BridgeErrorCode = 'bridge_disabled' | 'extension_offline' | 'command_timeout' | 'link_lost' | 'cancelled' | 'not_configured' | 'blocked_by_policy' | 'locked_by_user' | 'lock_unreadable' | 'held_by_user' | 'command_failed';
19
+ /**
20
+ * 扩展侧拒绝命令时写在 error 文本最前面的两个标记。
21
+ *
22
+ * 结果帧里只有一个 `error` 字符串,没有 code 字段,所以「这条命令根本没发出去」
23
+ * 这个信息只能长在文本上穿过渡线上。字面量在 `extension/background.js` 里有一份
24
+ * 一模一样的,`verify/verify_errors.mjs` 会对账两边——少了对账就是一边改了前缀、
25
+ * 另一边静默退回 `command_failed` + `unknown`(写操作会被说成「可能已经落到页面上」,
26
+ * 而那恰好是假的)。
27
+ */
28
+ export declare const LOCKED_BY_USER_MARKER = "browser-locked-by-user:";
29
+ export declare const LOCK_UNREADABLE_MARKER = "browser-lock-unreadable:";
30
+ /** 接管浮层上的「中止操作」。同样的线协议约定:字面量在扩展侧有一份,对账在 `verify_errors.mjs`。 */
31
+ export declare const HOLD_ABORTED_MARKER = "browser-hold-aborted:";
32
+ /** 副作用状态。 */
33
+ export type EffectState =
34
+ /** 确定没有副作用落到页面上,重试安全。 */
35
+ 'not_started'
36
+ /** 无法判断(写操作超时、在途掉线、扩展中途报错),重试前应先观察。 */
37
+ | 'unknown'
38
+ /** 已确认执行完成(用于成功路径的显式标注,目前只在结果里出现)。 */
39
+ | 'committed';
40
+ /** 该命令是否可能留下副作用。 */
41
+ export declare function isWriteCommand(command: string): boolean;
42
+ /** 带分类的桥接错误;`message` 面向模型,`toJSON()` 面向程序。 */
43
+ export declare class BridgeError extends Error {
44
+ readonly code: BridgeErrorCode;
45
+ readonly effectState: EffectState;
46
+ readonly hint: string;
47
+ readonly command: string;
48
+ /** 原始失败描述:HTTP 面的 `error` 字段就是它,不含我们追加的契约文案。 */
49
+ readonly detail: string;
50
+ constructor(command: string, code: BridgeErrorCode, effectState: EffectState, message: string);
51
+ /** 给 HTTP 面用的扁平 JSON 形状。 */
52
+ toJSON(): {
53
+ ok: false;
54
+ error: string;
55
+ code: string;
56
+ effectState: string;
57
+ hint: string;
58
+ };
59
+ }
60
+ /**
61
+ * 把任意底层抛出的错误归类成 BridgeError。已经分类过的原样返回,
62
+ * 保证多次包裹不会把 code 洗掉。
63
+ */
64
+ export declare function classify(command: string, error: unknown): BridgeError;
@@ -0,0 +1,161 @@
1
+ /**
2
+ * 工具调用失败时的结构化契约。
3
+ *
4
+ * Agent 拿到一句 `Error("...")` 文本时,其实无法决定下一步:该重试、该放弃、还是
5
+ * 该先去确认页面状态?真正需要区分的是两件事——
6
+ *
7
+ * 1. `code`:失败的可分类原因(链路没起、扩展没连、超时、扩展报错……)
8
+ * 2. `effectState`:这次调用的副作用**是否可能已经落到页面上**
9
+ *
10
+ * 第二条是重点:`click` 超时和"扩展中途断开"都属于**结果未知**——鼠标事件也许已经
11
+ * 发出去了。这时让 agent 盲目重试,就会点两次、提交两次表单。所以对写操作,
12
+ * 我们宁可报 `unknown` 并要求它先观察,也不假装"没发生"。
13
+ *
14
+ * 读操作(截图、读文本、查元素、列标签……)不改变页面,任何失败都是 `not_started`,
15
+ * 重试安全。
16
+ */
17
+ /**
18
+ * 扩展侧拒绝命令时写在 error 文本最前面的两个标记。
19
+ *
20
+ * 结果帧里只有一个 `error` 字符串,没有 code 字段,所以「这条命令根本没发出去」
21
+ * 这个信息只能长在文本上穿过渡线上。字面量在 `extension/background.js` 里有一份
22
+ * 一模一样的,`verify/verify_errors.mjs` 会对账两边——少了对账就是一边改了前缀、
23
+ * 另一边静默退回 `command_failed` + `unknown`(写操作会被说成「可能已经落到页面上」,
24
+ * 而那恰好是假的)。
25
+ */
26
+ export const LOCKED_BY_USER_MARKER = 'browser-locked-by-user:';
27
+ export const LOCK_UNREADABLE_MARKER = 'browser-lock-unreadable:';
28
+ /** 接管浮层上的「中止操作」。同样的线协议约定:字面量在扩展侧有一份,对账在 `verify_errors.mjs`。 */
29
+ export const HOLD_ABORTED_MARKER = 'browser-hold-aborted:';
30
+ /**
31
+ * 会改变页面或不可逆地改变浏览器状态的命令;其余按只读处理。
32
+ *
33
+ * 表里绝大多数是**扩展命令名**(`classify()` 按调用方传进来的名字查表),但也包含
34
+ * 插件本地命令(见下面 `cleanup`):判据始终是同一条——失败之后重试会不会多造成一次损害。
35
+ *
36
+ * 判据是「重试会不会造成额外损害」,不是「有没有动到浏览器」。所以标签分组
37
+ * (`tabs.group` / `tabs.ungroup`)不在这里:把已经在组里的标签再归一次是空操作,
38
+ * 拆组失败后再拆一次也不会多拆坏东西,重试安全;而 `tabs.open` 重试会多出标签页、
39
+ * `tabs.close` 重试可能关掉用户正在看的页面,必须报 unknown。
40
+ */
41
+ const WRITE_COMMANDS = new Set([
42
+ 'nav', 'click', 'input', 'press', 'scroll', 'eval', 'emulate',
43
+ 'tabs.open', 'tabs.close', 'tabs.closeMany', 'tabs.activate', 'dialog',
44
+ // 这条不是扩展命令,而是插件本地命令 `browser_cleanup`:它逐条 rm,中途失败
45
+ // (Windows 上被占用的截图会 EPERM)时前面的文件已经没了,报 not_started
46
+ // 就是告诉模型「什么都没发生,重试安全」,而那正好是假的。
47
+ 'cleanup',
48
+ // 往页面里插一个宿主元素 = 改页面 DOM。超时/掉线之后重试,可能多留一层浮层
49
+ //(旧的那层还挂着),所以报 unknown 而不是「没动过页面」。
50
+ 'hold',
51
+ ]);
52
+ /** 该命令是否可能留下副作用。 */
53
+ export function isWriteCommand(command) {
54
+ return WRITE_COMMANDS.has(command);
55
+ }
56
+ /** 每个 code 对应的下一步建议(写给 agent,不是写给用户看的日志)。 */
57
+ const HINTS = {
58
+ bridge_disabled: '桥接未启用:在 profile 的 cordis.patch.yml 里给 browser-bridge 配 enabled: true 后重启 dsh;不要重试本命令。',
59
+ extension_offline: '浏览器扩展没连上:让用户打开装了 DSH Browser Control 的那个浏览器(扩展图标应为绿色),确认端口与 Token 两边一致后再试。',
60
+ command_timeout: '扩展超时未回应:先别重试。用 browser_find / browser_snapshot 看页面当前状态,判断动作是否其实已经生效。',
61
+ link_lost: '命令在途时链路断了:动作可能已经落到页面上。先用只读工具确认页面状态,再决定是否重做。',
62
+ cancelled: '调用被取消:按需重新发起,不必当作错误处理。',
63
+ not_configured: '插件还没读到配置:确认 dsh 已加载本插件(dsh plugin list)后再试。',
64
+ held_by_user: '这是用户在页面上的接管浮层里按下的「中止操作」,不是故障:命令一条都没有发出。先与用户确认;他同意继续之后调用 browser_hold action=off 解除,然后再继续原来的步骤。不要重试,也不要换成别的工具绕过它。',
65
+ blocked_by_policy: '这是本机策略拦下的,不是故障:命令没有发给浏览器(零副作用)。要放开改 profile 的 cordis.patch.yml(browser-bridge 的 readOnly / allowScript)并重启 dsh,或换成本机允许的只读工具;不要原地重试。',
66
+ locked_by_user: '这是用户在扩展弹窗里按下的「锁定」,不是故障:命令一条都没有发出。请先与用户确认,由他在同一个弹窗里点「已锁定」解除;不要重试,也不要试图改配置绕过它。',
67
+ lock_unreadable: '扩展这次读不出锁的状态,按冻结处理了:命令没有发出。下一条命令会重新读一次,所以可以再试一次,但不要循环重试;一直如此就请用户重启浏览器或重装扩展。',
68
+ command_failed: '扩展报回失败:按 error 文本处理,通常是选择器没命中、标签页已关闭或页面不允许该操作。',
69
+ };
70
+ /**
71
+ * 失败文案里「该不该重试」那半句。
72
+ *
73
+ * 宿主的失败内容是 `Error: ${message}`(`@deepseek-ai/dsh-tools` `lib/index.js:3616-3623`),
74
+ * `code` / `effectState` / `hint` 这些结构化字段只进 `error.info` 与 HTTP 面,
75
+ * **模型读不到**。所以决定下一步所需的两个信息——副作用状态与下一步动作——必须写进
76
+ * message 本身,否则整个错误契约对 agent 失效(和「render 只写摘要」是同一类缺陷)。
77
+ */
78
+ function effectClause(effectState) {
79
+ switch (effectState) {
80
+ case 'unknown':
81
+ return 'effectState=unknown:动作可能已经落到页面上,先用只读工具看当前状态,再决定要不要重做,不要直接重试';
82
+ case 'committed':
83
+ return 'effectState=committed:动作已经生效';
84
+ default:
85
+ return 'effectState=not_started:这次调用没有动到页面,重试安全';
86
+ }
87
+ }
88
+ /** 带分类的桥接错误;`message` 面向模型,`toJSON()` 面向程序。 */
89
+ export class BridgeError extends Error {
90
+ code;
91
+ effectState;
92
+ hint;
93
+ command;
94
+ /** 原始失败描述:HTTP 面的 `error` 字段就是它,不含我们追加的契约文案。 */
95
+ detail;
96
+ constructor(command, code, effectState, message) {
97
+ super(`[${code}] ${message} — ${effectClause(effectState)}。下一步:${HINTS[code]}`);
98
+ this.name = 'BridgeError';
99
+ this.code = code;
100
+ this.effectState = effectState;
101
+ this.hint = HINTS[code];
102
+ this.command = command;
103
+ this.detail = message;
104
+ }
105
+ /** 给 HTTP 面用的扁平 JSON 形状。 */
106
+ toJSON() {
107
+ return {
108
+ ok: false,
109
+ error: this.detail,
110
+ code: this.code,
111
+ effectState: this.effectState,
112
+ hint: this.hint,
113
+ };
114
+ }
115
+ }
116
+ /**
117
+ * 把任意底层抛出的错误归类成 BridgeError。已经分类过的原样返回,
118
+ * 保证多次包裹不会把 code 洗掉。
119
+ */
120
+ export function classify(command, error) {
121
+ if (error instanceof BridgeError)
122
+ return error;
123
+ const raw = error instanceof Error ? error.message : String(error);
124
+ // 扩展弹窗的锁把「这条命令根本没发出去」写在文本前缀上(结果帧里只有 error 一个
125
+ // 字符串可以带信息)。先认它,并把标记从给模型看的那句话里剥掉:它不该跟着
126
+ // 落到上下文中。认不出前缀的那些(老扩展、别的失败)照原路走。
127
+ const locked = raw.startsWith(LOCKED_BY_USER_MARKER) ? 'locked_by_user'
128
+ : raw.startsWith(LOCK_UNREADABLE_MARKER) ? 'lock_unreadable'
129
+ : raw.startsWith(HOLD_ABORTED_MARKER) ? 'held_by_user'
130
+ : undefined;
131
+ const markerLength = locked === 'locked_by_user' ? LOCKED_BY_USER_MARKER.length
132
+ : locked === 'lock_unreadable' ? LOCK_UNREADABLE_MARKER.length
133
+ : locked === 'held_by_user' ? HOLD_ABORTED_MARKER.length
134
+ : 0;
135
+ const message = locked ? raw.slice(markerLength).trim() : raw;
136
+ // 写操作一旦超时或掉线,结果就是未知的;读操作永远可以安全重试。
137
+ const unknownIsPossible = isWriteCommand(command);
138
+ let code;
139
+ if (locked !== undefined)
140
+ code = locked;
141
+ else if (/no browser extension connected/.test(message))
142
+ code = 'extension_offline';
143
+ else if (/disconnected mid-command|browser-bridge stopped/.test(message))
144
+ code = 'link_lost';
145
+ else if (/timed out after/.test(message))
146
+ code = 'command_timeout';
147
+ else if (/cancelled/.test(message))
148
+ code = 'cancelled';
149
+ else if (/尚未加载配置|not configured yet/.test(message))
150
+ code = 'not_configured';
151
+ else if (/未启用|not enabled|桥接启动失败|listen EADDR|settings integration/.test(message))
152
+ code = 'bridge_disabled';
153
+ else
154
+ code = 'command_failed';
155
+ const effectState = code === 'command_timeout' || code === 'link_lost'
156
+ ? (unknownIsPossible ? 'unknown' : 'not_started')
157
+ : code === 'command_failed'
158
+ ? (unknownIsPossible ? 'unknown' : 'not_started')
159
+ : 'not_started';
160
+ return new BridgeError(command, code, effectState, message);
161
+ }
@@ -0,0 +1,62 @@
1
+ /**
2
+ * Browser-bridge plugin: one local WebSocket endpoint the DSH Browser Control
3
+ * extension connects to, plus the model-facing `browser_*` tools that drive
4
+ * it. The Settings-managed `enabled` flag starts and stops the listener live
5
+ * through dsh-settings' change hook — no reload needed.
6
+ *
7
+ * Settings integration is feature-detected, never assumed. The plugin bypasses the
8
+ * higher-level `installSettingsSection` helper (importing it on a build that does not
9
+ * export it crashes the plugin at module load) and calls the lower-level
10
+ * `sctx.settings.register` only when the host actually has it — dsh-settings up to
11
+ * 0.1.2-rc.1. 0.1.7-rc.2 and 0.2.x export `SettingsForms` with no `register`, so there
12
+ * the bridge converges from the profile config alone and the host projects this
13
+ * plugin's own `Config` schema into the settings page.
14
+ *
15
+ * Tools stay mounted whenever the plugin does; calling one while the bridge
16
+ * is disabled or the extension is offline fails with a message naming the
17
+ * fix, so the model can tell the user what to do instead of hanging.
18
+ * @module @deepseek-ai/dsh-browser-bridge
19
+ */
20
+ import { type Context } from '@deepseek-ai/cordis';
21
+ import z from '@deepseek-ai/schemastery';
22
+ export { LOCAL_DESTRUCTIVE_COMMANDS, PAGE_MUTATING_COMMANDS, READ_ONLY_ALLOWED, SCRIPT_COMMANDS, policyRefusal } from './policy.ts';
23
+ /** Cordis plugin name used by loader diagnostics. */
24
+ export declare const name = "browser-bridge";
25
+ /** The tool registry this plugin contributes `browser_*` tools to. */
26
+ export declare const inject: string[];
27
+ /** Settings namespace carrying the bridge switch and endpoint options. */
28
+ export declare const BROWSER_BRIDGE_SETTINGS_NAMESPACE = "browser-bridge";
29
+ export interface Config {
30
+ /**
31
+ * Whether the local bridge listens. Defaults to true so the plugin is
32
+ * usable immediately after install; set false to keep the tools mounted but
33
+ * every call reports how to enable the bridge, so the opt-out stays explicit.
34
+ */
35
+ enabled?: boolean;
36
+ /** Loopback port the extension dials; the HTTP face shares it. */
37
+ port?: number;
38
+ /** Shared secret the extension presents on the WebSocket upgrade query. */
39
+ token?: string;
40
+ /**
41
+ * Directory screenshots are written to and `cleanup` clears. Relative paths
42
+ * resolve against the process working directory at resolve time.
43
+ */ shotsDir?: string;
44
+ /**
45
+ * Read-only mode: refuse every command that would change the page or the
46
+ * browser (click / input / press / nav / scroll / tabs.* / emulate / dialog /
47
+ * network.clear …) while reads keep working. Off by default. Takes effect
48
+ * live — the gate reads the current settings on every call.
49
+ */
50
+ readOnly?: boolean;
51
+ /**
52
+ * Allow running arbitrary page scripts (`browser_evaluate`). On by default,
53
+ * because that tool is the escape hatch when no dedicated command fits. Set
54
+ * false to keep clicks and reads while forbidding arbitrary JS in the page:
55
+ * `browser_evaluate` can do everything the other tools can, so "read-only"
56
+ * alone does not take arbitrary code off the table.
57
+ */
58
+ allowScript?: boolean;
59
+ }
60
+ export declare const Config: z<Config>;
61
+ /** Cordis plugin entry: wire the settings-driven lifecycle plus the model-facing tools. */
62
+ export declare function apply(ctx: Context, config: Config): void;