dsh-vision-router 1.5.3 → 1.6.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
@@ -12,6 +12,7 @@
12
12
  <a href="https://awesome-dsh-plugin.com"><img src="https://awesome-dsh-plugin.com/badge.svg" alt="awesome · DSH plugin" /></a>
13
13
  <a href="https://github.com/zp-home/dsh-recommend"><img src="https://img.shields.io/endpoint?url=https%3A%2F%2Fraw.githubusercontent.com%2Fzp-home%2Fdsh-recommend%2Fmain%2Fdata%2Fbadges%2Fysr666__dsh-vision-router.certified.json&amp;style=flat-square" alt="dsh-recommend 🏅 精选认证" /></a>
14
14
  <a href="https://github.com/zp-home/dsh-recommend"><img src="https://img.shields.io/endpoint?url=https%3A%2F%2Fraw.githubusercontent.com%2Fzp-home%2Fdsh-recommend%2Fmain%2Fdata%2Fbadges%2Fysr666__dsh-vision-router.json&amp;style=flat-square" alt="dsh score 0.90" /></a>
15
+ <a href="https://dshplugin.app/plugins/dsh-vision-router"><img src="https://img.shields.io/badge/DSHPlugin.app-listed-555?style=flat-square" alt="Listed on DSHPlugin.app" /></a>
15
16
  </p>
16
17
 
17
18
  <p align="center">
@@ -28,9 +29,9 @@
28
29
  <p align="center">💬 <strong>QQ community group: 1105463028</strong></p>
29
30
 
30
31
  > [!WARNING]
31
- > 📌 **Announcement (v1.5.3)**
32
+ > 📌 **Announcement (v1.6.0)**
32
33
  >
