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 +8 -0
- package/README.ru.md +9 -0
- package/README.zh-CN.md +7 -0
- package/extension/_locales/en/messages.json +3 -0
- package/extension/_locales/ru/messages.json +3 -0
- package/extension/_locales/zh_CN/messages.json +3 -0
- package/extension/manifest.json +1 -1
- package/extension/popup.js +38 -4
- package/extension/sw.js +25 -11
- package/lib/bridge.js +3 -1
- package/lib/index.js +30 -3
- package/package.json +1 -1
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) 中逐条分析 —— 包含代码位置、消除方案与预估成本。
|
package/extension/manifest.json
CHANGED
package/extension/popup.js
CHANGED
|
@@ -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')
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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",
|