dsh-site-bridge 0.6.4 → 0.7.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
@@ -197,6 +197,14 @@ instead of `<all_urls>` and does not inject a content script into every page. Th
197
197
  step and Chrome's debugging banner. In exchange the permission is granted per tab, for the duration
198
198
  of a session, and requests are read where they are made — no credential is copied out of the browser.
199
199
 
200
+ ### Consent
201
+
202
+ Attaching a tab is a decision for the person by default. The agent may list tabs freely, but
203
+ `site_tabs {action:"claim"}` on a tab the user has not attached is refused unless the call carries
204
+ `confirmed: true` — and the refusal names the tab, so the agent knows what to ask about. Closing a tab
205
+ the user attached needs the same confirmation. This is a rule inside the plugin, not an OS permission:
206
+ it makes the agent stop and ask instead of quietly widening what it can reach.
207
+
200
208
  ## Limitations
201
209
 
202
210
  Cross-origin iframes, closed shadow roots and body size caps are analysed in
package/README.ru.md CHANGED
@@ -196,6 +196,15 @@ site_inject {js:"..."} → выполнить ваш скрипт в с
196
196
  Chrome. Взамен право выдаётся на вкладку и на время сессии, а запросы читаются там, где они сделаны:
197
197
  учётные данные не покидают браузер.
198
198
 
199
+ ### Согласие
200
+
201
+ Подключение вкладки по умолчанию — решение человека. Список вкладок агент получает свободно, но
202
+ `site_tabs {action:"claim"}` для вкладки, которую пользователь не подключал, отклоняется, пока в вызове
203
+ нет `confirmed: true` — причём в отказе названа сама вкладка, чтобы было понятно, о чём спрашивать.
204
+ Закрытие вкладки, подключённой пользователем, требует такого же подтверждения. Это правило внутри
205
+ плагина, а не разрешение ОС: оно заставляет агента остановиться и спросить, вместо того чтобы тихо
206
+ расширять свою область доступа.
207
+
199
208
  ## Ограничения
200
209
 
201
210
  Кросс-доменные iframe, закрытые shadow-корни и лимиты тела разобраны в
package/README.zh-CN.md CHANGED
@@ -162,6 +162,13 @@ site_inject {js:"..."} → 在页面里执行你的脚本以验证
162
162
  content script。代价是配对步骤与 Chrome 的调试提示条。换来的是:权限按标签页、按会话授予,请求在
163
163
  产生的地方被读取,凭据不离开浏览器。
164
164
 
165
+ ### 用户同意
166
+
167
+ 默认情况下,连接标签页是人的决定。智能体可以自由列出标签页,但对用户未连接的标签页调用
168
+ `site_tabs {action:"claim"}` 会被拒绝,除非调用带有 `confirmed: true`——拒绝信息会指出具体标签页,便于
169
+ 智能体询问。关闭用户自己连接的标签页同样需要确认。这是插件内部的规则,而不是操作系统权限:它让智能体
170
+ 停下来询问,而不是悄悄扩大可达范围。
171
+
165
172
  ## 限制
166
173
 
167
174
  跨域 iframe、封闭的 shadow root 与响应体大小上限在 [docs/limitations.md](docs/limitations.md) 中逐条分析 —— 包含代码位置、消除方案与预估成本。
@@ -2,7 +2,7 @@
2
2
  "manifest_version": 3,
3
3
  "default_locale": "en",
4
4
  "name": "__MSG_appName__",
5
- "version": "0.6.4",
5
+ "version": "0.7.0",
6
6
  "description": "__MSG_appDesc__",
7
7
  "minimum_chrome_version": "116",