33
- > **v1.5.3:** Fixes the rc.6 update-check routes (#160), guards repetition-loop vision output, rewords misleading copy, and logs every backend vision call.
34
+ > **v1.6.0:** Explicit vision-backend authorization — paid visual models merely configured in DSH are no longer callable; the DeepSeek + 自动识图 identity is pinned to official DeepSeek; the one-click updater now infers the owning profile and refuses on ambiguity; full-page screenshots, rc.7 Models directory, install diagnostics, guide-scroll performance and Android/Termux attachments all fixed.
34
35
 
35
36
  <p align="center">
36
37
  <img src="assets/vision-demo.gif" width="640" alt="Demo: paste an image, the agent locates the send button with vision_ground / vision_crop / vision_pixel_diff and answers with coordinates" />
@@ -118,6 +119,9 @@ For normal npm/npx installs, installation is a single command:
118
119
  npx @deepseek-ai/dsh plugin --profile web add dsh-vision-router
119
120
  ```
120
121
 
122
+ > [!WARNING]
123
+ > If this profile already loads community plugins manually through `cordis.patch.yml`, do **not** mix that legacy setup with `dsh plugin add` / `dsh plugin list`: current DSH CLI behavior can also append bundle-patch dependencies to `dsh.profile.bundles`, causing those plugins to register twice. Migrate the existing manual plugin rows to bundle-managed loading first, or keep using the manual installation path. See [deepseek-harness discussion #2889](https://github.com/deepseek-ai/deepseek-harness/discussions/2889).
124
+
121
125
  > [!NOTE]
122
126
  > Third-party `dsh-web-plugin-manager` / `dshpm` **v0.4.2+** is also compatible: its quality gate now correctly allows `@deepseek-ai/schemastery` as a runtime dependency. The official DSH CLI above remains the recommended install path.
123
127
 
@@ -415,6 +419,9 @@ Normal npm/npx install — one command:
415
419
  npx @deepseek-ai/dsh plugin --profile web add dsh-vision-router
416
420
  ```
417
421
 
422
+ > [!NOTE]
423
+ > Profiles that mix legacy manual `cordis.patch.yml` plugin rows with bundle-managed plugins should read the compatibility warning in [Quick start](#1-install-the-plugin) before running DSH plugin commands.
424
+
418
425
  From a DeepSeek Harness source checkout:
419
426
 
420
427
  ```sh
package/README.zh.md CHANGED
@@ -12,6 +12,7 @@
12
12
  <a href="https://awesome-dsh-plugin.com"><img src="https://awesome-dsh-plugin.com/badge.svg" alt="awesome · DSH plugin" /></a>
13
13
  <a href="https://github.com/zp-home/dsh-recommend"><img src="https://img.shields.io/endpoint?url=https%3A%2F%2Fraw.githubusercontent.com%2Fzp-home%2Fdsh-recommend%2Fmain%2Fdata%2Fbadges%2Fysr666__dsh-vision-router.certified.json&amp;style=flat-square" alt="dsh-recommend 🏅 精选认证" /></a>
14
14
  <a href="https://github.com/zp-home/dsh-recommend"><img src="https://img.shields.io/endpoint?url=https%3A%2F%2Fraw.githubusercontent.com%2Fzp-home%2Fdsh-recommend%2Fmain%2Fdata%2Fbadges%2Fysr666__dsh-vision-router.json&amp;style=flat-square" alt="dsh score 0.90" /></a>
15
+ <a href="https://dshplugin.app/plugins/dsh-vision-router"><img src="https://img.shields.io/badge/DSHPlugin.app-listed-555?style=flat-square" alt="已收录于 DSHPlugin.app" /></a>
15
16
  </p>
16
17
 
17
18
  <p align="center">
@@ -28,9 +29,9 @@
28
29
  <p align="center">💬 <strong>QQ 用户交流群:1105463028</strong></p>
29
30
 
30
31
  > [!WARNING]
31
- > 📌 **公告(v1.5.3)**
32
+ > 📌 **公告(v1.6.0)**
32
33
  >
33
- > **v1.5.3:修复 rc.6 更新检查路由(#160)、守卫视觉循环输出、改写易误导文案,并逐后端记录视觉调用日志。**
34
+ > **v1.6.0:视觉后端显式授权——仅在 DSH 里配置过的付费视觉模型不再可被调用,「DeepSeek + 自动识图」身份钉死官方 DeepSeek;一键更新改为从运行中插件真实路径反查所属 profile、不确定即拒绝;全页截图、rc.7 模型目录、安装诊断、设置引导性能与 Android/Termux 附件全部修复。**
34
35
 
35
36
  <p align="center">
36
37
  <img src="assets/vision-demo.gif" width="640" alt="演示:粘贴图片,Agent 用 vision_ground / vision_crop / vision_pixel_diff 定位发送按钮并给出坐标" />
@@ -116,6 +117,9 @@
116
117
  npx @deepseek-ai/dsh plugin --profile web add dsh-vision-router
117
118
  ```
118
119
 
120
+ > [!WARNING]
121
+ > 如果这个 profile 里已经有通过 `cordis.patch.yml` **手动挂载**的社区插件,不要再把这种旧式加载方式与 `dsh plugin add` / `dsh plugin list` 混用:当前 DSH CLI 可能同时把带 bundle patch 的依赖追加到 `dsh.profile.bundles`,导致这些插件被重复注册。请先把原有手动插件迁移到 bundle 管理方式,或继续沿用手动安装路径。详见 [deepseek-harness Discussion #2889](https://github.com/deepseek-ai/deepseek-harness/discussions/2889)。
122
+
119
123
  > [!NOTE]
120
124
  > 第三方 `dsh-web-plugin-manager` / `dshpm` **v0.4.2+** 现已兼容:其质量门已正确放行作为运行时依赖的 `@deepseek-ai/schemastery`。上面的官方 DSH CLI 仍是推荐安装方式。
121
125
 
@@ -410,6 +414,9 @@ ollama pull qwen2.5vl
410
414
  npx @deepseek-ai/dsh plugin --profile web add dsh-vision-router
411
415
  ```
412
416
 
417
+ > [!NOTE]
418
+ > 如果 profile 混用了旧式 `cordis.patch.yml` 手动插件行与 bundle 管理方式,请先阅读[快速开始](#1-安装插件)里的兼容警告,再执行 DSH plugin 命令。
419
+
413
420
  从 DeepSeek Harness 源码仓库运行:
414
421
 
415
422
  ```sh
package/entry.js CHANGED
@@ -10,7 +10,10 @@ import z from '@deepseek-ai/schemastery'
10
10
  import * as core from './index.js'
11
11
  import { installVisionRouterFileLogging } from './lib/file-logger.js'
12
12
  import { contextWithDelegatedReplay } from './lib/replay-delegation.js'
13
+ import { installAdversarialHardening } from './lib/adversarial-hardening.js'
13
14
  import { installLocalVisionStabilizer } from './lib/local-vision-stabilizer.js'
15
+ import { installWrapperDirectoryAlias } from './lib/wrapper-directory.js'
16
+ import { installAndroidAttachmentCompat } from './lib/android-attachment-compat.js'
14
17
 
15
18
  // Schemastery object schemas expose set() as the supported way to replace a
16
19
  // field schema. This mutates the Config object that index.js itself later uses
@@ -24,22 +27,47 @@ export const Config = core.Config
24
27
  // Defense in depth for direct/programmatic callers that invoke apply() without
25
28
  // first running the Cordis Config resolver: only an explicit true enables the
26
29
  // schema-changing progressive mode. The wrapped context changes logger plus a
27
- // private llm view used only by Vision Router: wrapper -> delegate calls can
28
- // restore adapter-owned replay identity without mutating the host LLM service.
30
+ // private llm/tools view used only by Vision Router: wrapper -> delegate calls
31
+ // preserve replay identity, while vision-tool network calls are constrained to
32
+ // the exact backends the user selected in Vision Router.
29
33
  export function apply(ctx, config = {}) {
30
34
  const logging = installVisionRouterFileLogging(ctx)
31
- const runtimeCtx = contextWithDelegatedReplay(logging.ctx)
35
+ const runtimeCtx = contextWithDelegatedReplay(logging.ctx, {
36
+ wrapperRoute:
37
+ typeof config.wrapperRoute === 'string' && config.wrapperRoute !== ''
38
+ ? config.wrapperRoute
39
+ : 'deepseek-vision',
40
+ visionConfig: config,
41
+ })
42
+ // Security/runtime boundary shared by the core and the local-vision shim:
43
+ // keep artifacts inside the session workspace, make HTML screenshots truly
44
+ // offline + sandboxed, protect the screenshot-permission side effect, and
45
+ // make the process-wide fetch cleanup coexist with later plugin patches.
46
+ const { ctx: hardenedCtx, config: hardenedConfig } = installAdversarialHardening(
47
+ runtimeCtx,
48
+ config,
49
+ core,
50
+ )
32
51
  // #141 stabilization boundary: keep the recently merged local-vision
33
52
  // behavior isolated from main's existing provider/router semantics. It
34
53
  // normalizes only the local settings/runtime seams before core.apply sees
35
54
  // the context (desktop screenshot exposure, instant-local budget/one-pass,
36
55
  // local vision-http transport and connection-probe fallback).
37
- const { ctx: stabilizedCtx, bootConfig } = installLocalVisionStabilizer(runtimeCtx, config, core)
56
+ const { ctx: stabilizedCtx, bootConfig } = installLocalVisionStabilizer(
57
+ hardenedCtx,
58
+ hardenedConfig,
59
+ core,
60
+ )
61
+ // #182: Android/Termux cannot open /data/data for the attachment-local
62
+ // durability walk. Keep this workaround private to Vision Router so the
63
+ // host attachment service and every non-Android runtime retain their normal
64
+ // semantics. Once DSH accepts the save, the compatibility path is inert.
65
+ const attachmentCompatCtx = installAndroidAttachmentCompat(stabilizedCtx, logging.logger)
38
66
  // 启动诊断摘要只描述 composition/apply 的基础配置。设置服务可能稍后
39
67
  // 覆盖这些值;每个图片轮还会记录 current() 的实时决策,避免把这个
40
68
  // 启动快照误当成最终设置状态。
41
69
  try {
42
- const c = config && typeof config === 'object' ? config : {}
70
+ const c = hardenedConfig && typeof hardenedConfig === 'object' ? hardenedConfig : {}
43
71
  const local = c.localOllama && typeof c.localOllama === 'object' ? c.localOllama : {}
44
72
  const lms = c.localLmStudio && typeof c.localLmStudio === 'object' ? c.localLmStudio : {}
45
73
  logging.logger.info(
@@ -53,10 +81,17 @@ export function apply(ctx, config = {}) {
53
81
  /* diagnostics must never break apply */
54
82
  }
55
83
  try {
56
- const result = core.apply(stabilizedCtx, {
84
+ const runtimeConfig = {
57
85
  ...bootConfig,
58
- progressiveTools: config.progressiveTools === true,
59
- })
86
+ progressiveTools: hardenedConfig.progressiveTools === true,
87
+ }
88
+ const result = core.apply(attachmentCompatCtx, runtimeConfig)
89
+ // DSH rc.7's Settings -> Models surface is backed by the configurable
90
+ // provider directory, not by the live adapter registry alone. Publish the
91
+ // main DeepSeek + 自动识图 route as a derived alias of official DeepSeek so
92
+ // a reinstall restores the expected model-group row without making an
93
+ // arbitrary textProvider look like DeepSeek.
94
+ installWrapperDirectoryAlias(attachmentCompatCtx, runtimeConfig, logging.logger)
60
95
  if (result && typeof result.then === 'function') {
61
96
  return result.catch((error) => {
62
97
  logging.logger.error(
@@ -0,0 +1,322 @@
1
+ import { existsSync, realpathSync } from 'node:fs'
2
+ import { mkdir, writeFile } from 'node:fs/promises'
3
+ import path from 'node:path'
4
+ import { fileURLToPath, pathToFileURL } from 'node:url'
5
+
6
+ const DEFAULT_ARTIFACTS_DIR = '.dsh-vision-router/artifacts'
7
+ const MAX_VIEWPORT_WIDTH = 4096
8
+ const MAX_VIEWPORT_HEIGHT = 4096
9
+ const MAX_SCREENSHOT_PIXELS = 50_000_000
10
+
11
+ function isNonEmptyString(value) {
12
+ return typeof value === 'string' && value.trim() !== ''
13
+ }
14
+
15
+ export function normalizeArtifactsDir(value) {
16
+ if (!isNonEmptyString(value)) return DEFAULT_ARTIFACTS_DIR
17
+ const raw = value.trim()
18
+ if (path.isAbsolute(raw) || path.win32.isAbsolute(raw)) return DEFAULT_ARTIFACTS_DIR
19
+ const parts = raw.split(/[\\/]+/)
20
+ if (parts.some((part) => part === '..')) return DEFAULT_ARTIFACTS_DIR
21
+ const normalized = path.normalize(raw)
22
+ if (normalized === '' || normalized === '.' || normalized === path.sep) return DEFAULT_ARTIFACTS_DIR
23
+ return normalized
24
+ }
25
+
26
+ function hardenConfig(config = {}) {
27
+ const value = config && typeof config === 'object' ? config : {}
28
+ return { ...value, artifactsDir: normalizeArtifactsDir(value.artifactsDir) }
29
+ }
30
+
31
+ function isPathInside(root, candidate) {
32
+ const relative = path.relative(root, candidate)
33
+ return relative === '' || (!relative.startsWith(`..${path.sep}`) && relative !== '..' && !path.isAbsolute(relative))
34
+ }
35
+
36
+ function realpathOrResolve(value, realpath = realpathSync) {
37
+ try {
38
+ return realpath(value)
39
+ } catch {
40
+ return path.resolve(value)
41
+ }
42
+ }
43
+
44
+ function workspaceOf(exec) {
45
+ const cwd = exec?.agent?.session?.header?.cwd
46
+ return isNonEmptyString(cwd) ? cwd : process.cwd()
47
+ }
48
+
49
+ function artifactDirectory(exec, configured, realpath = realpathSync) {
50
+ const workspace = realpathOrResolve(workspaceOf(exec), realpath)
51
+ const relative = normalizeArtifactsDir(configured)
52
+ const target = path.resolve(workspace, relative)
53
+ if (!isPathInside(workspace, target)) {
54
+ throw new Error('vision-router: artifactsDir must stay inside the session workspace')
55
+ }
56
+ return target
57
+ }
58
+
59
+ export function sameOriginRequest(req) {
60
+ const origin = req?.headers?.origin
61
+ const host = req?.headers?.host
62
+ const fetchSite = req?.headers?.['sec-fetch-site']
63
+ if (fetchSite && fetchSite !== 'same-origin' && fetchSite !== 'none') return false
64
+ if (!origin) return true
65
+ if (!host) return false
66
+ try {
67
+ return new URL(origin).host === host
68
+ } catch {
69
+ return false
70
+ }
71
+ }
72
+
73
+ function safeViewportDimension(value, fallback, max, label) {
74
+ if (value === undefined) return fallback
75
+ if (!Number.isInteger(value) || value <= 0 || value > max) {
76
+ throw new Error(`vision_html_screenshot: ${label} must be an integer between 1 and ${max}`)
77
+ }
78
+ return value
79
+ }
80
+
81
+ export function htmlRequestAllowed(urlValue, allowedRoot) {
82
+ let url
83
+ try {
84
+ url = new URL(urlValue)
85
+ } catch {
86
+ return false
87
+ }
88
+ if (url.protocol === 'data:' || url.protocol === 'blob:' || url.protocol === 'about:') return true
89
+ if (url.protocol !== 'file:') return false
90
+ let filePath
91
+ let root
92
+ try {
93
+ filePath = realpathSync(fileURLToPath(url))
94
+ // The allowed root can sit under a symlinked path (macOS /var ->
95
+ // /private/var, /tmp -> /private/tmp). Resolve it the same way the file
96
+ // path is resolved, otherwise genuinely local files fail the containment
97
+ // check. Fail closed when the root cannot be resolved at all.
98
+ root = realpathSync(allowedRoot)
99
+ } catch {
100
+ return false
101
+ }
102
+ return isPathInside(root, filePath)
103
+ }
104
+
105
+ function wrapRequestInterception(page, allowedRoot) {
106
+ return page.setRequestInterception(true).then(() => {
107
+ page.on('request', (request) => {
108
+ try {
109
+ if (htmlRequestAllowed(request.url(), allowedRoot)) request.continue()
110
+ else request.abort('blockedbyclient')
111
+ } catch {
112
+ try { request.abort('blockedbyclient') } catch { /* best effort */ }
113
+ }
114
+ })
115
+ })
116
+ }
117
+
118
+ // Full-page wake used when the core module predates wakePageForFullCapture:
119
+ // sweep the scrollbar so lazy images and scroll-triggered reveals below the
120
+ // fold exist before the height is measured and the page is captured.
121
+ async function fallbackWakePageForFullCapture(page, viewportHeight) {
122
+ const step = Number.isInteger(viewportHeight) && viewportHeight > 0 ? viewportHeight : 720
123
+ await page.evaluate(() => {
124
+ document.documentElement.style.scrollBehavior = 'auto'
125
+ })
126
+ const total = await page.evaluate(() =>
127
+ Math.max(document.documentElement.scrollHeight, document.body ? document.body.scrollHeight : 0),
128
+ )
129
+ for (let y = 0; y < total; y += step) {
130
+ await page.evaluate((yy) => window.scrollTo(0, yy), y)
131
+ await new Promise((resolve) => setTimeout(resolve, 60))
132
+ }
133
+ await page.evaluate(() => window.scrollTo(0, 0))
134
+ await new Promise((resolve) => setTimeout(resolve, 800))
135
+ }
136
+
137
+ /**
138
+ * Full scrollable page height (CSS px), measured after reveals have woken.
139
+ * The viewport height is part of the max: an empty page has zero scroll
140
+ * height, and must not be misreported as exceeding the pixel safety limit.
141
+ */
142
+ export async function fullPageHeightOf(page) {
143
+ return await page.evaluate(() => Math.max(
144
+ document.documentElement?.scrollHeight ?? 0,
145
+ document.body?.scrollHeight ?? 0,
146
+ window.innerHeight,
147
+ ))
148
+ }
149
+
150
+ export function createSecureHtmlScreenshotExecute(ctx, core, config, deps = {}) {
151
+ const importPuppeteer = deps.importPuppeteer ?? (() => import('puppeteer-core'))
152
+ const fileExists = deps.existsSync ?? existsSync
153
+ const makeDir = deps.mkdir ?? mkdir
154
+ const saveFile = deps.writeFile ?? writeFile
155
+ const realpath = deps.realpathSync ?? realpathSync
156
+ const wakeFullPage =
157
+ typeof core?.wakePageForFullCapture === 'function'
158
+ ? core.wakePageForFullCapture
159
+ : fallbackWakePageForFullCapture
160
+
161
+ return async (args, exec) => {
162
+ const source = String(args?.source ?? '')
163
+ if (!/\.(html?|htm)$/i.test(source)) {
164
+ throw new Error('vision_html_screenshot: source must be a local .html/.htm file')
165
+ }
166
+ const fsService = ctx.get('fs')
167
+ if (fsService === undefined) throw new Error('vision_html_screenshot: the fs service is not available')
168
+ const resolved = await fsService.resolve(source)
169
+ const targetPath = core.toRealPath(fsService, resolved)
170
+ if (!fileExists(targetPath)) throw new Error(`vision_html_screenshot: file not found: ${source}`)
171
+
172
+ const targetReal = realpath(targetPath)
173
+ const workspace = realpathOrResolve(workspaceOf(exec), realpath)
174
+ const sourceRoot = isPathInside(workspace, targetReal) ? workspace : realpath(path.dirname(targetReal))
175
+
176
+ const width = safeViewportDimension(args?.width, 1200, MAX_VIEWPORT_WIDTH, 'width')
177
+ const height = safeViewportDimension(args?.height, 720, MAX_VIEWPORT_HEIGHT, 'height')
178
+ const fullPage = args?.fullPage === true
179
+
180
+ let puppeteer
181
+ try {
182
+ puppeteer = await importPuppeteer()
183
+ } catch {
184
+ throw new Error('vision_html_screenshot: puppeteer-core is not installed')
185
+ }
186
+ const candidates = core.chromiumCandidates(
187
+ typeof process !== 'undefined' && process.env ? process.env : {},
188
+ typeof process !== 'undefined' ? process.platform : '',
189
+ )
190
+ const executablePath = candidates.find((candidate) => fileExists(candidate))
191
+ if (executablePath === undefined) {
192
+ throw new Error(
193
+ 'vision_html_screenshot: no Chrome/Chromium/Edge found; install one or set CHROME_PATH / PUPPETEER_EXECUTABLE_PATH',
194
+ )
195
+ }
196
+
197
+ const launchArgs = ['--disable-gpu', '--hide-scrollbars', '--incognito']
198
+ if (fullPage) launchArgs.push('--blink-settings=imagesLazyLoadingEnabled=false')
199
+ const launcher = puppeteer.default ?? puppeteer
200
+ let browser
201
+ try {
202
+ browser = await launcher.launch({ executablePath, headless: true, args: launchArgs })
203
+ } catch (error) {
204
+ const detail = error && error.message ? error.message : String(error)
205
+ throw new Error(`vision_html_screenshot: secure Chrome sandbox launch failed: ${detail}`)
206
+ }
207
+
208
+ try {
209
+ const page = await browser.newPage()
210
+ await page.setViewport({ width, height })
211
+ // Puppeteer 25 exposes this API. Treat its absence as a hard failure:
212
+ // the tool promises an offline render, so silently degrading would
213
+ // recreate the security gap this boundary exists to close.
214
+ if (typeof page.setOfflineMode !== 'function') {
215
+ throw new Error('vision_html_screenshot: browser offline mode is unavailable')
216
+ }
217
+ await page.setOfflineMode(true)
218
+ await wrapRequestInterception(page, sourceRoot)
219
+ await page.goto(pathToFileURL(targetReal).href, { waitUntil: 'networkidle0', timeout: 30000 })
220
+
221
+ let pageHeight
222
+ if (fullPage) {
223
+ // Recreate the pre-hardening behavior: wake lazy/scroll-triggered
224
+ // content before measuring, so below-the-fold reveals are not silently
225
+ // missing from the capture.
226
+ await wakeFullPage(page, height)
227
+ pageHeight = await fullPageHeightOf(page)
228
+ if (!Number.isFinite(pageHeight) || pageHeight <= 0 || width * pageHeight > MAX_SCREENSHOT_PIXELS) {
229
+ throw new Error(
230
+ `vision_html_screenshot: full-page capture exceeds the ${MAX_SCREENSHOT_PIXELS} pixel safety limit`,
231
+ )
232
+ }
233
+ } else if (width * height > MAX_SCREENSHOT_PIXELS) {
234
+ throw new Error(`vision_html_screenshot: viewport exceeds the ${MAX_SCREENSHOT_PIXELS} pixel safety limit`)
235
+ }
236
+
237
+ const png = fullPage
238
+ ? await page.screenshot({ type: 'png', fullPage: true })
239
+ : await page.screenshot({ type: 'png' })
240
+ const stem = fullPage ? `shot-${width}x${height}-fullpage` : `shot-${width}x${height}`
241
+ const dir = artifactDirectory(exec, config.artifactsDir, realpath)
242
+ await makeDir(dir, { recursive: true })
243
+ const target = path.join(dir, `${core.artifactStemOf(source, stem)}.png`)
244
+ if (!isPathInside(dir, target)) throw new Error('vision_html_screenshot: unsafe artifact target')
245
+ await saveFile(target, png)
246
+ const result = { path: target, width, height, bytes: png.length }
247
+ if (fullPage) result.pageHeight = pageHeight
248
+ return JSON.stringify(result)
249
+ } finally {
250
+ await browser.close()
251
+ }
252
+ }
253
+ }
254
+
255
+ function wrapTools(tools, ctx, core, config) {
256
+ if (!tools || (typeof tools !== 'object' && typeof tools !== 'function')) return tools
257
+ return new Proxy(tools, {
258
+ get(target, property) {
259
+ if (property !== 'register') {
260
+ const value = Reflect.get(target, property, target)
261
+ return typeof value === 'function' ? value.bind(target) : value
262
+ }
263
+ const register = Reflect.get(target, property, target)
264
+ return (def) => {
265
+ if (def?.name === 'vision_html_screenshot' && typeof def.execute === 'function') {
266
+ return register.call(target, {
267
+ ...def,
268
+ execute: createSecureHtmlScreenshotExecute(ctx, core, config),
269
+ })
270
+ }
271
+ return register.call(target, def)
272
+ }
273
+ },
274
+ })
275
+ }
276
+
277
+ export function installAdversarialHardening(ctx, config = {}, core) {
278
+ if (!ctx || typeof ctx !== 'object') return { ctx, config: hardenConfig(config) }
279
+ const hardenedConfig = hardenConfig(config)
280
+ const wrapped = new Proxy(ctx, {
281
+ get(target, property) {
282
+ if (property === 'tools') return wrapTools(target.tools, wrapped, core, hardenedConfig)
283
+ if (property === 'effect') {
284
+ const effect = Reflect.get(target, property, target)
285
+ if (typeof effect !== 'function') return effect
286
+ return (factory, label) => {
287
+ if (label !== 'vision-router: proxy fetch') return effect.call(target, factory, label)
288
+ return effect.call(target, () => {
289
+ const originalFetch = globalThis.fetch
290
+ const disposer = factory()
291
+ const installedFetch = globalThis.fetch
292
+ let active = true
293
+ // Later plugins may capture this function. Making it an inert
294
+ // delegator on unload removes Vision Router from the chain even if
295
+ // another plugin still sits above it, and prevents it resurfacing
296
+ // when that later plugin restores the fetch value it captured.
297
+ const guardedFetch = (...args) => active
298
+ ? installedFetch(...args)
299
+ : originalFetch(...args)
300
+ globalThis.fetch = guardedFetch
301
+ return () => {
302
+ active = false
303
+ if (globalThis.fetch === guardedFetch) {
304
+ globalThis.fetch = originalFetch
305
+ // The core disposer is safe only while our guard is still the
306
+ // active process-level fetch. Never let its unconditional
307
+ // assignment clobber a later plugin's patch.
308
+ if (typeof disposer === 'function') disposer()
309
+ }
310
+ }
311
+ }, label)
312
+ }
313
+ }
314
+ // Deliberately do NOT wrap ctx.inject or injected child contexts. DSH
315
+ // rc.6 ties route/effect ownership to the exact child-context identity;
316
+ // replacing it with a Proxy previously made host routes disappear (#160).
317
+ const value = Reflect.get(target, property, target)
318
+ return typeof value === 'function' ? value.bind(target) : value
319
+ },
320
+ })
321
+ return { ctx: wrapped, config: hardenedConfig }
322
+ }
@@ -0,0 +1,162 @@
1
+ import { createHash } from 'node:crypto'
2
+
3
+ const wrappedContexts = new WeakMap()
4
+ const MAX_TRANSIENT_ATTACHMENTS = 64
5
+
6
+ function nonEmptyString(value) {
7
+ return typeof value === 'string' && value.length > 0
8
+ }
9
+
10
+ export function isAndroidTermuxRuntime(options = {}) {
11
+ const platform = options.platform ?? process.platform
12
+ const env = options.env ?? process.env
13
+ if (platform === 'android') return true
14
+ if (nonEmptyString(env?.TERMUX_VERSION)) return true
15
+ const prefix = nonEmptyString(env?.PREFIX) ? env.PREFIX : ''
16
+ return prefix.includes('/com.termux/') || prefix.includes('\\com.termux\\')
17
+ }
18
+
19
+ export function isPermissionBoundaryError(error) {
20
+ let current = error
21
+ const seen = new Set()
22
+ for (let depth = 0; current && depth < 8; depth += 1) {
23
+ if ((typeof current === 'object' || typeof current === 'function') && seen.has(current)) break
24
+ if (typeof current === 'object' || typeof current === 'function') seen.add(current)
25
+ const code = current && typeof current === 'object' ? current.code : undefined
26
+ if (code === 'EACCES' || code === 'EPERM') return true
27
+ current = current && typeof current === 'object' ? current.cause : undefined
28
+ }
29
+ return false
30
+ }
31
+
32
+ function displayName(value) {
33
+ if (!nonEmptyString(value)) return undefined
34
+ const leaf = value.slice(Math.max(value.lastIndexOf('/'), value.lastIndexOf('\\')) + 1)
35
+ const clean = leaf.replace(/[\u0000-\u001f\u007f]/g, '').trim().slice(0, 255)
36
+ return clean === '' ? undefined : clean
37
+ }
38
+
39
+ async function probeImage(data, injectedProbe) {
40
+ if (typeof injectedProbe === 'function') return injectedProbe(data)
41
+ const mod = await import('sharp')
42
+ const sharp = mod.default ?? mod
43
+ const metadata = await sharp(data, { failOn: 'none' }).metadata()
44
+ const width = Number(metadata.width ?? 0)
45
+ const height = Number(metadata.height ?? 0)
46
+ if (!Number.isInteger(width) || width <= 0 || !Number.isInteger(height) || height <= 0) {
47
+ throw new Error('vision-router: Android attachment fallback could not read image dimensions')
48
+ }
49
+ return { width, height }
50
+ }
51
+
52
+ async function transientStoredImage(input, injectedProbe) {
53
+ const data = input?.data
54
+ if (!(data instanceof Uint8Array) || data.byteLength === 0) {
55
+ throw new Error('vision-router: Android attachment fallback received empty image bytes')
56
+ }
57
+ const mediaType = input?.mediaType
58
+ if (!nonEmptyString(mediaType)) {
59
+ throw new Error('vision-router: Android attachment fallback received no media type')
60
+ }
61
+ const { width, height } = await probeImage(data, injectedProbe)
62
+ const sha256 = createHash('sha256').update(data).digest('hex')
63
+ const name = displayName(input?.name)
64
+ const ref = {
65
+ attachmentId: `sha256:${sha256}`,
66
+ mediaType,
67
+ bytes: data.byteLength,
68
+ width,
69
+ height,
70
+ ...(name !== undefined ? { name } : {}),
71
+ }
72
+ return { ref, data }
73
+ }
74
+
75
+ function rememberTransient(map, stored) {
76
+ const id = String(stored.ref.attachmentId)
77
+ if (map.has(id)) map.delete(id)
78
+ map.set(id, stored)
79
+ while (map.size > MAX_TRANSIENT_ATTACHMENTS) {
80
+ const oldest = map.keys().next().value
81
+ map.delete(oldest)
82
+ }
83
+ }
84
+
85
+ /**
86
+ * Android/Termux compatibility shim for deepseek-harness attachment-local.
87
+ *
88
+ * Current DSH attachment-local proves directory durability by fsyncing every
89
+ * ancestor from DSH_HOME to the filesystem root. Android SELinux lets Termux
90
+ * access its own /data/data/com.termux subtree but intentionally denies opening
91
+ * /data/data itself, so saveImage() is wrapped as ATTACHMENT_WRITE_FAILED even
92
+ * though the image bytes are otherwise readable.
93
+ *
94
+ * Keep the host service authoritative. Only when an Android runtime reaches an
95
+ * EACCES/EPERM boundary do Vision Router's own calls fall back to a bounded,
96
+ * process-local content-addressed attachment. The paired readImage() wrapper
97
+ * makes the direct vision-http path and plugin-side bridge code consume those
98
+ * bytes normally. Once upstream accepts the save, this path is never used.
99
+ */
100
+ export function installAndroidAttachmentCompat(ctx, logger, options = {}) {
101
+ if (!ctx || typeof ctx !== 'object' || !isAndroidTermuxRuntime(options.runtime)) return ctx
102
+ const cached = wrappedContexts.get(ctx)
103
+ if (cached) return cached
104
+
105
+ let attachments
106
+ try {
107
+ attachments = typeof ctx.get === 'function' ? ctx.get('attachments') : undefined
108
+ } catch {
109
+ return ctx
110
+ }
111
+ if (!attachments || typeof attachments.saveImage !== 'function' || typeof attachments.readImage !== 'function') {
112
+ return ctx
113
+ }
114
+
115
+ const transient = new Map()
116
+ const wrappedAttachments = new Proxy(attachments, {
117
+ get(target, property) {
118
+ if (property === 'saveImage') {
119
+ return async (input) => {
120
+ try {
121
+ return await target.saveImage(input)
122
+ } catch (error) {
123
+ if (!isPermissionBoundaryError(error)) throw error
124
+ const stored = await transientStoredImage(input, options.probeImage)
125
+ rememberTransient(transient, stored)
126
+ try {
127
+ logger?.warn?.(
128
+ 'vision-router: Android/Termux attachment save hit an inaccessible ancestor; using a process-local image ref until DSH attachment-local is fixed upstream',
129
+ )
130
+ } catch {
131
+ /* diagnostics must never block the fallback */
132
+ }
133
+ return stored.ref
134
+ }
135
+ }
136
+ }
137
+ if (property === 'readImage') {
138
+ return async (ref, signal) => {
139
+ signal?.throwIfAborted?.()
140
+ const id = String(ref?.attachmentId ?? '')
141
+ const stored = transient.get(id)
142
+ if (stored !== undefined) return stored
143
+ return target.readImage(ref, signal)
144
+ }
145
+ }
146
+ const value = Reflect.get(target, property, target)
147
+ return typeof value === 'function' ? value.bind(target) : value
148
+ },
149
+ })
150
+
151
+ const wrapped = new Proxy(ctx, {
152
+ get(target, property) {
153
+ if (property === 'get') {
154
+ return (name) => (name === 'attachments' ? wrappedAttachments : target.get(name))
155
+ }
156
+ const value = Reflect.get(target, property, target)
157
+ return typeof value === 'function' ? value.bind(target) : value
158
+ },
159
+ })
160
+ wrappedContexts.set(ctx, wrapped)
161
+ return wrapped
162
+ }