dsh-vision-router 1.7.1 → 1.7.4
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/cordis.patch.yml +23 -10
- package/entry.js +57 -20
- package/lib/adversarial-hardening.js +36 -5
- package/lib/artifact-boundary.js +45 -6
- package/lib/artifact-retention.js +124 -0
- package/lib/client-presentation-boundary.js +271 -0
- package/lib/dsh-contract-compat.js +125 -26
- package/lib/live-model-client-prelude.js +56 -25
- package/lib/repetition-guard.js +57 -36
- package/lib/runtime-reliability.js +261 -0
- package/lib/settings-client-rc8-lifecycle.js +383 -0
- package/lib/turn-budget-context.js +36 -2
- package/lib/vision-tool-runtime-boundary.js +449 -0
- package/package.json +2 -2
package/cordis.patch.yml
CHANGED
|
@@ -4,9 +4,9 @@
|
|
|
4
4
|
# without any manual cordis.patch.yml edits. Later layers (the profile's own
|
|
5
5
|
# cordis.patch.yml, --patch overlays) override these rows by id.
|
|
6
6
|
|
|
7
|
-
# 纯增量补丁(issue #34
|
|
8
|
-
#
|
|
9
|
-
#
|
|
7
|
+
# 纯增量补丁(issue #34):不碰核心行。最低支持 Host 仍保留旧兼容路径;
|
|
8
|
+
# batch-attachment 合同的 Host 始终拥有 deepseek-official,插件只提供
|
|
9
|
+
# 「+ 自动识图」包装路由,因此这里永远不默认禁用官方 llm-deepseek 行。
|
|
10
10
|
|
|
11
11
|
# 挂载插件行。默认保持完整视觉工具表常驻(issue #81):虽然渐进挂载可以
|
|
12
12
|
# 少发一小段工具 schema,但图片轮首次扩展工具列表会改变请求前缀,可能让
|
|
@@ -18,14 +18,27 @@
|
|
|
18
18
|
config:
|
|
19
19
|
progressiveTools: false
|
|
20
20
|
|
|
21
|
-
#
|
|
22
|
-
#
|
|
23
|
-
#
|
|
24
|
-
#
|
|
25
|
-
#
|
|
26
|
-
#
|
|
27
|
-
#
|
|
21
|
+
# Vision Router 的附件产品契约:Host attachment store 负责保存用户提交的
|
|
22
|
+
# durable 原件,具体视觉后端负责本次推理所需的 resize/tile/request budget。
|
|
23
|
+
# 因此存储准入保持 20MiB / 1 亿像素,同时在 DSH rc.8 新增的单边限制上
|
|
24
|
+
# 显式选择 10000px:覆盖 4K/手机原图和常见长截图,但不把 Host 的全局
|
|
25
|
+
# 极端长边安全闸无限放开。该值作用于整个 attachment-local(包括原生视觉
|
|
26
|
+
# provider),所以这里刻意不用 32768/无限;更大的部署需求仍可由后续
|
|
27
|
+
# profile/--patch 层按实际 provider 能力覆盖。
|
|
28
|
+
#
|
|
29
|
+
# DSH 的 patch 是“整段 config 替换”而非深度合并。旧版 Vision Router 曾在
|
|
30
|
+
# profile 自己的 cordis.patch.yml 写入同一行的 20MiB/100MP 两个字段;这层
|
|
31
|
+
# 比 bundle 晚,会把这里新加的 maxImageDimension 擦掉,让 rc.8 又落回 2000。
|
|
32
|
+
# 1.7.2 起运行时只对“20MiB + 100MP + rc.8 默认 2000px”这一历史指纹做一次
|
|
33
|
+
# 内存态迁移到 10000px;显式自定义过 dimension 或其他附件策略一律不碰。
|
|
34
|
+
# 这样升级旧 profile 不要求用户手工清理配置,同时后续 profile/--patch 的
|
|
35
|
+
# 明确部署策略仍保持最终所有权。
|
|
36
|
+
#
|
|
37
|
+
# rc.6/rc.7 没有 maxImageDimension;发布合同 CI 会用真实旧包验证额外配置键
|
|
38
|
+
# 不会破坏最低支持 Host。若旧 Host 开始严格拒绝未知键,此处必须改为按能力
|
|
39
|
+
# 生成配置,而不是靠版本字符串分支。
|
|
28
40
|
- id: attachment-local
|
|
29
41
|
config:
|
|
30
42
|
maxImageBytes: 20971520
|
|
31
43
|
maxImagePixels: 100000000
|
|
44
|
+
maxImageDimension: 10000
|
package/entry.js
CHANGED
|
@@ -15,6 +15,7 @@ import { contextWithVisionExecutionPolicy } from './lib/vision-execution-policy.
|
|
|
15
15
|
import { installLiveModelDiscovery } from './lib/live-model-discovery.js'
|
|
16
16
|
import { installVisionModelRegistry } from './lib/vision-model-registry.js'
|
|
17
17
|
import { installLiveModelClientPrelude } from './lib/live-model-client-prelude.js'
|
|
18
|
+
import { installClientPresentationBoundary } from './lib/client-presentation-boundary.js'
|
|
18
19
|
import { installAdversarialHardening } from './lib/adversarial-hardening.js'
|
|
19
20
|
import { installOllamaColdStartGuard } from './lib/ollama-cold-start.js'
|
|
20
21
|
import { installLocalVisionStabilizer } from './lib/local-vision-stabilizer.js'
|
|
@@ -24,16 +25,19 @@ import { contextWithCoalescedAdapterUpdates } from './lib/adapter-update-coalesc
|
|
|
24
25
|
import { installTesseractExecFileCompat } from './lib/tesseract-exec-compat.js'
|
|
25
26
|
import { installLocalMutationRouteBoundary } from './lib/web-capability-boundary.js'
|
|
26
27
|
import { installScreenshotSourceBoundary } from './lib/screenshot-source-boundary.js'
|
|
28
|
+
import { installVisionToolRuntimeBoundary } from './lib/vision-tool-runtime-boundary.js'
|
|
27
29
|
import { installVisionRouterRemoteSettingsBridge } from './lib/remote-settings-bridge.js'
|
|
30
|
+
import { installSettingsRc8ClientLifecycle } from './lib/settings-client-rc8-lifecycle.js'
|
|
28
31
|
import {
|
|
29
32
|
installStructuredFlowHardening,
|
|
30
33
|
normalizeGuidanceOverrides,
|
|
31
34
|
} from './lib/structured-flow-hardening.js'
|
|
32
35
|
import {
|
|
33
36
|
attachmentContextForContract,
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
+
hasBatchAttachmentContract,
|
|
38
|
+
installHostSettingsCompatibility,
|
|
39
|
+
installVisionAttachmentAdmissionPolicy,
|
|
40
|
+
protectHostProviderOwnership,
|
|
37
41
|
} from './lib/dsh-contract-compat.js'
|
|
38
42
|
|
|
39
43
|
// Increment whenever the browser-visible settings contract gains a field whose
|
|
@@ -56,7 +60,7 @@ core.Config.set('visionTurnBudgetMs', z.number().step(1000).min(10000).max(60000
|
|
|
56
60
|
// Both visible entry points — Settings > Vision Router and the legacy
|
|
57
61
|
// Settings > Plugins compatibility card — edit the same Host-owned namespace.
|
|
58
62
|
// Keep the depth enum and custom cap on this final public contract so either
|
|
59
|
-
// entry serializes exactly the same shape
|
|
63
|
+
// entry serializes exactly the same shape on every supported Host generation.
|
|
60
64
|
core.Config.set('visionDepth', z.union(['fast', 'standard', 'deep', 'custom']).default('standard'))
|
|
61
65
|
core.Config.set('visionDepthMaxCalls', z.number().step(1).min(0).max(100).default(0))
|
|
62
66
|
|
|
@@ -79,6 +83,13 @@ core.Config.set(
|
|
|
79
83
|
export * from './index.js'
|
|
80
84
|
export {
|
|
81
85
|
attachmentContextForContract,
|
|
86
|
+
ensureVisionAttachmentAdmissionPolicy,
|
|
87
|
+
hasBatchAttachmentContract,
|
|
88
|
+
installHostSettingsCompatibility,
|
|
89
|
+
installVisionAttachmentAdmissionPolicy,
|
|
90
|
+
protectHostProviderOwnership,
|
|
91
|
+
// Transitional public aliases retained for callers/tests written during the
|
|
92
|
+
// rc.7 compatibility pass. Runtime code below no longer branches on names.
|
|
82
93
|
installRc7SettingsCompatibility,
|
|
83
94
|
isRc7ContractRuntime,
|
|
84
95
|
protectRc7ProviderOwnership,
|
|
@@ -106,7 +117,7 @@ export function apply(ctx, config = {}) {
|
|
|
106
117
|
: 'deepseek-vision',
|
|
107
118
|
visionConfig: config,
|
|
108
119
|
})
|
|
109
|
-
//
|
|
120
|
+
// Newer pi-ai replay envelopes store the real producer under
|
|
110
121
|
// replayState.response.{provider,model}; the older delegated-replay shim
|
|
111
122
|
// recognizes the pre-v2 top-level shape. Layer a narrow private compatibility
|
|
112
123
|
// view so resumed wrapper history keeps provider-native replay metadata rather
|
|
@@ -150,31 +161,52 @@ export function apply(ctx, config = {}) {
|
|
|
150
161
|
? Number(bootConfig.visionTurnBudgetMs)
|
|
151
162
|
: 90000,
|
|
152
163
|
}
|
|
153
|
-
|
|
164
|
+
// The batch-attachment API is the released, non-incidental discriminator
|
|
165
|
+
// between the minimum Host contract and the newer Host-owned integration
|
|
166
|
+
// generation. Keep the branch named after that observable capability rather
|
|
167
|
+
// than a release number so rc.8+ naturally follows the same public contract.
|
|
168
|
+
const batchAttachmentHost = hasBatchAttachmentContract(stabilizedCtx)
|
|
169
|
+
// DSH may reconstruct attachment-local after a profile/home patch reload.
|
|
170
|
+
// Keep the historical rc.8 overlay migration attached to that service
|
|
171
|
+
// lifecycle instead of healing only the instance present during apply().
|
|
172
|
+
if (batchAttachmentHost) {
|
|
173
|
+
installVisionAttachmentAdmissionPolicy(stabilizedCtx, logging.logger)
|
|
174
|
+
}
|
|
154
175
|
// The remote settings bridge uses DSH Connection's trusted-host carrier
|
|
155
176
|
// fence and its own safe-field capability allow-list. Main's local Web
|
|
156
177
|
// mutation boundary continues to protect the independent /_dsh write routes.
|
|
157
178
|
installVisionRouterRemoteSettingsBridge(stabilizedCtx, logging.logger)
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
179
|
+
// rc.8 swaps ModuleLoader.load() while entering live mode. The older local
|
|
180
|
+
// permission/risk shims still own rc.6/rc.7; this narrow lifecycle shim
|
|
181
|
+
// re-installs both contexts after rc.8's queue -> live transition.
|
|
182
|
+
installSettingsRc8ClientLifecycle(stabilizedCtx)
|
|
183
|
+
const ownershipCtx = batchAttachmentHost
|
|
184
|
+
? protectHostProviderOwnership(stabilizedCtx)
|
|
185
|
+
: stabilizedCtx
|
|
186
|
+
const settingsCtx = batchAttachmentHost
|
|
187
|
+
? installHostSettingsCompatibility(ownershipCtx, { ...runtimeConfig, stealth: false }, {
|
|
161
188
|
namespace: 'vision-router',
|
|
162
189
|
Config: core.Config,
|
|
163
190
|
})
|
|
164
191
|
: ownershipCtx
|
|
165
|
-
//
|
|
166
|
-
//
|
|
167
|
-
// store-owned, so never
|
|
192
|
+
// The minimum Host keeps the narrow process-local fallback required by the
|
|
193
|
+
// old attachment-local durability walk. Batch-capable hosts keep AttachmentId
|
|
194
|
+
// store-owned, so never fabricate one there: host persistence errors remain
|
|
168
195
|
// authoritative and diagnosable instead of creating a false durable ref.
|
|
169
196
|
const attachmentCompatCtx = attachmentContextForContract(settingsCtx, logging.logger, {
|
|
170
197
|
installAndroidAttachmentCompat,
|
|
171
198
|
})
|
|
199
|
+
// Put per-tool cwd/cancellation/cache policy AFTER Host settings compatibility
|
|
200
|
+
// so rc.7/rc.8's synthetic settings injection is visible to the boundary.
|
|
201
|
+
// The secure screenshot renderer owns its exact FsTarget and active browser
|
|
202
|
+
// cancellation directly, so it does not depend on this placement.
|
|
203
|
+
const toolRuntimeCtx = installVisionToolRuntimeBoundary(attachmentCompatCtx)
|
|
172
204
|
// Final structured-flow guard sits closest to core.apply so it sees the
|
|
173
205
|
// actual tool registrations and pre-step listener. It makes bootstrap
|
|
174
206
|
// one-shot, enforces fast/standard/deep/custom quotas, tracks mixed branches,
|
|
175
207
|
// rejects empty/non-evidence results, and applies one shared visual deadline.
|
|
176
|
-
const structuredCtx = installStructuredFlowHardening(
|
|
177
|
-
// DSH
|
|
208
|
+
const structuredCtx = installStructuredFlowHardening(toolRuntimeCtx, runtimeConfig)
|
|
209
|
+
// Newer DSH releases publish llm/adapters-updated synchronously from inside
|
|
178
210
|
// registerAdapter(). Coalesce only Vision Router's listener: nested events
|
|
179
211
|
// mark the topology dirty and the outer pass reruns to a fixed point, so we
|
|
180
212
|
// neither double-register a twin nor lose a provider added mid-pass.
|
|
@@ -196,6 +228,12 @@ export function apply(ctx, config = {}) {
|
|
|
196
228
|
// source strictly for diagnostics (`known` vs `live`) without changing the
|
|
197
229
|
// admission decision.
|
|
198
230
|
installVisionModelRegistry(reconciledCtx, liveDiscovery, { config: runtimeConfig })
|
|
231
|
+
// rc.8 turns ui-attachment into a dynamic presentation plugin and no longer
|
|
232
|
+
// exports its React implementation as package values. Install a narrowly
|
|
233
|
+
// scoped browser boundary that supplies Vision Router's own lightweight
|
|
234
|
+
// gallery to the legacy 1.7.x client factory, so the official package is
|
|
235
|
+
// never value-required at runtime and remains free to evolve independently.
|
|
236
|
+
installClientPresentationBoundary(reconciledCtx)
|
|
199
237
|
// Keep endpoint-discovered ids private to Vision Router's settings client:
|
|
200
238
|
// the prelude wraps this package's browser context rather than changing the
|
|
201
239
|
// global llm.models response (which would expose UNKNOWN_MODEL entries in the
|
|
@@ -229,7 +267,7 @@ export function apply(ctx, config = {}) {
|
|
|
229
267
|
const lms = c.localLmStudio && typeof c.localLmStudio === 'object' ? c.localLmStudio : {}
|
|
230
268
|
logging.logger.info(
|
|
231
269
|
'vision-router: base config summary — contract=%s instantDescribe=%s localDescribeStyle=%s localOllama=%s localLmStudio=%s',
|
|
232
|
-
|
|
270
|
+
batchAttachmentHost ? 'batch-attachments' : 'single-attachment',
|
|
233
271
|
c.instantDescribe === true ? 'on' : 'off',
|
|
234
272
|
c.localDescribeStyle === 'structured' ? 'structured' : 'plain',
|
|
235
273
|
local.enabled === true ? 'on' : 'off',
|
|
@@ -240,11 +278,10 @@ export function apply(ctx, config = {}) {
|
|
|
240
278
|
}
|
|
241
279
|
try {
|
|
242
280
|
const result = core.apply(executionCtx, runtimeConfig)
|
|
243
|
-
//
|
|
244
|
-
// provider directory, not by the live adapter registry alone.
|
|
245
|
-
// main DeepSeek + 自动识图 route as a derived alias of official
|
|
246
|
-
//
|
|
247
|
-
// arbitrary textProvider look like DeepSeek. On rc.6 the helper is inert.
|
|
281
|
+
// On newer Hosts the Settings -> Models surface is backed by the
|
|
282
|
+
// configurable-provider directory, not by the live adapter registry alone.
|
|
283
|
+
// Publish the main DeepSeek + 自动识图 route as a derived alias of official
|
|
284
|
+
// DeepSeek. On older Hosts the helper feature-detects and stays inert.
|
|
248
285
|
installWrapperDirectoryAlias(attachmentCompatCtx, runtimeConfig, logging.logger)
|
|
249
286
|
if (result && typeof result.then === 'function') {
|
|
250
287
|
return result.catch((error) => {
|
|
@@ -3,6 +3,7 @@ import { mkdir, writeFile } from 'node:fs/promises'
|
|
|
3
3
|
import path from 'node:path'
|
|
4
4
|
import { fileURLToPath, pathToFileURL } from 'node:url'
|
|
5
5
|
import { writeArtifactFile } from './artifact-boundary.js'
|
|
6
|
+
import { assertScreenshotSourceInWorkspace } from './screenshot-source-boundary.js'
|
|
6
7
|
|
|
7
8
|
const DEFAULT_ARTIFACTS_DIR = '.dsh-vision-router/artifacts'
|
|
8
9
|
const MAX_VIEWPORT_WIDTH = 4096
|
|
@@ -217,7 +218,7 @@ export async function wakePageForFullCaptureBounded(
|
|
|
217
218
|
}
|
|
218
219
|
|
|
219
220
|
function screenshotAbortError() {
|
|
220
|
-
const error = new Error('vision_html_screenshot: browser
|
|
221
|
+
const error = new Error('vision_html_screenshot: browser work aborted')
|
|
221
222
|
error.name = 'AbortError'
|
|
222
223
|
error.code = 'ABORT_ERR'
|
|
223
224
|
return error
|
|
@@ -299,18 +300,26 @@ export function createSecureHtmlScreenshotExecute(ctx, core, config, deps = {})
|
|
|
299
300
|
|
|
300
301
|
return async (args, exec) => {
|
|
301
302
|
const source = String(args?.source ?? '')
|
|
303
|
+
const signal = exec?.signal
|
|
304
|
+
if (signal?.aborted) throw screenshotAbortError()
|
|
302
305
|
if (!/\.(html?|htm)$/i.test(source)) {
|
|
303
306
|
throw new Error('vision_html_screenshot: source must be a local .html/.htm file')
|
|
304
307
|
}
|
|
305
308
|
const fsService = ctx.get('fs')
|
|
306
309
|
if (fsService === undefined) throw new Error('vision_html_screenshot: the fs service is not available')
|
|
307
|
-
|
|
310
|
+
// Authorize the exact FsTarget that will be rendered. Do not validate one
|
|
311
|
+
// cwd interpretation and then resolve the same string again under the
|
|
312
|
+
// provider default cwd.
|
|
313
|
+
const resolved = await assertScreenshotSourceInWorkspace(ctx, core, source, exec, { realpathSync: realpath })
|
|
308
314
|
const targetPath = core.toRealPath(fsService, resolved)
|
|
309
315
|
if (!fileExists(targetPath)) throw new Error(`vision_html_screenshot: file not found: ${source}`)
|
|
310
316
|
|
|
311
317
|
const targetReal = realpath(targetPath)
|
|
312
318
|
const workspace = realpathOrResolve(workspaceOf(exec), realpath)
|
|
313
|
-
|
|
319
|
+
if (!isPathInside(workspace, targetReal)) {
|
|
320
|
+
throw new Error('vision_html_screenshot: source must stay inside the session workspace')
|
|
321
|
+
}
|
|
322
|
+
const sourceRoot = workspace
|
|
314
323
|
|
|
315
324
|
const width = safeViewportDimension(args?.width, 1200, MAX_VIEWPORT_WIDTH, 'width')
|
|
316
325
|
const height = safeViewportDimension(args?.height, 720, MAX_VIEWPORT_HEIGHT, 'height')
|
|
@@ -336,13 +345,22 @@ export function createSecureHtmlScreenshotExecute(ctx, core, config, deps = {})
|
|
|
336
345
|
)
|
|
337
346
|
}
|
|
338
347
|
|
|
339
|
-
const releaseBrowserSlot = await browserGovernor.acquire({ signal
|
|
348
|
+
const releaseBrowserSlot = await browserGovernor.acquire({ signal })
|
|
340
349
|
const launchArgs = ['--disable-gpu', '--hide-scrollbars', '--incognito']
|
|
341
350
|
if (fullPage) launchArgs.push('--blink-settings=imagesLazyLoadingEnabled=false')
|
|
342
351
|
const launcher = puppeteer.default ?? puppeteer
|
|
343
352
|
let browser
|
|
353
|
+
let browserClosePromise
|
|
354
|
+
let abortHandler
|
|
344
355
|
let png
|
|
345
356
|
let pageHeight
|
|
357
|
+
const closeBrowser = () => {
|
|
358
|
+
if (!browser) return Promise.resolve()
|
|
359
|
+
if (!browserClosePromise) {
|
|
360
|
+
browserClosePromise = Promise.resolve(browser.close()).catch(() => undefined)
|
|
361
|
+
}
|
|
362
|
+
return browserClosePromise
|
|
363
|
+
}
|
|
346
364
|
try {
|
|
347
365
|
try {
|
|
348
366
|
browser = await launcher.launch({ executablePath, headless: true, args: launchArgs })
|
|
@@ -350,6 +368,11 @@ export function createSecureHtmlScreenshotExecute(ctx, core, config, deps = {})
|
|
|
350
368
|
const detail = error && error.message ? error.message : String(error)
|
|
351
369
|
throw new Error(`vision_html_screenshot: secure Chrome sandbox launch failed: ${detail}`)
|
|
352
370
|
}
|
|
371
|
+
if (signal) {
|
|
372
|
+
abortHandler = () => { void closeBrowser() }
|
|
373
|
+
signal.addEventListener('abort', abortHandler, { once: true })
|
|
374
|
+
}
|
|
375
|
+
if (signal?.aborted) throw screenshotAbortError()
|
|
353
376
|
|
|
354
377
|
const page = await browser.newPage()
|
|
355
378
|
await page.setViewport({ width, height })
|
|
@@ -362,6 +385,7 @@ export function createSecureHtmlScreenshotExecute(ctx, core, config, deps = {})
|
|
|
362
385
|
await page.setOfflineMode(true)
|
|
363
386
|
await wrapRequestInterception(page, sourceRoot)
|
|
364
387
|
await page.goto(pathToFileURL(targetReal).href, { waitUntil: 'networkidle0', timeout: 30000 })
|
|
388
|
+
if (signal?.aborted) throw screenshotAbortError()
|
|
365
389
|
|
|
366
390
|
if (fullPage) {
|
|
367
391
|
pageHeight = await wakePageForFullCaptureBounded(page, height, width, {
|
|
@@ -371,13 +395,19 @@ export function createSecureHtmlScreenshotExecute(ctx, core, config, deps = {})
|
|
|
371
395
|
maxWakeMs: deps.maxWakeMs,
|
|
372
396
|
})
|
|
373
397
|
}
|
|
398
|
+
if (signal?.aborted) throw screenshotAbortError()
|
|
374
399
|
|
|
375
400
|
png = fullPage
|
|
376
401
|
? await page.screenshot({ type: 'png', fullPage: true })
|
|
377
402
|
: await page.screenshot({ type: 'png' })
|
|
403
|
+
if (signal?.aborted) throw screenshotAbortError()
|
|
404
|
+
} catch (error) {
|
|
405
|
+
if (signal?.aborted && error?.code !== 'ABORT_ERR') throw screenshotAbortError()
|
|
406
|
+
throw error
|
|
378
407
|
} finally {
|
|
408
|
+
if (signal && abortHandler) signal.removeEventListener('abort', abortHandler)
|
|
379
409
|
try {
|
|
380
|
-
|
|
410
|
+
await closeBrowser()
|
|
381
411
|
} finally {
|
|
382
412
|
releaseBrowserSlot()
|
|
383
413
|
}
|
|
@@ -385,6 +415,7 @@ export function createSecureHtmlScreenshotExecute(ctx, core, config, deps = {})
|
|
|
385
415
|
|
|
386
416
|
// The heavyweight Chrome slot is released before filesystem artifact IO;
|
|
387
417
|
// slow antivirus/indexing must not unnecessarily serialize later captures.
|
|
418
|
+
if (signal?.aborted) throw screenshotAbortError()
|
|
388
419
|
const stem = fullPage ? `shot-${width}x${height}-fullpage` : `shot-${width}x${height}`
|
|
389
420
|
const fileName = `${core.artifactStemOf(source, stem)}.png`
|
|
390
421
|
let target
|
package/lib/artifact-boundary.js
CHANGED
|
@@ -1,6 +1,14 @@
|
|
|
1
1
|
import { randomUUID } from 'node:crypto'
|
|
2
2
|
import { lstat, mkdir, realpath, rename, unlink, writeFile } from 'node:fs/promises'
|
|
3
3
|
import path from 'node:path'
|
|
4
|
+
import {
|
|
5
|
+
currentVisionTurnBudget,
|
|
6
|
+
currentVisionTurnBudgetSignal,
|
|
7
|
+
} from './turn-budget-context.js'
|
|
8
|
+
import {
|
|
9
|
+
isManagedArtifactRunName,
|
|
10
|
+
scheduleArtifactRetention,
|
|
11
|
+
} from './artifact-retention.js'
|
|
4
12
|
|
|
5
13
|
export const DEFAULT_ARTIFACTS_DIR = '.dsh-vision-router/artifacts'
|
|
6
14
|
|
|
@@ -13,6 +21,22 @@ function isMissing(error) {
|
|
|
13
21
|
return error && (error.code === 'ENOENT' || error.code === 'ENOTDIR')
|
|
14
22
|
}
|
|
15
23
|
|
|
24
|
+
function artifactAbortError() {
|
|
25
|
+
const error = new Error('vision-router: artifact publication aborted')
|
|
26
|
+
error.name = 'AbortError'
|
|
27
|
+
error.code = 'ABORT_ERR'
|
|
28
|
+
return error
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function throwIfVisionAborted() {
|
|
32
|
+
if (currentVisionTurnBudgetSignal()?.aborted) throw artifactAbortError()
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function currentArtifactRunId() {
|
|
36
|
+
const value = currentVisionTurnBudget()?.artifactRunId
|
|
37
|
+
return isManagedArtifactRunName(value) ? value : undefined
|
|
38
|
+
}
|
|
39
|
+
|
|
16
40
|
export function normalizeArtifactsDir(value) {
|
|
17
41
|
if (typeof value !== 'string' || value.trim() === '') return DEFAULT_ARTIFACTS_DIR
|
|
18
42
|
const raw = value.trim()
|
|
@@ -69,18 +93,22 @@ async function safeLstat(target, lstatImpl) {
|
|
|
69
93
|
/**
|
|
70
94
|
* Resolve an artifact target without treating lexical workspace containment as
|
|
71
95
|
* authority. Existing ancestors are canonicalized before mkdir runs, then the
|
|
72
|
-
* completed parent
|
|
73
|
-
* nested OCR directory) symlink from turning a workspace-looking path into an
|
|
74
|
-
* outside write.
|
|
96
|
+
* completed parent and artifact root are canonicalized again.
|
|
75
97
|
*/
|
|
76
98
|
export async function resolveArtifactTarget(workspace, artifactsDir, relativePath, deps = {}) {
|
|
77
99
|
const realpathImpl = deps.realpath ?? realpath
|
|
78
100
|
const mkdirImpl = deps.mkdir ?? mkdir
|
|
79
101
|
const lstatImpl = deps.lstat ?? lstat
|
|
80
102
|
|
|
103
|
+
throwIfVisionAborted()
|
|
81
104
|
const workspaceReal = await realpathImpl(path.resolve(String(workspace ?? '')))
|
|
105
|
+
throwIfVisionAborted()
|
|
82
106
|
const relativeBase = normalizeArtifactsDir(artifactsDir)
|
|
83
|
-
const
|
|
107
|
+
const runId = currentArtifactRunId()
|
|
108
|
+
const requestedTarget = normalizeRelativeArtifactPath(relativePath)
|
|
109
|
+
const relativeTarget = runId
|
|
110
|
+
? normalizeRelativeArtifactPath(path.join(runId, requestedTarget))
|
|
111
|
+
: requestedTarget
|
|
84
112
|
const lexicalBase = path.resolve(workspaceReal, relativeBase)
|
|
85
113
|
if (!isPathInside(workspaceReal, lexicalBase)) {
|
|
86
114
|
throw new Error('vision-router: artifactsDir must stay inside the session workspace')
|
|
@@ -92,22 +120,27 @@ export async function resolveArtifactTarget(workspace, artifactsDir, relativePat
|
|
|
92
120
|
|
|
93
121
|
const lexicalParent = path.dirname(lexicalTarget)
|
|
94
122
|
const existingAncestorReal = await realpathNearestExisting(lexicalParent, realpathImpl)
|
|
123
|
+
throwIfVisionAborted()
|
|
95
124
|
if (!isPathInside(workspaceReal, existingAncestorReal)) {
|
|
96
125
|
throw new Error('vision-router: artifact parent escapes the session workspace through a symlink')
|
|
97
126
|
}
|
|
98
127
|
|
|
99
128
|
await mkdirImpl(lexicalParent, { recursive: true })
|
|
129
|
+
throwIfVisionAborted()
|
|
100
130
|
const parentReal = await realpathImpl(lexicalParent)
|
|
101
|
-
|
|
131
|
+
const artifactsBaseReal = await realpathImpl(lexicalBase)
|
|
132
|
+
throwIfVisionAborted()
|
|
133
|
+
if (!isPathInside(workspaceReal, parentReal) || !isPathInside(workspaceReal, artifactsBaseReal)) {
|
|
102
134
|
throw new Error('vision-router: artifact parent escapes the session workspace through a symlink')
|
|
103
135
|
}
|
|
104
136
|
|
|
105
137
|
const target = path.join(parentReal, path.basename(lexicalTarget))
|
|
106
138
|
const existing = await safeLstat(target, lstatImpl)
|
|
139
|
+
throwIfVisionAborted()
|
|
107
140
|
if (existing?.isDirectory?.()) {
|
|
108
141
|
throw new Error('vision-router: artifact target is a directory')
|
|
109
142
|
}
|
|
110
|
-
return { target, parentReal, existing }
|
|
143
|
+
return { target, parentReal, artifactsBaseReal, existing, runId }
|
|
111
144
|
}
|
|
112
145
|
|
|
113
146
|
/**
|
|
@@ -126,16 +159,22 @@ export async function writeArtifactFile(workspace, artifactsDir, relativePath, d
|
|
|
126
159
|
)
|
|
127
160
|
let published = false
|
|
128
161
|
try {
|
|
162
|
+
throwIfVisionAborted()
|
|
129
163
|
await writeFileImpl(temp, data, { mode: 0o600 })
|
|
164
|
+
throwIfVisionAborted()
|
|
130
165
|
if (resolved.existing !== undefined) {
|
|
131
166
|
try {
|
|
132
167
|
await unlinkImpl(resolved.target)
|
|
133
168
|
} catch (error) {
|
|
134
169
|
if (!isMissing(error)) throw error
|
|
135
170
|
}
|
|
171
|
+
throwIfVisionAborted()
|
|
136
172
|
}
|
|
137
173
|
await renameImpl(temp, resolved.target)
|
|
138
174
|
published = true
|
|
175
|
+
if (resolved.runId) {
|
|
176
|
+
scheduleArtifactRetention(resolved.artifactsBaseReal, { protectRunId: resolved.runId })
|
|
177
|
+
}
|
|
139
178
|
return resolved.target
|
|
140
179
|
} finally {
|
|
141
180
|
if (!published) {
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { lstat, readdir, rm } from 'node:fs/promises'
|
|
2
|
+
import path from 'node:path'
|
|
3
|
+
|
|
4
|
+
export const ARTIFACT_RUN_PREFIX = '.vision-run-'
|
|
5
|
+
export const DEFAULT_ARTIFACT_TTL_MS = 30 * 24 * 60 * 60 * 1000
|
|
6
|
+
export const DEFAULT_ARTIFACT_MAX_BYTES = 2 * 1024 * 1024 * 1024
|
|
7
|
+
export const DEFAULT_ARTIFACT_MAX_RUNS = 512
|
|
8
|
+
export const DEFAULT_ARTIFACT_CLEANUP_INTERVAL_MS = 5 * 60 * 1000
|
|
9
|
+
|
|
10
|
+
const cleanupState = new Map()
|
|
11
|
+
const MAX_TRACKED_ROOTS = 64
|
|
12
|
+
|
|
13
|
+
export function isManagedArtifactRunName(name) {
|
|
14
|
+
return typeof name === 'string' && /^\.vision-run-[A-Za-z0-9._-]+$/.test(name)
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
async function treeBytes(target) {
|
|
18
|
+
let info
|
|
19
|
+
try { info = await lstat(target) } catch { return 0 }
|
|
20
|
+
if (info.isSymbolicLink()) return 0
|
|
21
|
+
if (!info.isDirectory()) return Number(info.size) || 0
|
|
22
|
+
let total = 0
|
|
23
|
+
let entries
|
|
24
|
+
try { entries = await readdir(target, { withFileTypes: true }) } catch { return 0 }
|
|
25
|
+
for (const entry of entries) {
|
|
26
|
+
total += await treeBytes(path.join(target, entry.name))
|
|
27
|
+
}
|
|
28
|
+
return total
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function finitePositive(value, fallback) {
|
|
32
|
+
const number = Number(value)
|
|
33
|
+
return Number.isFinite(number) && number > 0 ? number : fallback
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Delete only Vision Router run directories. Unknown files, legacy artifacts,
|
|
38
|
+
* symlinks and user-created directories are intentionally outside this policy.
|
|
39
|
+
*/
|
|
40
|
+
export async function cleanupArtifactRuns(root, options = {}) {
|
|
41
|
+
const ttlMs = finitePositive(options.ttlMs, DEFAULT_ARTIFACT_TTL_MS)
|
|
42
|
+
const maxBytes = finitePositive(options.maxBytes, DEFAULT_ARTIFACT_MAX_BYTES)
|
|
43
|
+
const maxRuns = Math.max(1, Math.floor(finitePositive(options.maxRuns, DEFAULT_ARTIFACT_MAX_RUNS)))
|
|
44
|
+
const now = Number.isFinite(Number(options.now)) ? Number(options.now) : Date.now()
|
|
45
|
+
const protect = typeof options.protectRunId === 'string' ? options.protectRunId : undefined
|
|
46
|
+
|
|
47
|
+
let entries
|
|
48
|
+
try { entries = await readdir(root, { withFileTypes: true }) } catch { return { scanned: 0, removed: 0, bytes: 0 } }
|
|
49
|
+
const runs = []
|
|
50
|
+
for (const entry of entries) {
|
|
51
|
+
if (!entry.isDirectory() || !isManagedArtifactRunName(entry.name)) continue
|
|
52
|
+
const target = path.join(root, entry.name)
|
|
53
|
+
let info
|
|
54
|
+
try { info = await lstat(target) } catch { continue }
|
|
55
|
+
if (!info.isDirectory() || info.isSymbolicLink()) continue
|
|
56
|
+
runs.push({
|
|
57
|
+
name: entry.name,
|
|
58
|
+
target,
|
|
59
|
+
mtimeMs: Number(info.mtimeMs) || 0,
|
|
60
|
+
bytes: await treeBytes(target),
|
|
61
|
+
protected: entry.name === protect,
|
|
62
|
+
})
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
runs.sort((a, b) => b.mtimeMs - a.mtimeMs)
|
|
66
|
+
const remove = new Set()
|
|
67
|
+
for (const run of runs) {
|
|
68
|
+
if (!run.protected && now - run.mtimeMs >= ttlMs) remove.add(run)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const survivors = () => runs.filter((run) => !remove.has(run))
|
|
72
|
+
let live = survivors()
|
|
73
|
+
while (live.length > maxRuns) {
|
|
74
|
+
const candidate = [...live].reverse().find((run) => !run.protected)
|
|
75
|
+
if (!candidate) break
|
|
76
|
+
remove.add(candidate)
|
|
77
|
+
live = survivors()
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
let totalBytes = live.reduce((sum, run) => sum + run.bytes, 0)
|
|
81
|
+
while (totalBytes > maxBytes) {
|
|
82
|
+
const candidate = [...live].reverse().find((run) => !run.protected)
|
|
83
|
+
if (!candidate) break
|
|
84
|
+
remove.add(candidate)
|
|
85
|
+
totalBytes -= candidate.bytes
|
|
86
|
+
live = survivors()
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
let removed = 0
|
|
90
|
+
for (const run of remove) {
|
|
91
|
+
try {
|
|
92
|
+
await rm(run.target, { recursive: true, force: true })
|
|
93
|
+
removed += 1
|
|
94
|
+
} catch {
|
|
95
|
+
// Retention is best-effort and must never make the foreground tool fail.
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
return { scanned: runs.length, removed, bytes: Math.max(0, totalBytes) }
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Coalesce cleanup work per artifact root so foreground tools only schedule it. */
|
|
102
|
+
export function scheduleArtifactRetention(root, options = {}) {
|
|
103
|
+
if (typeof root !== 'string' || root === '') return
|
|
104
|
+
const now = Date.now()
|
|
105
|
+
let state = cleanupState.get(root)
|
|
106
|
+
if (!state) {
|
|
107
|
+
state = { lastStartedAt: 0, promise: undefined }
|
|
108
|
+
cleanupState.set(root, state)
|
|
109
|
+
}
|
|
110
|
+
const intervalMs = finitePositive(options.intervalMs, DEFAULT_ARTIFACT_CLEANUP_INTERVAL_MS)
|
|
111
|
+
if (state.promise || now - state.lastStartedAt < intervalMs) return
|
|
112
|
+
state.lastStartedAt = now
|
|
113
|
+
state.promise = cleanupArtifactRuns(root, options)
|
|
114
|
+
.catch(() => undefined)
|
|
115
|
+
.finally(() => { state.promise = undefined })
|
|
116
|
+
|
|
117
|
+
cleanupState.delete(root)
|
|
118
|
+
cleanupState.set(root, state)
|
|
119
|
+
while (cleanupState.size > MAX_TRACKED_ROOTS) {
|
|
120
|
+
const oldest = cleanupState.keys().next().value
|
|
121
|
+
if (oldest === undefined) break
|
|
122
|
+
cleanupState.delete(oldest)
|
|
123
|
+
}
|
|
124
|
+
}
|