dsh-site-bridge 0.6.4 → 0.7.2

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) 中逐条分析 —— 包含代码位置、消除方案与预估成本。
@@ -149,5 +149,8 @@
149
149
  },
150
150
  "disconnectTab": {
151
151
  "message": "Disconnect this tab"
152
+ },
153
+ "notClaimable": {
154
+ "message": "This page cannot be attached: Chrome does not allow service pages or other extensions' pages"
152
155
  }
153
156
  }
@@ -149,5 +149,8 @@
149
149
  },
150
150
  "disconnectTab": {
151
151
  "message": "Отключить эту вкладку"
152
+ },
153
+ "notClaimable": {
154
+ "message": "Эту страницу нельзя подключить: Chrome не даёт доступ к служебным страницам и страницам других расширений"
152
155
  }
153
156
  }
@@ -149,5 +149,8 @@
149
149
  },
150
150
  "disconnectTab": {
151
151
  "message": "断开此标签页"
152
+ },
153
+ "notClaimable": {
154
+ "message": "无法连接此页面:Chrome 不允许访问服务页面或其他扩展的页面"
152
155
  }
153
156
  }
@@ -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.2",
6
6
  "description": "__MSG_appDesc__",
7
7
  "minimum_chrome_version": "116",
8
8
  "permissions": [
@@ -138,10 +138,17 @@ function render(status) {
138
138
 
139
139
  // Nothing to claim once a tab is bound: the action would be a no-op.
140
140
  const selfAttached = selfTabId != null && targets.some((item) => item.id === selfTabId)
141
+ // Service pages (chrome://, other extensions, the Web Store) cannot be driven;
142
+ // do not offer a button that Chrome will refuse.
143
+ const claimable = selfTabUrl == null || /^(https?|file):/.test(selfTabUrl)
141
144
  claim.hidden = targets.length > 0
142
- claim.disabled = !connected || targets.length > 0
145
+ claim.disabled = !connected || !claimable || targets.length > 0
146
+ claim.title = claimable ? '' : t('notClaimable')
143
147
  claim.textContent = t('connectTab')
144
- $('claim-more').hidden = targets.length === 0 || selfAttached
148
+ const claimMore = $('claim-more')
149
+ claimMore.hidden = targets.length === 0 || selfAttached
150
+ claimMore.disabled = !connected || !claimable
151
+ claimMore.title = claimable ? t('attachAnother') : t('notClaimable')
145
152
 
146
153
  const pick = $('pick')
147
154
  pick.classList.toggle('active', status.picking === true)
@@ -170,12 +177,14 @@ function render(status) {
170
177
  // Which tab this popup is open on: attaching that tab again is a no-op, so the
171
178
  // attach button is only offered when the current tab is not attached yet.
172
179
  let selfTabId = null
180
+ let selfTabUrl = null
173
181
 
174
182
  async function refresh() {
175
183
  try {
176
184
  if (chrome.tabs?.query) {
177
- const [tab] = await chrome.tabs.query({ active: true, currentWindow: true })
185
+ const tab = await currentBrowserTab()
178
186
  selfTabId = tab?.id ?? null
187
+ selfTabUrl = tab?.url ?? null
179
188
  }
180
189
  render(await send({ type: 'status' }))
181
190
  } catch {
@@ -183,6 +192,31 @@ async function refresh() {
183
192
  }
184
193
  }
185
194
 
195
+ /**
196
+ * The browser tab this popup belongs to. `currentWindow` from a popup can point
197
+ * at the popup's own extension page, and Chrome refuses to read other
198
+ * extensions' pages, so prefer a real http(s)/file tab from the focused window.
199
+ */
200
+ async function currentBrowserTab() {
201
+ const scheme = (tab) => (/^(https?|file):/.test(String(tab?.url ?? '')) ? 'web' : 'other')
202
+ const scopes = [
203
+ { active: true, lastFocusedWindow: true, windowType: 'normal' },
204
+ { active: true, windowType: 'normal' },
205
+ { active: true, lastFocusedWindow: true },
206
+ { active: true, currentWindow: true }
207
+ ]
208
+ for (const scope of scopes) {
209
+ try {
210
+ const tabs = await chrome.tabs.query(scope)
211
+ if (!Array.isArray(tabs) || tabs.length === 0) continue
212
+ return tabs.find((tab) => scheme(tab) === 'web') ?? tabs[0]
213
+ } catch {
214
+ // try the next scope
215
+ }
216
+ }
217
+ return null
218
+ }
219
+
186
220
  /* Autosave: a complete pair is stored as soon as it appears, no button. */
187
221
  let saveTimer = null
188
222
 
@@ -262,7 +296,7 @@ $('import-file').addEventListener('change', async (event) => {
262
296
  async function attachCurrentTab() {
263
297
  let tabId = null
264
298
  try {
265
- const [tab] = await chrome.tabs.query({ active: true, currentWindow: true })
299
+ const tab = await currentBrowserTab()
266
300
  tabId = tab?.id ?? null
267
301
  } catch {
268
302
  tabId = null
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,26 +279,39 @@ 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)
285
286
  if (!Number.isFinite(id)) {
286
- const [active] = await chrome.tabs.query({ active: true, lastFocusedWindow: true })
287
+ const candidates = await chrome.tabs
288
+ .query({ active: true, lastFocusedWindow: true, windowType: 'normal' })
289
+ .catch(() => [])
290
+ const active = candidates.find((tab) => /^(https?|file):/.test(tab.url ?? '')) ?? candidates[0]
287
291
  id = active?.id
288
292
  }
289
293
  if (!Number.isFinite(id)) throw new Error('no tab to claim: pass tabId or open a page first')
290
- const tab = await chrome.tabs.get(id)
294
+ let tab = null
295
+ try {
296
+ tab = await chrome.tabs.get(id)
297
+ } catch (error) {
298
+ // Chrome refuses to read pages of other extensions and its own service
299
+ // pages; say that in words a person can act on.
300
+ throw new Error(`эту вкладку нельзя подключить: ${String(error?.message ?? error)}`)
301
+ }
291
302
  if (!/^https?:|^file:/.test(tab.url ?? '')) {
292
- throw new Error(`cannot drive ${tab.url || 'this tab'}: only http(s) and file:// pages are supported`)
303
+ const scheme = String(tab.url ?? '').split(':')[0] || 'неизвестная схема'
304
+ throw new Error(
305
+ `эту страницу нельзя подключить (${scheme}:): Chrome не даёт доступ к служебным страницам и страницам других расширений. Откройте обычный сайт http(s) или file://`
306
+ )
293
307
  }
294
- return attach(id)
308
+ return attach(id, attachedBy)
295
309
  }
296
310
 
297
311
  chrome.tabs.onUpdated.addListener((tabId, info, tab) => {
298
312
  const session = state.sessions.get(tabId)
299
313
  if (!session) return
300
- const next = { id: tab.id, url: tab.url, title: tab.title }
314
+ const next = { id: tab.id, url: tab.url, title: tab.title, attachedBy: session.attachedBy }
301
315
  if (next.title === session.target.title && next.url === session.target.url) return
302
316
  session.target = next
303
317
  void evt('target', { tab: next, tabId })
@@ -1530,7 +1544,7 @@ async function dispatch(op, args) {
1530
1544
  }
1531
1545
  case 'tab.open': {
1532
1546
  const tab = await chrome.tabs.create({ url: String(args.url ?? 'about:blank'), active: args.active !== false })
1533
- await attach(tab.id)
1547
+ await attach(tab.id, 'agent')
1534
1548
  // A fresh tab has no title yet: wait for the load so the caller gets a
1535
1549
  // target it can show, instead of an empty string.
1536
1550
  const deadline = Date.now() + Math.min(Number(args.waitMs ?? 10000), 30000)
@@ -1542,7 +1556,7 @@ async function dispatch(op, args) {
1542
1556
  }
1543
1557
  const session = state.sessions.get(tab.id)
1544
1558
  if (session) {
1545
- session.target = { id: info.id, url: info.url, title: info.title }
1559
+ session.target = { id: info.id, url: info.url, title: info.title, attachedBy: session.attachedBy }
1546
1560
  await evt('target', { tab: session.target, tabId: tab.id })
1547
1561
  }
1548
1562
  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.2",
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",