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 +9 -2
- package/README.zh.md +9 -2
- package/entry.js +43 -8
- package/lib/adversarial-hardening.js +322 -0
- package/lib/android-attachment-compat.js +162 -0
- package/lib/client.js +42 -3
- package/lib/doctor-cli.js +14 -1
- package/lib/doctor.js +9 -3
- package/lib/local-vision-stabilizer.js +7 -1
- package/lib/profile-pnpm-diagnostics.js +234 -0
- package/lib/replay-delegation.js +443 -8
- package/lib/self-update.js +211 -16
- package/lib/wrapper-directory.js +212 -0
- package/package.json +13 -2
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&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&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.
|
|
32
|
+
> 📌 **Announcement (v1.6.0)**
|
|
32
33
|
>
|
|
33
|
-
> **v1.
|
|
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&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&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.
|
|
32
|
+
> 📌 **公告(v1.6.0)**
|
|
32
33
|
>
|
|
33
|
-
> **v1.
|
|
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
|
|
28
|
-
//
|
|
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(
|
|
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 =
|
|
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
|
|
84
|
+
const runtimeConfig = {
|
|
57
85
|
...bootConfig,
|
|
58
|
-
progressiveTools:
|
|
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
|
+
}
|