@liustack/modlens 3.26.1 → 3.26.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/CHANGELOG.md CHANGED
@@ -1,5 +1,9 @@
1
1
  # Changelog
2
2
 
3
+ ## 3.26.2 - 2026-09-19
4
+
5
+ - **dsh: `/modlens/paste` answers same-origin loopback only ([#107](https://github.com/liustack/modlens/issues/107)).** The paste route wrote to disk and disclosed the takeover verdict without the fence `/modlens/config` already had, so a page rebound onto loopback, or a cross-site page on the same machine, could plant a file in the paste store and, by repeating it, push the store over its ceiling and sweep away pastes a live session still had to read. Both branches now run the same `isTrustedRequest` check as the config route: a non-loopback Host, `Sec-Fetch-Site: cross-site`, or an Origin that does not match the Host is refused with 403 and nothing is written. The client treats that 403 like a 404 and stands down for the page, so a refused paste goes native at once instead of being taken and lost for the rest of the verdict window; the settings card does the same and does not mount. The refusal line is one shared constant, and both routes' tests assert it. A dsh opened over a LAN address loses paste-to-path and the settings card, which is the config route's existing behavior. Thanks to @nanami-0713 for the report.
6
+
3
7
  ## 3.26.1 - 2026-09-08
4
8
 
5
9
  - **dsh paste-to-path writes into the Lexical composer ([#100](https://github.com/liustack/modlens/issues/100)).** dsh 0.1.2-rc.1 replaced the composer textarea with a Lexical contenteditable div. The paste listener still uploaded the image, then `insertText` returned immediately because it only accepted `TEXTAREA` and `INPUT`, so the path never landed and the console stayed quiet. Paste-to-path now resolves a writable target (textarea, input, or `[data-composer-input][contenteditable=true]`) before taking the event, inserts with `execCommand`, and logs the path if that insert fails. Thanks to @xp1205700819-sudo.
package/README.md CHANGED
@@ -33,7 +33,7 @@ Issues are welcome any time: [open one](https://github.com/liustack/modlens/issu
33
33
 
34
34
  ## Highlights
35
35
 
36
- **🥇 The most capable vision plugin for DeepSeek Harness (dsh):** install it instantly with one command: `npx -y @deepseek-ai/dsh plugin --profile web add @liustack/modlens@3.26.1`. See the [setup guide](docs/harness-setup.md) for installation and update details. If the command line is not your thing but you still want to try DSH, check out <a href="https://github.com/liustack/aimanager"><b>AIManager</b></a>, the lightest desktop wrapper for DeepSeek Harness. It gets you started with zero code or configuration and installs every dependency for you with one click.
36
+ **🥇 The most capable vision plugin for DeepSeek Harness (dsh):** install it instantly with one command: `npx -y @deepseek-ai/dsh plugin --profile web add @liustack/modlens@3.26.2`. See the [setup guide](docs/harness-setup.md) for installation and update details. If the command line is not your thing but you still want to try DSH, check out <a href="https://github.com/liustack/aimanager"><b>AIManager</b></a>, the lightest desktop wrapper for DeepSeek Harness. It gets you started with zero code or configuration and installs every dependency for you with one click.
37
37
 
38
38
  Pasting an image works two ways. **① Just paste.** On a text-only model the pasted image lands as a private temp file and its path enters the composer (the same interaction OpenCode and Pi ship), then the `modlens_read_image` tool takes it from there. **② Pick a `(modlens vision)` entry** in the model selector (it remembers your choice, so once is enough), then paste: the thumbnail stays visible in your message, closer to the Codex app feel, and the image is converted to structured evidence at request time, answered by the same underlying route. The plugin auto-discovers every provider route carrying eligible text-only DeepSeek, GLM, or MiMo Pro models and adds a wrapped entry per route. A stock install gets **`DeepSeek-V4-Flash (modlens vision)`** and **`DeepSeek-V4-Pro (modlens vision)`**, while extra routes like opencode-go or zai get their own. Native vision models in those families, including GLM-5.3-Flash, are excluded automatically. Which paste route applies is the host's per-model call: only a model its metadata positively confirms text-only is taken over, anything unconfirmed is left alone, so vision models keep their native paste ([details](docs/harness-setup.md)).
39
39
 
package/README.zh-CN.md CHANGED
@@ -33,7 +33,7 @@ DeepSeek 的主力对话模型和 GLM-5.3 本体仍是纯文本,无法读图
33
33
 
34
34
  ## 亮点
35
35
 
36
- **🥇 全网最强的 DeepSeek Harness(dsh)外挂视觉识别插件:**一条命令即刻安装 `npx -y @deepseek-ai/dsh plugin --profile web add @liustack/modlens@3.26.1`。更多安装与更新细节参考 [配置手册](docs/harness-setup.zh-CN.md) 。如果用不惯命令行,也想想玩玩 DSH,推荐食用全网最轻量级的 DeepSeek Harness 桌面版封装 <a href="https://github.com/liustack/aimanager"><b> AIManager</b></a>,零代码零配置起手,一键帮你安装所有依赖环境。
36
+ **🥇 全网最强的 DeepSeek Harness(dsh)外挂视觉识别插件:**一条命令即刻安装 `npx -y @deepseek-ai/dsh plugin --profile web add @liustack/modlens@3.26.2`。更多安装与更新细节参考 [配置手册](docs/harness-setup.zh-CN.md) 。如果用不惯命令行,也想想玩玩 DSH,推荐食用全网最轻量级的 DeepSeek Harness 桌面版封装 <a href="https://github.com/liustack/aimanager"><b> AIManager</b></a>,零代码零配置起手,一键帮你安装所有依赖环境。
37
37
 
38
38
  DeepSeek Harness 粘贴识图有两种玩法。
39
39
 
@@ -80,7 +80,7 @@ agy # 浏览器完成
80
80
  **DeepSeek Harness(dsh)用户不走 skill 流程**,本包就是原生 dsh 插件:
81
81
 
82
82
  ```sh
83
- npx -y @deepseek-ai/dsh plugin --profile web add @liustack/modlens@3.26.1
83
+ npx -y @deepseek-ai/dsh plugin --profile web add @liustack/modlens@3.26.2
84
84
  ```
85
85
 
86
86
  装完即有 `modlens_read_image` 工具,选「(modlens vision)」模型变体即可直接粘贴识图。引擎配置同样在 `~/.modlens`,详见[宿主接入](docs/harness-setup.zh-CN.md)。
package/dist/main.js CHANGED
@@ -5562,7 +5562,7 @@ function parsePositiveInt(raw, flag) {
5562
5562
  }
5563
5563
  return Number.parseInt(raw, 10);
5564
5564
  }
5565
- program.name("modlens").description("Plug-in vision for text-only LLMs: image in, structured JSON evidence out").version("3.26.1");
5565
+ program.name("modlens").description("Plug-in vision for text-only LLMs: image in, structured JSON evidence out").version("3.26.2");
5566
5566
  program.command("analyze", { isDefault: true }).description("Analyze an image into structured JSON evidence (default command)").requiredOption("-i, --input <path|url>", "Input image path or https URL").option("-o, --output <path>", "Write result JSON to a file").option("-m, --model <name>", "Provider model name").option("-p, --provider <name>", `Vision provider (${listProviders().join(", ")})`).option("--prompt <text>", "Extra focus for this image").option("--timeout <ms>", "Provider timeout in milliseconds", "180000").option("--provider-bin <path>", "Provider binary path (default: agy)").option("--workdir <path>", "Working directory for the provider").option(
5567
5567
  "--extra-body <json>",
5568
5568
  `JSON merged into the API request body, e.g. '{"thinking":{"type":"disabled"}}'`
@@ -5673,7 +5673,7 @@ program.command("doctor").description(
5673
5673
  configPath: CONFIG_PATH,
5674
5674
  // Lets doctor name an installed skill copy that is older than
5675
5675
  // the CLI reporting on it (issue #33).
5676
- version: "3.26.1"
5676
+ version: "3.26.2"
5677
5677
  });
5678
5678
  const output = options.json ? JSON.stringify(report, null, 2) : renderDoctorReport(report);
5679
5679
  process.stdout.write(`${output}
@@ -55,7 +55,7 @@ OpenCode with DeepSeek: `opencode auth login`, pick DeepSeek and paste the key (
55
55
  dsh is different from the other harnesses: modlens plugs in as a native tool, not a prompt-triggered skill. The package itself is a dsh bundle, so one command installs it into a profile:
56
56
 
57
57
  ```sh
58
- npx -y @deepseek-ai/dsh plugin --profile web add @liustack/modlens@3.26.1
58
+ npx -y @deepseek-ai/dsh plugin --profile web add @liustack/modlens@3.26.2
59
59
  ```
60
60
 
61
61
  This registers a `modlens_read_image` tool whose schema reaches the model on every request (no trigger heuristics), runs the modlens CLI shipped inside the same package, and returns the structured evidence as the tool's canonical JSON output. Engines, reuse grants, and guard rules stay in `~/.modlens/config.json`, shared with every other harness. dsh is in developer preview and its plugin surface may change; the plugin keeps its touch small (raw tool registration, the llm adapter surface for the vision variants, the attachment reader, and one agent pre-step hook) and degrades loudly if any of them moves.
@@ -137,7 +137,7 @@ modlens ships often, and both install shapes freeze at whatever version they
137
137
  got. On dsh, re-run the install with the version named:
138
138
 
139
139
  ```sh
140
- npx -y @deepseek-ai/dsh plugin --profile <name> add @liustack/modlens@3.26.1
140
+ npx -y @deepseek-ai/dsh plugin --profile <name> add @liustack/modlens@3.26.2
141
141
  ```
142
142
 
143
143
  `npm view @liustack/modlens version` prints the current one, and this page is
@@ -55,7 +55,7 @@ OpenCode 接 DeepSeek:执行 `opencode auth login`,选择 DeepSeek 并粘贴
55
55
  dsh 与其他 harness 不同:modlens 以原生工具的形式接入,而不是靠提示词触发的 skill。本包自身就是一个 dsh bundle,一条命令即可装进某个 profile:
56
56
 
57
57
  ```sh
58
- npx -y @deepseek-ai/dsh plugin --profile web add @liustack/modlens@3.26.1
58
+ npx -y @deepseek-ai/dsh plugin --profile web add @liustack/modlens@3.26.2
59
59
  ```
60
60
 
61
61
  这会注册一个 `modlens_read_image` 工具,它的 schema 随每次请求抵达模型(不靠触发启发式),运行同一个包里自带的 modlens CLI,并把结构化证据作为工具的标准 JSON 输出返回。引擎、复用授权和 guard 规则仍在 `~/.modlens/config.json` 里,与其他所有 harness 共享。dsh 还在开发者预览阶段,插件接口可能变化。这个插件刻意保持很小的接触面(原生工具注册、视觉变体所用的 llm 适配层、附件读取器,以及一个 agent 执行前钩子),其中任何一处变动,它都会大声报错而不是无声退化。
@@ -116,7 +116,7 @@ dsh 的网页用户面前没有终端,所以引擎设置有一张卡片,在*
116
116
  modlens 发布很频繁,而两种安装形态都会冻结在装进来的那个版本上。dsh 上重跑一遍安装即可,版本号要点名:
117
117
 
118
118
  ```sh
119
- npx -y @deepseek-ai/dsh plugin --profile <name> add @liustack/modlens@3.26.1
119
+ npx -y @deepseek-ai/dsh plugin --profile <name> add @liustack/modlens@3.26.2
120
120
  ```
121
121
 
122
122
  `npm view @liustack/modlens version` 可以查到当前版本号,本页的版本号则由发布流程自动写入。
@@ -163,7 +163,7 @@ simply lands on an older one. Name the exact version instead, which pnpm treats
163
163
  as a deliberate request rather than a resolution:
164
164
 
165
165
  ```sh
166
- npx -y @deepseek-ai/dsh plugin --profile <name> add @liustack/modlens@3.26.1
166
+ npx -y @deepseek-ai/dsh plugin --profile <name> add @liustack/modlens@3.26.2
167
167
  ```
168
168
 
169
169
  `npm view @liustack/modlens version` prints the current one. pnpm 11 installs a named
@@ -178,7 +178,7 @@ file:
178
178
 
179
179
  ```yaml
180
180
  minimumReleaseAgeExclude:
181
- - '@liustack/modlens@3.26.1'
181
+ - '@liustack/modlens@3.26.2'
182
182
  ```
183
183
 
184
184
  Or lift the gate for a single command, which lifts it for everything that
@@ -144,7 +144,7 @@ dsh profile 装到的是旧版 modlens。`dsh.bundle` 声明从 3.9.0 起才存
144
144
  `@latest` 绕不开这一层,本页早先的说法是错的。冷静期先把候选版本过滤掉,dist-tag 才在剩下的里面解析,于是它直接落到了更旧的那个上。改成写死精确版本号,pnpm 会把它当作一次明确的指定,而不是一次解析:
145
145
 
146
146
  ```sh
147
- npx -y @deepseek-ai/dsh plugin --profile <name> add @liustack/modlens@3.26.1
147
+ npx -y @deepseek-ai/dsh plugin --profile <name> add @liustack/modlens@3.26.2
148
148
  ```
149
149
 
150
150
  `npm view @liustack/modlens version` 可以查到当前版本号。pnpm 11 会装上被点名的版本,11.1.3 起还会把它作为一条已批准的例外写进该 profile 的 `pnpm-workspace.yaml`,其余所有包和 modlens 以后的版本仍然留在窗口后面。
@@ -153,7 +153,7 @@ npx -y @deepseek-ai/dsh plugin --profile <name> add @liustack/modlens@3.26.1
153
153
 
154
154
  ```yaml
155
155
  minimumReleaseAgeExclude:
156
- - '@liustack/modlens@3.26.1'
156
+ - '@liustack/modlens@3.26.2'
157
157
  ```
158
158
 
159
159
  或者只为这一条命令解除冷静期,注意它解除的是这条命令解析到的所有包,不只 modlens:
package/dsh/client.js CHANGED
@@ -122,7 +122,9 @@ window.__ModuleLoader__.load({
122
122
  // vision model (keeps its thumbnail) and a text-only one (keeps only its
123
123
  // old error message, once). A 404 means the route is off (pasteToPath:
124
124
  // false, or no host half), so the client stands down entirely instead of
125
- // swallowing pastes into a dead endpoint.
125
+ // swallowing pastes into a dead endpoint. A 403 means the route refuses
126
+ // this page's origin (non-loopback Host, or cross-site), which is just as
127
+ // permanent for this page, so it stands down the same way.
126
128
  var routeAvailable = true
127
129
  var verdicts = {}
128
130
  // A verdict older than this is UNKNOWN again, even while a refresh is in
@@ -144,7 +146,7 @@ window.__ModuleLoader__.load({
144
146
  verdicts[label] = entry
145
147
  fetch(`/modlens/paste?model=${encodeURIComponent(label)}`)
146
148
  .then((res) => {
147
- if (res.status === 404) {
149
+ if (res.status === 404 || res.status === 403) {
148
150
  routeAvailable = false
149
151
  entry.pending = false
150
152
  return null
@@ -199,10 +201,11 @@ window.__ModuleLoader__.load({
199
201
  })
200
202
  .catch((error) => {
201
203
  // A 404 here means the route vanished AFTER a verdict confirmed it
202
- // (plugin disposed mid-session): that race can cost this one paste
203
- // — preventDefault already ran — but never another. Stand down and
204
+ // (plugin disposed mid-session), and a 403 that it refuses this
205
+ // page's origin: either way that race can cost this one paste —
206
+ // preventDefault already ran — but never another. Stand down and
204
207
  // forget every verdict, so the next paste goes native immediately.
205
- if (error && error.status === 404) {
208
+ if (error && (error.status === 404 || error.status === 403)) {
206
209
  routeAvailable = false
207
210
  verdicts = {}
208
211
  }
@@ -1091,10 +1094,12 @@ window.__ModuleLoader__.load({
1091
1094
  // off (settingsCard: false, or no web profile) a card would only
1092
1095
  // render an error, which is not what turning a feature off means.
1093
1096
  // Any response at all proves the route exists; only a 404 or a
1094
- // network failure reads as absent.
1097
+ // network failure reads as absent. A 403 is the route's same-origin
1098
+ // loopback fence turning this page away, which is just as permanent,
1099
+ // so the card stays away there too.
1095
1100
  fetch('/modlens/config')
1096
1101
  .then((response) => {
1097
- if (response.status === 404) return
1102
+ if (response.status === 404 || response.status === 403) return
1098
1103
  try {
1099
1104
  mountCard(scope, localeRef)
1100
1105
  } catch (error) {
package/dsh/index.js CHANGED
@@ -488,6 +488,14 @@ function registerPasteRoute(ctx, host, ownProviders, config = {}) {
488
488
  kind: 'exact',
489
489
  path: '/modlens/paste',
490
490
  handler: async (req, res) => {
491
+ // Same fence as /modlens/config, for the same reason: a page on this
492
+ // machine, or one rebound onto loopback, must not be able to plant a
493
+ // file here or read back what the takeover verdict discloses.
494
+ if (!isTrustedRequest(req)) {
495
+ res.writeHead(403, { 'content-type': 'application/json' })
496
+ res.end(JSON.stringify({ error: ROUTE_REFUSAL }))
497
+ return
498
+ }
491
499
  if (req.method === 'GET') {
492
500
  try {
493
501
  const label = new URL(req.url, 'http://localhost').searchParams.get('model') ?? ''
@@ -1900,6 +1908,11 @@ function isLoopbackHost(hostname) {
1900
1908
  )
1901
1909
  }
1902
1910
 
1911
+ // Both host routes answer an untrusted request with this exact line. It is
1912
+ // one constant so the two fences cannot drift apart, and so the tests can
1913
+ // assert the wire contract instead of a copy of it.
1914
+ const ROUTE_REFUSAL = 'request refused: this route answers same-origin loopback only'
1915
+
1903
1916
  /**
1904
1917
  * The same fence dsh puts in front of its own /api, for the same two
1905
1918
  * confused-deputy paths. Host is the header DNS rebinding cannot forge, so it
@@ -2132,7 +2145,7 @@ function registerConfigRoute(ctx) {
2132
2145
  res.end(JSON.stringify(body))
2133
2146
  }
2134
2147
  if (!isTrustedRequest(req)) {
2135
- send(403, { error: 'request refused: this route answers same-origin loopback only' })
2148
+ send(403, { error: ROUTE_REFUSAL })
2136
2149
  return
2137
2150
  }
2138
2151
  if (req.method === 'GET') {
@@ -2186,7 +2199,7 @@ function registerConfigRoute(ctx) {
2186
2199
  // reachable from the test suite the way client.js exposes `__card`. They read
2187
2200
  // and write a real file and a real environment, so they are tested against
2188
2201
  // both rather than through the HTTP route.
2189
- export const __config = { engineSummary, applyEngineSettings, modlensConfigPath }
2202
+ export const __config = { engineSummary, applyEngineSettings, modlensConfigPath, refusal: ROUTE_REFUSAL }
2190
2203
 
2191
2204
  // The paste sweeper, reachable from the test suite the way __config is.
2192
2205
  export const __paste = {
@@ -2195,5 +2208,6 @@ export const __paste = {
2195
2208
  openPasteRoot,
2196
2209
  pasteRoot,
2197
2210
  ttlMs: PASTE_TTL_MS,
2211
+ refusal: ROUTE_REFUSAL,
2198
2212
  maxBytes: PASTE_STORE_MAX_BYTES,
2199
2213
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@liustack/modlens",
3
- "version": "3.26.1",
3
+ "version": "3.26.2",
4
4
  "description": "Plug-in vision for text-only LLMs, powered by the free Antigravity CLI",
5
5
  "type": "module",
6
6
  "bin": {
@@ -20,11 +20,11 @@ powershell -ExecutionPolicy Bypass -File <skill-dir>\scripts\run.ps1 <args>
20
20
 
21
21
  It resolves a working runtime (PATH `modlens`, then `npx`, then `bunx`) and forwards your arguments unchanged. Exit 78 means no runtime: relay the `nextSteps` from its stderr JSON instead of retrying.
22
22
 
23
- If your harness forbids running scripts, reason through the same order by hand and run the first line that works (the pinned version is 3.26.1):
23
+ If your harness forbids running scripts, reason through the same order by hand and run the first line that works (the pinned version is 3.26.2):
24
24
 
25
- 1. A `modlens` on `PATH` whose major version is 3 and is at least 3.26.1: `modlens <args>`.
26
- 2. Otherwise, if `npx` exists: `npx --yes --package @liustack/modlens@3.26.1 modlens <args>`.
27
- 3. Otherwise, if `bunx` exists: `bunx --bun @liustack/modlens@3.26.1 <args>`.
25
+ 1. A `modlens` on `PATH` whose major version is 3 and is at least 3.26.2: `modlens <args>`.
26
+ 2. Otherwise, if `npx` exists: `npx --yes --package @liustack/modlens@3.26.2 modlens <args>`.
27
+ 3. Otherwise, if `bunx` exists: `bunx --bun @liustack/modlens@3.26.2 <args>`.
28
28
  4. Otherwise tell the user no JavaScript runtime was found and that installing Node 22.19+ (https://nodejs.org) or Bun (https://bun.sh) is the next step. Do not claim modlens itself failed.
29
29
 
30
30
  `references/runtime.md` documents the pin and the diagnostic fields.
@@ -8,7 +8,7 @@ shell syntax.
8
8
 
9
9
  ## Pinned version
10
10
 
11
- - Pinned CLI version: 3.26.1
11
+ - Pinned CLI version: 3.26.2
12
12
  - npm package: `@liustack/modlens`
13
13
  - CLI binary name: `modlens`
14
14
 
@@ -24,7 +24,7 @@ $ErrorActionPreference = 'Stop'
24
24
  # package.json version, and the release script rewrites it on every bump.
25
25
  $Package = '@liustack/modlens'
26
26
  $Bin = 'modlens'
27
- $Pinned = '3.26.1'
27
+ $Pinned = '3.26.2'
28
28
  # -------------------------------------------------------------------------------
29
29
 
30
30
  $NativeNote = 'no native artifact is published for this tool yet; phase A ships npm launch paths only'
@@ -22,7 +22,7 @@ set -eu
22
22
  # package.json version, and the release script rewrites it on every bump.
23
23
  PKG="@liustack/modlens"
24
24
  BIN="modlens"
25
- PINNED="3.26.1"
25
+ PINNED="3.26.2"
26
26
  # -------------------------------------------------------------------------------
27
27
 
28
28
  NATIVE_NOTE="no native artifact is published for this tool yet; phase A ships npm launch paths only"