8
8
  "permissions": [
package/extension/sw.js CHANGED
@@ -165,7 +165,7 @@ function handleMessage(message) {
165
165
 
166
166
  async function handleMessageInner(message) {
167
167
  if (message?.type === 'claim') {
168
- const target = await claimTab(message.tabId)
168
+ const target = await claimTab(message.tabId, 'user')
169
169
  return { ok: true, target }
170
170
  }
171
171
  if (message?.type === 'use') {
@@ -247,7 +247,7 @@ async function detach(tabId) {
247
247
  await chrome.debugger.detach({ tabId: session.tabId }).catch(() => {})
248
248
  }
249
249
 
250
- async function attach(tabId) {
250
+ async function attach(tabId, attachedBy = 'agent') {
251
251
  const existing = state.sessions.get(tabId)
252
252
  if (existing) {
253
253
  state.activeTabId = tabId
@@ -265,7 +265,8 @@ async function attach(tabId) {
265
265
  const tab = await chrome.tabs.get(tabId)
266
266
  const session = {
267
267
  tabId,
268
- target: { id: tab.id, url: tab.url, title: tab.title },
268
+ attachedBy,
269
+ target: { id: tab.id, url: tab.url, title: tab.title, attachedBy },
269
270
  recording: false,
270
271
  entries: [],
271
272
  picking: false,
@@ -278,7 +279,7 @@ async function attach(tabId) {
278
279
  return session.target
279
280
  }
280
281
 
281
- async function claimTab(tabId) {
282
+ async function claimTab(tabId, attachedBy = 'agent') {
282
283
  // Without an explicit id, claim whatever tab the user is looking at, so a
283
284
  // caller that only knows "this tab" cannot silently pass NaN.
284
285
  let id = Number(tabId)
@@ -291,13 +292,13 @@ async function claimTab(tabId) {
291
292
  if (!/^https?:|^file:/.test(tab.url ?? '')) {
292
293
  throw new Error(`cannot drive ${tab.url || 'this tab'}: only http(s) and file:// pages are supported`)
293
294
  }
294
- return attach(id)
295
+ return attach(id, attachedBy)
295
296
  }
296
297
 
297
298
  chrome.tabs.onUpdated.addListener((tabId, info, tab) => {
298
299
  const session = state.sessions.get(tabId)
299
300
  if (!session) return
300
- const next = { id: tab.id, url: tab.url, title: tab.title }
301
+ const next = { id: tab.id, url: tab.url, title: tab.title, attachedBy: session.attachedBy }
301
302
  if (next.title === session.target.title && next.url === session.target.url) return
302
303
  session.target = next
303
304
  void evt('target', { tab: next, tabId })
@@ -1530,7 +1531,7 @@ async function dispatch(op, args) {
1530
1531
  }
1531
1532
  case 'tab.open': {
1532
1533
  const tab = await chrome.tabs.create({ url: String(args.url ?? 'about:blank'), active: args.active !== false })
1533
- await attach(tab.id)
1534
+ await attach(tab.id, 'agent')
1534
1535
  // A fresh tab has no title yet: wait for the load so the caller gets a
1535
1536
  // target it can show, instead of an empty string.
1536
1537
  const deadline = Date.now() + Math.min(Number(args.waitMs ?? 10000), 30000)
@@ -1542,7 +1543,7 @@ async function dispatch(op, args) {
1542
1543
  }
1543
1544
  const session = state.sessions.get(tab.id)
1544
1545
  if (session) {
1545
- session.target = { id: info.id, url: info.url, title: info.title }
1546
+ session.target = { id: info.id, url: info.url, title: info.title, attachedBy: session.attachedBy }
1546
1547
  await evt('target', { tab: session.target, tabId: tab.id })
1547
1548
  }
1548
1549
  return { target: session?.target ?? { id: tab.id, url: tab.url, title: tab.title } }
package/lib/bridge.js CHANGED
@@ -320,7 +320,9 @@ export class Bridge {
320
320
  const tabId = Number(event.tabId ?? tab?.id)
321
321
  if (single) {
322
322
  const id = Number(tab.id)
323
- this.targets.set(id, { id, url: String(tab.url ?? ''), title: String(tab.title ?? ''), at: Date.now() })
323
+ // Keep whatever else the extension says about the tab (attachedBy, for
324
+ // example): the consent rule depends on it.
325
+ this.targets.set(id, { ...tab, id, url: String(tab.url ?? ''), title: String(tab.title ?? ''), at: Date.now() })
324
326
  if (this.activeTabId == null) this.activeTabId = id
325
327
  } else if (Number.isFinite(tabId)) {
326
328
  this.targets.delete(tabId)
package/lib/index.js CHANGED
@@ -259,15 +259,25 @@ export function apply(ctx, config) {
259
259
  'site_tabs',
260
260
  'Вкладки браузера и выбор рабочей вкладки. Можно держать подключёнными несколько вкладок: ' +
261
261
  'claim добавляет ещё одну, use переключает активную, release отключает, open открывает новую и сразу ' +
262
- 'подключает её, focus выводит вкладку на передний план. Вкладка должна быть http(s) или file://.',
262
+ 'подключает её, focus выводит вкладку на передний план. Вкладка должна быть http(s) или file://.\n' +
263
+ 'ПРАВИЛО: подключать вкладку, которую пользователь не подключал сам, можно только с его явного ' +
264
+ 'разрешения. Без confirmed:true вызов claim отклоняется — сначала спросите пользователя.',
263
265
  {
264
266
  action: { type: 'string', description: 'list | claim | use | release | open | close | focus' },
265
- tabId: { type: 'number', description: 'id вкладки для claim / use / release / focus (из action=list)' },
266
- url: { type: 'string', description: 'адрес для action=open (по умолчанию about:blank)' }
267
+ tabId: { type: 'number', description: 'id вкладки для claim / use / release / focus / close (из action=list)' },
268
+ url: { type: 'string', description: 'адрес для action=open (по умолчанию about:blank)' },
269
+ confirmed: {
270
+ type: 'boolean',
271
+ description:
272
+ 'Только после явного разрешения пользователя: подтверждает подключение вкладки, ' +
273
+ 'которую он не подключал, или закрытие вкладки, которую он подключил сам'
274
+ }
267
275
  },
268
276
  async (args) => {
269
277
  const instance = await ensureBridge()
270
278
  const action = String(args.action ?? 'list')
279
+ const confirmed = args.confirmed === true
280
+ const attached = (id) => (instance.state().targets ?? []).find((item) => item.id === id) ?? null
271
281
  if (action === 'open') {
272
282
  const result = await instance.enqueue(
273
283
  'tab.open',
@@ -280,6 +290,13 @@ export function apply(ctx, config) {
280
290
  if (action === 'close') {
281
291
  const tabId = Number(args.tabId ?? instance.state().activeTabId)
282
292
  if (!Number.isFinite(tabId)) throw new Error('нечего закрывать: передайте tabId')
293
+ const known = attached(tabId)
294
+ if (known?.attachedBy === 'user' && !confirmed) {
295
+ throw new Error(
296
+ `Вкладку [${tabId}] ${known.title || known.url || ''} подключили вы, а не агент. ` +
297
+ 'Закрытие такой вкладки требует явного подтверждения: спросите пользователя и повторите с confirmed:true.'
298
+ )
299
+ }
283
300
  await instance.enqueue('tab.close', { tabId }, { timeout: 20000 })
284
301
  return ok(`Вкладка ${tabId} закрыта.`, { tabId })
285
302
  }
@@ -292,6 +309,16 @@ export function apply(ctx, config) {
292
309
  if (action === 'claim' || action === 'use' || action === 'release') {
293
310
  const tabId = Number(args.tabId)
294
311
  if (!Number.isFinite(tabId)) throw new Error(`tabId is required for action=${action}`)
312
+ if (action === 'claim' && !attached(tabId) && !confirmed) {
313
+ const list = await instance.enqueue('tabs.list', {}, { timeout: 20000 })
314
+ const known = (list?.tabs ?? []).find((item) => item.id === tabId)
315
+ const label = known ? `${known.title || '(без названия)'} — ${known.url}` : `вкладка ${tabId}`
316
+ throw new Error(
317
+ `Вкладка не подключена: ${label}. Пользователь её не подключал, а правило плагина требует его ` +
318
+ 'явного разрешения на доступ. Спросите пользователя (назовите эту вкладку) и только после ' +
319
+ 'согласия повторите вызов с confirmed:true.'
320
+ )
321
+ }
295
322
  const op = action === 'claim' ? 'tab.claim' : action === 'use' ? 'tab.use' : 'tab.release'
296
323
  const result = await instance.enqueue(op, { tabId }, { timeout: 20000 })
297
324
  if (action === 'release') return ok(`Вкладка ${tabId} отключена.`, { tabId })
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-site-bridge",
3
- "version": "0.6.4",
3
+ "version": "0.7.0",
4
4
  "description": "Chrome/Edge MV3 bridge for DeepSeek Harness: capture page markup (DOM, styles, selectors) and BFF traffic (xhr/fetch with headers and bodies) into a workspace snapshot bundle, drive the page with trusted CDP input, and verify injected scripts. Authorization never leaves the browser and is never written to disk.",
5
5
  "icon": "./icon.svg",
6
6
  "type": "module",