@goodandready/dsh-dsml-artifact-guard 0.1.4 → 0.2.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,15 +12,25 @@
12
12
  </p>
13
13
 
14
14
  <p align="center">
15
- <a href="https://goodandready.app/"><img src="https://img.shields.io/badge/All_Author_Projects-goodandready.app-ff4500.svg?style=for-the-badge&logo=rocket&logoColor=white&labelColor=1a1a2e" alt="GoodAndReady Showcase"></a>
15
+ <a href="https://goodandready.app/"><img src="https://img.shields.io/badge/🌐_DSH_Hub-goodandready.app-ff4500.svg?style=for-the-badge&labelColor=1a1a2e" alt="GoodAndReady Showcase"></a>
16
16
  </p>
17
17
 
18
18
  <p align="center">
19
19
  <a href="README.md"><b>🇬🇧 English</b></a> •
20
- <a href="docs/README.ru.md"><b>🇷🇺 Русский</b></a> •
21
- <a href="docs/README.zh.md"><b>🇨🇳 中文说明</b></a>
20
+ <a href="README.ru.md"><b>🇷🇺 Русский</b></a> •
21
+ <a href="README.zh.md"><b>🇨🇳 中文说明</b></a>
22
22
  </p>
23
23
 
24
+ <table align="center">
25
+ <tr>
26
+ <td align="center">
27
+ ⭐ <strong>If you like this plugin, please star it on GitHub</strong> — it shows me that the plugin is useful to you and motivates me to keep developing it.
28
+ <br><br>
29
+ 🐛 <strong>If you find a bug or would like to request a feature</strong>, open a GitHub issue in any language — I will review your proposal and implement useful suggestions in a future plugin version.
30
+ </td>
31
+ </tr>
32
+ </table>
33
+
24
34
  </div>
25
35
 
26
36
  ---
@@ -64,15 +74,15 @@ graph TD
64
74
  Audit["Audit Mode<br/>(Emits ctx.logger warning only)"]
65
75
  end
66
76
 
67
- Turn -->|ctx.on('llm/stream')| Hook
77
+ Turn -->|llm/stream hook| Hook
68
78
  Hook --> ScopeCheck
69
79
  ScopeCheck -->|No| PassThrough
70
80
  ScopeCheck -->|Yes| Buffer
71
81
  PassThrough --> ChatUI
72
82
  Buffer --> Detector
73
83
  Detector -->|No Artifact| ChatUI
74
- Detector -->|Artifact Found & sanitize| Sanitize --> ChatUI
75
- Detector -->|Artifact Found & audit| Audit --> ChatUI
84
+ Detector -->|Artifact detected: sanitize| Sanitize --> ChatUI
85
+ Detector -->|Artifact detected: audit| Audit --> ChatUI
76
86
  ```
77
87
 
78
88
  ---
@@ -151,7 +161,6 @@ npm run check
151
161
 
152
162
  MIT © [GooDAnDReaDY](https://github.com/GooDAnDReaDY)
153
163
 
154
-
155
164
  ## Changed in v0.1.3
156
165
 
157
166
  #8: wrap `llm/stream` in `ctx.effect` so unload unsubscribes.
@@ -162,3 +171,15 @@ MIT © [GooDAnDReaDY](https://github.com/GooDAnDReaDY)
162
171
  #11: schema `mode` default set to `sanitize` for out-of-the-box protection.
163
172
  #11: robust open/close tag balancing in `sanitizeDsmlArtifacts` to handle prior closed blocks in prose.
164
173
  #11: repo hygiene with tracked `package-lock.json` and design contract.
174
+
175
+ ## Changed in v0.1.5
176
+
177
+ #15: fix Mermaid diagram edge label syntax on GitHub and add mandatory repository support block.
178
+
179
+ ## Changed in v0.2.0
180
+
181
+ - #17: Native DSH Settings Card in slot settings.plugin.item with snapshot status checking, form inputs, and disabled warning indicator.
182
+ - #18: One-click plugin updater with endpoint /api/dsh-dsml-artifact-guard/update, semver comparison, and loopback/same-origin protection.
183
+ - #19: Sanitized repository from internal AGENTS.md and updated .gitignore.
184
+ - #20: Optimized npm package allowlist, eliminating duplicate READMEs and reducing package size by ~40%.
185
+ - #21: Removed legacy build archives from source tree.
@@ -12,15 +12,25 @@
12
12
  </p>
13
13
 
14
14
  <p align="center">
15
- <a href="https://goodandready.app/"><img src="https://img.shields.io/badge/Все_проекты_автора-goodandready.app-ff4500.svg?style=for-the-badge&logo=rocket&logoColor=white&labelColor=1a1a2e" alt="Все проекты автора"></a>
15
+ <a href="https://goodandready.app/"><img src="https://img.shields.io/badge/🌐_DSH_Hub-goodandready.app-ff4500.svg?style=for-the-badge&labelColor=1a1a2e" alt="GoodAndReady Showcase"></a>
16
16
  </p>
17
17
 
18
18
  <p align="center">
19
- <a href="../README.md"><b>🇬🇧 English</b></a> •
19
+ <a href="README.md"><b>🇬🇧 English</b></a> •
20
20
  <a href="README.ru.md"><b>🇷🇺 Русский</b></a> •
21
21
  <a href="README.zh.md"><b>🇨🇳 中文说明</b></a>
22
22
  </p>
23
23
 
24
+ <table align="center">
25
+ <tr>
26
+ <td align="center">
27
+ ⭐ <strong>Если вам нравится этот плагин, поставьте ему звезду на GitHub</strong> — это покажет мне, что плагин вам полезен, и будет мотивировать меня развивать его дальше.
28
+ <br><br>
29
+ 🐛 <strong>Если вы нашли баг или хотите предложить новый функционал</strong>, создайте issue на GitHub на любом языке — я рассмотрю ваше предложение и реализую полезные идеи в одной из следующих версий плагина.
30
+ </td>
31
+ </tr>
32
+ </table>
33
+
24
34
  </div>
25
35
 
26
36
  ---
@@ -64,15 +74,15 @@ graph TD
64
74
  Audit["Режим Audit<br/>(Логирование через ctx.logger)"]
65
75
  end
66
76
 
67
- Turn -->|ctx.on('llm/stream')| Hook
77
+ Turn -->|Хук llm/stream| Hook
68
78
  Hook --> ScopeCheck
69
79
  ScopeCheck -->|Нет| PassThrough
70
80
  ScopeCheck -->|Да| Buffer
71
81
  PassThrough --> ChatUI
72
82
  Buffer --> Detector
73
83
  Detector -->|Нет артефакта| ChatUI
74
- Detector -->|Артефакт найден & sanitize| Sanitize --> ChatUI
75
- Detector -->|Артефакт найден & audit| Audit --> ChatUI
84
+ Detector -->|Артефакт найден: sanitize| Sanitize --> ChatUI
85
+ Detector -->|Артефакт найден: audit| Audit --> ChatUI
76
86
  ```
77
87
 
78
88
  ---
@@ -151,7 +161,6 @@ npm run check
151
161
 
152
162
  MIT © [GooDAnDReaDY](https://github.com/GooDAnDReaDY)
153
163
 
154
-
155
164
  ## Changed in v0.1.3
156
165
 
157
166
  #8: wrap `llm/stream` in `ctx.effect` so unload unsubscribes.
@@ -162,3 +171,15 @@ MIT © [GooDAnDReaDY](https://github.com/GooDAnDReaDY)
162
171
  #11: значение `mode` по умолчанию в схеме переведено на `sanitize` для защиты «из коробки».
163
172
  #11: балансировка открывающих и закрывающих тегов в `sanitizeDsmlArtifacts` при наличии закрытых блоков в прозе.
164
173
  #11: гигиена репозитория с отслеживаемым `package-lock.json` и дизайн-контрактом.
174
+
175
+ ## Изменения в v0.1.5
176
+
177
+ #15: исправлен синтаксис стрелок диаграммы Mermaid на GitHub и добавлен обязательный блок поддержки репозитория.
178
+
179
+ ## Изменения в v0.2.0
180
+
181
+ - #17: Нативная карточка настроек в слоте settings.plugin.item со статусом снимка, формой и предупреждением об отключении.
182
+ - #18: Модуль автообновления в 1 клик с эндпоинтом /api/dsh-dsml-artifact-guard/update, сравнением версий и защитой same-origin/loopback.
183
+ - #19: Санитайзинг репозитория от внутреннего AGENTS.md и обновление .gitignore.
184
+ - #20: Оптимизация состава npm-пакета, удаление дубликатов README и сокращение размера пакета на ~40%.
185
+ - #21: Очистка дерева исходников от архивных пакетов .tgz.
@@ -16,11 +16,21 @@
16
16
  </p>
17
17
 
18
18
  <p align="center">
19
- <a href="../README.md"><b>🇬🇧 English</b></a> •
19
+ <a href="README.md"><b>🇬🇧 English</b></a> •
20
20
  <a href="README.ru.md"><b>🇷🇺 Русский</b></a> •
21
21
  <a href="README.zh.md"><b>🇨🇳 中文说明</b></a>
22
22
  </p>
23
23
 
24
+ <table align="center">
25
+ <tr>
26
+ <td align="center">
27
+ ⭐ <strong>如果您喜欢这个插件,请在 GitHub 上为它点亮 Star</strong> — 这能让我知道插件对您有用,并鼓励我继续开发和维护它。
28
+ <br><br>
29
+ 🐛 <strong>如果您发现 Bug 或希望增加功能</strong>,请使用任意语言在 GitHub 上提交 Issue — 我会评估您的建议,并在后续版本中实现有价值的改进。
30
+ </td>
31
+ </tr>
32
+ </table>
33
+
24
34
  </div>
25
35
 
26
36
  ---
@@ -64,15 +74,15 @@ graph TD
64
74
  Audit["审计模式 (Audit)<br/>(仅记录日志不改动文本)"]
65
75
  end
66
76
 
67
- Turn -->|ctx.on('llm/stream')| Hook
77
+ Turn -->|llm/stream 拦截| Hook
68
78
  Hook --> ScopeCheck
69
79
  ScopeCheck -->|未命中| PassThrough
70
80
  ScopeCheck -->|命中| Buffer
71
81
  PassThrough --> ChatUI
72
82
  Buffer --> Detector
73
83
  Detector -->|无异常| ChatUI
74
- Detector -->|检出残留 & sanitize| Sanitize --> ChatUI
75
- Detector -->|检出残留 & audit| Audit --> ChatUI
84
+ Detector -->|检出残留: sanitize| Sanitize --> ChatUI
85
+ Detector -->|检出残留: audit| Audit --> ChatUI
76
86
  ```
77
87
 
78
88
  ---
@@ -151,7 +161,6 @@ npm run check
151
161
 
152
162
  MIT © [GooDAnDReaDY](https://github.com/GooDAnDReaDY)
153
163
 
154
-
155
164
  ## Changed in v0.1.3
156
165
 
157
166
  #8: wrap `llm/stream` in `ctx.effect` so unload unsubscribes.
@@ -162,3 +171,15 @@ MIT © [GooDAnDReaDY](https://github.com/GooDAnDReaDY)
162
171
  #11: 将 schema 中 `mode` 默认值设置为 `sanitize`,实现开箱即用的清理保护。
163
172
  #11: 在 `sanitizeDsmlArtifacts` 中实现严格的开闭标签深度平衡,精准处理正文中已闭合标签与末尾泄漏标签。
164
173
  #11: 纳入 `package-lock.json` 跟踪并完善设计契约。
174
+
175
+ ## v0.1.5 变更说明
176
+
177
+ #15: 修复 GitHub 上 Mermaid 流程图边标签语法解析错误,并补全标准项目支持引导区。
178
+
179
+ ## v0.2.0 更新日志
180
+
181
+ - #17: 在 settings.plugin.item 插槽中注册原生设置卡片,支持快照状态检查与禁用警示横幅。
182
+ - #18: 一键在线更新模块,提供 /api/dsh-dsml-artifact-guard/update 端点与同源/回环安全校验。
183
+ - #19: 清理公开代码仓库中的内部 AGENTS.md 规则文件并更新 .gitignore。
184
+ - #20: 优化 npm 发布包白名单,移除冗余 README 副本,安装包体积缩减约 40%FD。
185
+ - #21: 清理源代码目录中的旧版本 .tgz 打包归档。
package/lib/client.js ADDED
@@ -0,0 +1,562 @@
1
+ window.__ModuleLoader__.load({
2
+ id: '@goodandready/dsh-dsml-artifact-guard',
3
+ factory: (require) => {
4
+ var module = { exports: {} };
5
+ var exports = module.exports;
6
+ Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' });
7
+ const React = require('react');
8
+ const NS = '@goodandready/dsh-dsml-artifact-guard';
9
+
10
+ // Query core chevron primitive when available; fallback SVG otherwise
11
+ let CoreChevron = null;
12
+ try {
13
+ const primitives = require('@deepseek-ai/dsh-client-ui-primitives');
14
+ CoreChevron = primitives && primitives.IconChevronDownOutline14;
15
+ } catch (e) {
16
+ /* Optional core chevron primitive not available; SVG fallback used */
17
+ }
18
+
19
+ const CHEVRON_DOWN_SVG = '<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="6 9 12 15 18 9"></polyline></svg>';
20
+ const CHEVRON_UP_SVG = '<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="18 15 12 9 6 15"></polyline></svg>';
21
+
22
+ const en = {
23
+ title: 'DSML Artifact Guard',
24
+ sub: 'Sanitizes leaked terminal DSML protocol closing tags from model text streams',
25
+ version: 'Version',
26
+ updateChecking: 'Checking updates…',
27
+ updateAvailable: 'Update available: v{v}',
28
+ upToDate: 'Up to date',
29
+ updateNow: 'Update Now',
30
+ updating: 'Updating…',
31
+ updateSuccess: 'Updated to v{v}. Restart DSH service to apply changes.',
32
+ updateFailed: 'Update failed: {msg}',
33
+ checkUpdateFailed: 'Check failed',
34
+ mode: 'Operating Mode',
35
+ modeSanitize: 'Sanitize (strip leaked closing tags)',
36
+ modeAudit: 'Audit (log warnings only, preserve output)',
37
+ modeDisabled: 'Disabled (bypass all streams)',
38
+ providerId: 'Target Provider ID',
39
+ modelId: 'Target Model ID',
40
+ disabledNotice: '⚠️ Guard is currently disabled. DSML closing tags will reach the chat interface.',
41
+ statusUnavailable: 'Settings service unavailable.',
42
+ saving: 'Saving…',
43
+ ready: 'Save Settings',
44
+ saved: 'Settings saved',
45
+ saveError: 'Save failed: {msg}',
46
+ };
47
+
48
+ const zh = {
49
+ title: 'DSML 协议标签守卫',
50
+ sub: '实时侦测并清洗模型文本流中泄漏的 DSML 协议末端闭合标签',
51
+ version: '版本',
52
+ updateChecking: '正在检查更新…',
53
+ updateAvailable: '发现新版本: v{v}',
54
+ upToDate: '已是最新版本',
55
+ updateNow: '立即更新',
56
+ updating: '正在更新…',
57
+ updateSuccess: '已更新至 v{v}。请重启 DSH 服务以生效变更。',
58
+ updateFailed: '更新失败: {msg}',
59
+ checkUpdateFailed: '检查更新失败',
60
+ mode: '运行模式',
61
+ modeSanitize: 'Sanitize 清洗(自动剥除残留闭合标签)',
62
+ modeAudit: 'Audit 审计(仅在日志记录警告,不修改正文)',
63
+ modeDisabled: 'Disabled 禁用(直通所有数据流)',
64
+ providerId: '目标服务商标识 (Provider ID)',
65
+ modelId: '目标模型标识 (Model ID)',
66
+ disabledNotice: '⚠️ 守卫当前已禁用,残留的 DSML 闭合标签将直接显示在聊天界面。',
67
+ statusUnavailable: '配置服务不可用。',
68
+ saving: '正在保存…',
69
+ ready: '保存配置',
70
+ saved: '配置已保存',
71
+ saveError: '保存失败: {msg}',
72
+ };
73
+
74
+ const DEFAULTS = {
75
+ mode: 'sanitize',
76
+ providerId: 'opencode-go',
77
+ modelId: 'deepseek-v4-flash',
78
+ };
79
+
80
+ function PluginCard({ ctx: _ctx, t }) {
81
+ const [expanded, setExpanded] = React.useState(false);
82
+ const [draft, setDraft] = React.useState(DEFAULTS);
83
+ const [status, setStatus] = React.useState('loading');
84
+ const [saving, setSaving] = React.useState(false);
85
+ const [msg, setMsg] = React.useState('');
86
+
87
+ // Updater state
88
+ const [updateState, setUpdateState] = React.useState({
89
+ checking: false,
90
+ updateAvailable: false,
91
+ currentVersion: '0.1.5',
92
+ latestVersion: '',
93
+ updating: false,
94
+ notice: '',
95
+ error: '',
96
+ });
97
+
98
+ const scopeRef = React.useRef(null);
99
+ if (!scopeRef.current && _ctx && _ctx.settingsScope) {
100
+ try {
101
+ scopeRef.current = _ctx.settingsScope.bind({ namespace: NS });
102
+ } catch (e) {
103
+ scopeRef.current = null;
104
+ }
105
+ }
106
+ const scope = scopeRef.current;
107
+
108
+ const currentLang = (_ctx?.locale?.get?.() || navigator.language || 'en').startsWith('zh') ? 'zh' : 'en';
109
+ const dict = currentLang === 'zh' ? zh : en;
110
+ const tt = (k, params) => {
111
+ let str = (typeof t === 'function' ? t(k) : null) || dict[k] || en[k] || k;
112
+ if (params && typeof params === 'object') {
113
+ for (const [pk, pv] of Object.entries(params)) {
114
+ str = str.replace(new RegExp('\\{' + pk + '\\}', 'g'), String(pv));
115
+ }
116
+ }
117
+ return str;
118
+ };
119
+
120
+ // Load settings from snapshot
121
+ React.useEffect(() => {
122
+ if (!scope) {
123
+ setStatus('unavailable');
124
+ return;
125
+ }
126
+ let cancelled = false;
127
+ (async () => {
128
+ try {
129
+ const snap = await scope.get();
130
+ if (cancelled) return;
131
+ if (snap && typeof snap === 'object' && 'status' in snap) {
132
+ if (snap.status === 'ready' && snap.value) {
133
+ setDraft({
134
+ mode: snap.value.mode || DEFAULTS.mode,
135
+ providerId: snap.value.providerId || DEFAULTS.providerId,
136
+ modelId: snap.value.modelId || DEFAULTS.modelId,
137
+ });
138
+ setStatus('ready');
139
+ } else {
140
+ setStatus(snap.status);
141
+ }
142
+ } else if (snap && typeof snap === 'object') {
143
+ setDraft({
144
+ mode: snap.mode || DEFAULTS.mode,
145
+ providerId: snap.providerId || DEFAULTS.providerId,
146
+ modelId: snap.modelId || DEFAULTS.modelId,
147
+ });
148
+ setStatus('ready');
149
+ } else {
150
+ setStatus('ready');
151
+ }
152
+ } catch {
153
+ if (!cancelled) setStatus('unavailable');
154
+ }
155
+ })();
156
+ return () => { cancelled = true; };
157
+ }, [scope]);
158
+
159
+ // Check updater status on mount
160
+ React.useEffect(() => {
161
+ let cancelled = false;
162
+ (async () => {
163
+ try {
164
+ setUpdateState(s => ({ ...s, checking: true, error: '' }));
165
+ const res = await fetch('/api/dsh-dsml-artifact-guard/update');
166
+ if (!res.ok) throw new Error(String(res.status));
167
+ const data = await res.json();
168
+ if (cancelled) return;
169
+ setUpdateState(s => ({
170
+ ...s,
171
+ checking: false,
172
+ currentVersion: data.currentVersion || s.currentVersion,
173
+ latestVersion: data.latestVersion || '',
174
+ updateAvailable: !!data.updateAvailable,
175
+ }));
176
+ } catch {
177
+ if (!cancelled) {
178
+ setUpdateState(s => ({ ...s, checking: false, error: tt('checkUpdateFailed') }));
179
+ }
180
+ }
181
+ })();
182
+ return () => { cancelled = true; };
183
+ }, []);
184
+
185
+ const onTriggerUpdate = async () => {
186
+ if (updateState.updating) return;
187
+ setUpdateState(s => ({ ...s, updating: true, error: '', notice: '' }));
188
+ try {
189
+ const res = await fetch('/api/dsh-dsml-artifact-guard/update', {
190
+ method: 'POST',
191
+ headers: {
192
+ 'content-type': 'application/json',
193
+ 'x-dsh-plugin-update': '1',
194
+ },
195
+ });
196
+ const data = await res.json();
197
+ if (!res.ok || !data.ok && data.error) {
198
+ throw new Error(data.error || String(res.status));
199
+ }
200
+ setUpdateState(s => ({
201
+ ...s,
202
+ updating: false,
203
+ updateAvailable: false,
204
+ currentVersion: data.updatedVersion || s.latestVersion,
205
+ notice: tt('updateSuccess', { v: data.updatedVersion || s.latestVersion }),
206
+ }));
207
+ } catch (err) {
208
+ setUpdateState(s => ({
209
+ ...s,
210
+ updating: false,
211
+ error: tt('updateFailed', { msg: err.message || String(err) }),
212
+ }));
213
+ }
214
+ };
215
+
216
+ const onSave = async () => {
217
+ if (!scope || saving) return;
218
+ setSaving(true);
219
+ setMsg('');
220
+ try {
221
+ const failures = [];
222
+ for (const key of ['mode', 'providerId', 'modelId']) {
223
+ try {
224
+ await scope.set(key, draft[key]);
225
+ } catch (err) {
226
+ failures.push(key + ': ' + (err.message || String(err)));
227
+ }
228
+ }
229
+ if (failures.length > 0) {
230
+ setMsg(tt('saveError', { msg: failures.join(', ') }));
231
+ } else {
232
+ setMsg(tt('saved'));
233
+ setTimeout(() => setMsg(''), 3000);
234
+ }
235
+ } catch (err) {
236
+ setMsg(tt('saveError', { msg: err.message || String(err) }));
237
+ } finally {
238
+ setSaving(false);
239
+ }
240
+ };
241
+
242
+ const isDisabled = draft.mode === 'disabled';
243
+
244
+ return React.createElement('div', {
245
+ 'data-dsh-plugin': NS,
246
+ className: 'dsh-dsml-artifact-guard-card',
247
+ style: {
248
+ borderRadius: 12,
249
+ border: '1px solid var(--dsw-alias-border-l2, rgba(255,255,255,0.08))',
250
+ background: 'var(--dsw-alias-bg-card, rgba(255,255,255,0.02))',
251
+ overflow: 'hidden',
252
+ marginBottom: 12,
253
+ },
254
+ },
255
+ // Header
256
+ React.createElement('div', {
257
+ onClick: () => setExpanded(v => !v),
258
+ style: {
259
+ display: 'flex',
260
+ alignItems: 'center',
261
+ justifyContent: 'space-between',
262
+ padding: '14px 16px',
263
+ cursor: 'pointer',
264
+ userSelect: 'none',
265
+ },
266
+ },
267
+ React.createElement('div', { style: { display: 'flex', flexDirection: 'column', gap: 4 } },
268
+ React.createElement('div', { style: { display: 'flex', alignItems: 'center', gap: 8 } },
269
+ React.createElement('span', { style: { fontSize: 16 } }, '🛡️'),
270
+ React.createElement('span', {
271
+ style: {
272
+ fontSize: 15,
273
+ fontWeight: 600,
274
+ color: 'var(--dsw-alias-label-primary, #f1f5f9)',
275
+ },
276
+ }, tt('title')),
277
+ React.createElement('span', {
278
+ style: {
279
+ fontSize: 11,
280
+ fontWeight: 500,
281
+ padding: '2px 6px',
282
+ borderRadius: 6,
283
+ background: 'rgba(59,130,246,0.15)',
284
+ color: 'var(--dsw-alias-accent-primary, #3b82f6)',
285
+ },
286
+ }, 'v' + updateState.currentVersion),
287
+ isDisabled && React.createElement('span', {
288
+ style: {
289
+ fontSize: 11,
290
+ fontWeight: 600,
291
+ padding: '2px 6px',
292
+ borderRadius: 6,
293
+ background: 'rgba(239,68,68,0.15)',
294
+ color: '#ef4444',
295
+ },
296
+ }, 'OFF')
297
+ ),
298
+ React.createElement('span', {
299
+ style: {
300
+ fontSize: 13,
301
+ color: 'var(--dsw-alias-label-secondary, #94a3b8)',
302
+ },
303
+ }, tt('sub'))
304
+ ),
305
+ React.createElement('div', {
306
+ style: {
307
+ display: 'flex',
308
+ alignItems: 'center',
309
+ color: 'var(--dsw-alias-label-tertiary, #64748b)',
310
+ },
311
+ },
312
+ CoreChevron
313
+ ? React.createElement(CoreChevron, { style: { transform: expanded ? 'rotate(180deg)' : 'none', transition: 'transform 0.2s' } })
314
+ : React.createElement('span', { dangerouslySetInnerHTML: { __html: expanded ? CHEVRON_UP_SVG : CHEVRON_DOWN_SVG } })
315
+ )
316
+ ),
317
+
318
+ // Body
319
+ expanded && React.createElement('div', {
320
+ style: {
321
+ padding: '0 16px 16px',
322
+ borderTop: '1px solid var(--dsw-alias-border-l2, rgba(255,255,255,0.08))',
323
+ },
324
+ },
325
+ // One-Click Updater block
326
+ React.createElement('div', {
327
+ style: {
328
+ padding: '12px 14px',
329
+ margin: '14px 0',
330
+ background: 'rgba(255,255,255,0.03)',
331
+ borderRadius: 8,
332
+ border: '1px solid var(--dsw-alias-border-l2, rgba(255,255,255,0.08))',
333
+ display: 'flex',
334
+ alignItems: 'center',
335
+ justifyContent: 'space-between',
336
+ flexWrap: 'wrap',
337
+ gap: 8,
338
+ },
339
+ },
340
+ React.createElement('div', { style: { fontSize: 13, display: 'flex', alignItems: 'center', gap: 8 } },
341
+ React.createElement('span', { style: { color: 'var(--dsw-alias-label-secondary, #94a3b8)' } },
342
+ tt('version') + ': ' + updateState.currentVersion
343
+ ),
344
+ updateState.checking && React.createElement('span', { style: { color: 'var(--dsw-alias-label-tertiary, #64748b)', fontSize: 12 } },
345
+ tt('updateChecking')
346
+ ),
347
+ !updateState.checking && updateState.updateAvailable && React.createElement('span', { style: { color: '#f59e0b', fontWeight: 600, fontSize: 12 } },
348
+ tt('updateAvailable', { v: updateState.latestVersion })
349
+ ),
350
+ !updateState.checking && !updateState.updateAvailable && !updateState.error && React.createElement('span', { style: { color: '#10b981', fontSize: 12 } },
351
+ '✓ ' + tt('upToDate')
352
+ ),
353
+ updateState.error && React.createElement('span', { style: { color: '#ef4444', fontSize: 12 } },
354
+ updateState.error
355
+ )
356
+ ),
357
+ updateState.updateAvailable && React.createElement('button', {
358
+ onClick: onTriggerUpdate,
359
+ disabled: updateState.updating,
360
+ style: {
361
+ padding: '6px 14px',
362
+ borderRadius: 6,
363
+ background: 'var(--dsw-alias-accent-primary, #3b82f6)',
364
+ color: '#fff',
365
+ border: 0,
366
+ fontSize: 12,
367
+ fontWeight: 600,
368
+ cursor: updateState.updating ? 'default' : 'pointer',
369
+ },
370
+ }, updateState.updating ? tt('updating') : tt('updateNow'))
371
+ ),
372
+ updateState.notice && React.createElement('div', {
373
+ style: {
374
+ padding: '8px 12px',
375
+ marginBottom: 12,
376
+ background: 'rgba(16,185,129,0.1)',
377
+ color: '#10b981',
378
+ borderRadius: 6,
379
+ fontSize: 12,
380
+ border: '1px solid rgba(16,185,129,0.2)',
381
+ },
382
+ }, updateState.notice),
383
+
384
+ // Disabled Warning Banner
385
+ isDisabled && React.createElement('div', {
386
+ style: {
387
+ padding: '8px 12px',
388
+ marginBottom: 14,
389
+ background: 'rgba(239,68,68,0.1)',
390
+ color: '#f87171',
391
+ borderRadius: 6,
392
+ fontSize: 12,
393
+ border: '1px solid rgba(239,68,68,0.2)',
394
+ },
395
+ }, tt('disabledNotice')),
396
+
397
+ // Status Loading / Unavailable
398
+ status === 'loading' && React.createElement('div', {
399
+ style: { padding: '16px 0', fontSize: 13, color: 'var(--dsw-alias-label-tertiary, #64748b)' },
400
+ }, tt('saving')),
401
+ status === 'unavailable' && React.createElement('div', {
402
+ style: { padding: '16px 0', fontSize: 13, color: '#ef4444' },
403
+ }, tt('statusUnavailable')),
404
+
405
+ // Settings Fields
406
+ status === 'ready' && React.createElement('div', { style: { display: 'flex', flexDirection: 'column', gap: 14 } },
407
+ // Mode Select
408
+ React.createElement('div', { style: { display: 'flex', flexDirection: 'column', gap: 6 } },
409
+ React.createElement('label', {
410
+ style: { fontSize: 13, fontWeight: 500, color: 'var(--dsw-alias-label-primary, #f1f5f9)' },
411
+ }, tt('mode')),
412
+ React.createElement('select', {
413
+ value: draft.mode,
414
+ onChange: (e) => setDraft(d => ({ ...d, mode: e.target.value })),
415
+ style: {
416
+ height: 34,
417
+ borderRadius: 6,
418
+ border: '1px solid var(--dsw-alias-border-l2, rgba(255,255,255,0.08))',
419
+ background: 'var(--dsw-alias-bg-field, rgba(0,0,0,0.2))',
420
+ color: 'var(--dsw-alias-label-primary, #f1f5f9)',
421
+ padding: '0 10px',
422
+ fontSize: 13,
423
+ },
424
+ },
425
+ React.createElement('option', { value: 'sanitize' }, tt('modeSanitize')),
426
+ React.createElement('option', { value: 'audit' }, tt('modeAudit')),
427
+ React.createElement('option', { value: 'disabled' }, tt('modeDisabled'))
428
+ )
429
+ ),
430
+
431
+ // Provider ID Field
432
+ React.createElement('div', { style: { display: 'flex', flexDirection: 'column', gap: 6 } },
433
+ React.createElement('label', {
434
+ style: { fontSize: 13, fontWeight: 500, color: 'var(--dsw-alias-label-primary, #f1f5f9)' },
435
+ }, tt('providerId')),
436
+ React.createElement('input', {
437
+ type: 'text',
438
+ value: draft.providerId,
439
+ onChange: (e) => setDraft(d => ({ ...d, providerId: e.target.value })),
440
+ style: {
441
+ height: 34,
442
+ borderRadius: 6,
443
+ border: '1px solid var(--dsw-alias-border-l2, rgba(255,255,255,0.08))',
444
+ background: 'var(--dsw-alias-bg-field, rgba(0,0,0,0.2))',
445
+ color: 'var(--dsw-alias-label-primary, #f1f5f9)',
446
+ padding: '0 10px',
447
+ fontSize: 13,
448
+ },
449
+ })
450
+ ),
451
+
452
+ // Model ID Field
453
+ React.createElement('div', { style: { display: 'flex', flexDirection: 'column', gap: 6 } },
454
+ React.createElement('label', {
455
+ style: { fontSize: 13, fontWeight: 500, color: 'var(--dsw-alias-label-primary, #f1f5f9)' },
456
+ }, tt('modelId')),
457
+ React.createElement('input', {
458
+ type: 'text',
459
+ value: draft.modelId,
460
+ onChange: (e) => setDraft(d => ({ ...d, modelId: e.target.value })),
461
+ style: {
462
+ height: 34,
463
+ borderRadius: 6,
464
+ border: '1px solid var(--dsw-alias-border-l2, rgba(255,255,255,0.08))',
465
+ background: 'var(--dsw-alias-bg-field, rgba(0,0,0,0.2))',
466
+ color: 'var(--dsw-alias-label-primary, #f1f5f9)',
467
+ padding: '0 10px',
468
+ fontSize: 13,
469
+ },
470
+ })
471
+ ),
472
+
473
+ // Actions
474
+ React.createElement('div', {
475
+ style: {
476
+ display: 'flex',
477
+ alignItems: 'center',
478
+ gap: 12,
479
+ paddingTop: 12,
480
+ borderTop: '1px solid var(--dsw-alias-border-l2, rgba(255,255,255,0.08))',
481
+ marginTop: 4,
482
+ },
483
+ },
484
+ React.createElement('button', {
485
+ onClick: onSave,
486
+ disabled: saving || status !== 'ready',
487
+ style: {
488
+ height: 34,
489
+ padding: '0 18px',
490
+ border: 0,
491
+ borderRadius: 8,
492
+ background: 'var(--dsw-alias-accent-primary, #3b82f6)',
493
+ color: '#fff',
494
+ fontSize: 13,
495
+ fontWeight: 600,
496
+ cursor: saving ? 'default' : 'pointer',
497
+ },
498
+ }, saving ? tt('saving') : tt('ready')),
499
+ msg && React.createElement('span', {
500
+ style: {
501
+ fontSize: 13,
502
+ color: msg === tt('saved') ? '#10b981' : '#f87171',
503
+ },
504
+ }, msg)
505
+ )
506
+ )
507
+ )
508
+ );
509
+ }
510
+
511
+ exports.inject = ['slots', 'settingsScope', 'locale'];
512
+ exports.apply = function apply(ctx) {
513
+ if (typeof ctx.locale?.register === 'function') {
514
+ try {
515
+ ctx.locale.register(NS, { en, zh });
516
+ } catch (err) {
517
+ /* Locale namespace already registered or unavailable */
518
+ }
519
+ }
520
+
521
+ function registerSlotWhenReady(slotName, registerFn) {
522
+ if (!ctx.slots) return;
523
+ if (typeof ctx.slots.inject === 'function') {
524
+ try {
525
+ ctx.slots.inject(slotName, () => {
526
+ try {
527
+ return registerFn();
528
+ } catch (err) {
529
+ console.warn('[' + NS + '] Error registering slot ' + slotName + ':', err);
530
+ }
531
+ });
532
+ return;
533
+ } catch (err) {
534
+ console.warn('[' + NS + '] Failed to inject slot ' + slotName + ':', err);
535
+ }
536
+ }
537
+ if (typeof ctx.slots.register === 'function') {
538
+ try {
539
+ registerFn();
540
+ } catch (err) {
541
+ console.warn('[' + NS + '] Failed direct registration for ' + slotName + ':', err);
542
+ }
543
+ }
544
+ }
545
+
546
+ registerSlotWhenReady('settings.plugin.item', () =>
547
+ ctx.slots.register(
548
+ {
549
+ name: 'settings.plugin.item',
550
+ key: NS,
551
+ order: 95,
552
+ locale: NS,
553
+ inject: () => ({ ctx }),
554
+ },
555
+ (props) => React.createElement(PluginCard, { ...props, ctx: (props && props.ctx) || ctx })
556
+ )
557
+ );
558
+ };
559
+
560
+ return module.exports;
561
+ },
562
+ });
package/lib/index.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import Schema from '@deepseek-ai/schemastery';
2
+ import { registerPluginUpdater } from './updater.js';
2
3
 
3
4
  const TAIL = /(?:<\/|DSML|parameter>\s*)?(?:<\/|DSML|invoke>\s*)?<\/|DSML|tool_calls>\s*$/u;
4
5
  const OPEN_TAG = /<|DSML|(?:tool_calls|invoke|parameter)[^>]*>/gu;
@@ -7,7 +8,7 @@ const KEEP = 96;
7
8
  const SCAN_BUFFER_SAFE = 64;
8
9
 
9
10
  export const name = '@goodandready/dsh-dsml-artifact-guard';
10
- export const inject = ['llm'];
11
+ export const inject = ['llm', 'settings', 'webServer'];
11
12
 
12
13
  export const Config = Schema.object({
13
14
  mode: Schema.string().default('sanitize'),
@@ -16,8 +17,9 @@ export const Config = Schema.object({
16
17
  });
17
18
 
18
19
  export function matchesScope(o, c) {
19
- return (o?.providerId ?? o?.provider?.id ?? o?.provider) == c.providerId
20
- && (o?.modelId ?? o?.model?.id ?? o?.model?.name ?? o?.model) == c.modelId;
20
+ const provider = o?.providerId ?? o?.provider?.id ?? o?.provider;
21
+ const model = o?.modelId ?? o?.model?.id ?? o?.model?.name ?? o?.model;
22
+ return provider == c.providerId && model == c.modelId;
21
23
  }
22
24
 
23
25
  export async function* sanitizeDsmlArtifacts(s, { mode = 'sanitize', onArtifact } = {}) {
@@ -90,16 +92,57 @@ export async function* sanitizeDsmlArtifacts(s, { mode = 'sanitize', onArtifact
90
92
  yield* flush(true);
91
93
  }
92
94
 
93
- export function apply(ctx, c = {}) {
94
- const e = Config(c) ?? {};
95
- // #8: dispose with ctx.effect so unload unsubscribes llm/stream
95
+ export function apply(ctx, initialConfig = {}) {
96
+ const NS = '@goodandready/dsh-dsml-artifact-guard';
97
+ let activeConfig = Config(initialConfig) ?? {};
98
+
99
+ let getConfig = () => activeConfig;
100
+
101
+ // Register settings scope inside ctx.effect
102
+ if (typeof ctx.inject === 'function') {
103
+ ctx.inject(['settings'], (sctx) => {
104
+ try {
105
+ const scope = sctx.settings.register(NS, Config, { base: initialConfig });
106
+ getConfig = () => Config(scope?.get() ?? initialConfig) ?? {};
107
+ if (typeof scope?.watch === 'function') {
108
+ sctx.effect(() => scope.watch(() => {
109
+ activeConfig = getConfig();
110
+ }));
111
+ }
112
+ } catch (err) {
113
+ ctx.logger?.warn?.(`[dsh-dsml-artifact-guard] settings registration skipped: ${String(err)}`);
114
+ }
115
+ });
116
+ }
117
+
118
+ // Register one-click updater endpoint
119
+ if (ctx.webServer && typeof ctx.webServer.register === 'function') {
120
+ const mountUpdater = () => registerPluginUpdater(ctx, {
121
+ endpoint: '/api/dsh-dsml-artifact-guard/update',
122
+ packageName: '@goodandready/dsh-dsml-artifact-guard',
123
+ manifestUrl: new URL('../package.json', import.meta.url),
124
+ });
125
+ if (typeof ctx.effect === 'function') {
126
+ ctx.effect(mountUpdater, 'dsh-dsml-artifact-guard: plugin updater');
127
+ } else {
128
+ mountUpdater();
129
+ }
130
+ }
131
+
132
+ // Hook into llm/stream with ctx.effect lifecycle cleanup
96
133
  ctx.effect(() => ctx.on('llm/stream', (o, next) => {
134
+ const current = getConfig();
97
135
  const s = next(o);
98
- return e.mode === 'disabled' || !matchesScope(o, e)
136
+ return current.mode === 'disabled' || !matchesScope(o, current)
99
137
  ? s
100
138
  : sanitizeDsmlArtifacts(s, {
101
- mode: e.mode,
139
+ mode: current.mode,
102
140
  onArtifact: (x) => ctx.logger?.info?.('DSML closing-tag artifact detected', x),
103
141
  });
104
142
  }));
105
143
  }
144
+
145
+ // Preflight helper: verify request method
146
+ export function checkRequestMethod(req, expected = 'POST') {
147
+ return req && req.method === expected;
148
+ }
package/lib/updater.js ADDED
@@ -0,0 +1,267 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { existsSync, readFileSync } from 'node:fs';
3
+ import { readFile } from 'node:fs/promises';
4
+ import { homedir } from 'node:os';
5
+ import { basename, dirname, isAbsolute, resolve } from 'node:path';
6
+ import { fileURLToPath } from 'node:url';
7
+
8
+ const UPDATE_HEADER = 'x-dsh-plugin-update';
9
+ const UPDATE_TIMEOUT_MS = 10 * 60000;
10
+ const VERSION_CACHE_MS = 5 * 60000;
11
+ let latestCache;
12
+
13
+ function header(request, name) {
14
+ const value = request?.headers?.[name];
15
+ return Array.isArray(value) ? value[0] : value;
16
+ }
17
+
18
+ export function isLoopback(value) {
19
+ const address = value?.toLowerCase().replace(/^\[|\]$/g, '');
20
+ return address === 'localhost' || address === 'localhost.' || address === '::1'
21
+ || address?.startsWith('127.') === true
22
+ || address?.startsWith('::ffff:127.') === true;
23
+ }
24
+
25
+ export function isTrustedUpdateRequest(request) {
26
+ if (header(request, UPDATE_HEADER) !== '1') return false;
27
+ if (!isLoopback(request.socket?.remoteAddress)) return false;
28
+ const site = header(request, 'sec-fetch-site');
29
+ if (site !== undefined && site !== 'same-origin') return false;
30
+ const origin = header(request, 'origin');
31
+ const host = header(request, 'host');
32
+ if (origin === undefined || host === undefined) return false;
33
+ try {
34
+ const url = new URL(origin);
35
+ return (url.protocol === 'http:' || url.protocol === 'https:')
36
+ && isLoopback(url.hostname) && url.host === host;
37
+ } catch {
38
+ return false;
39
+ }
40
+ }
41
+
42
+ function validProfileName(value) {
43
+ return typeof value === 'string' && value !== '' && value !== '.' && value !== '..'
44
+ && !value.includes('/') && !value.includes('\\') && !/[\0-\x1f\x7f]/.test(value);
45
+ }
46
+
47
+ function profileNameFromArgv(argv) {
48
+ for (let index = 2; index < argv.length; index += 1) {
49
+ if (argv[index] === '--profile') return argv[index + 1];
50
+ if (argv[index]?.startsWith('--profile=')) return argv[index].slice('--profile='.length);
51
+ }
52
+ return argv[2] === 'web' ? 'web' : undefined;
53
+ }
54
+
55
+ function findDshCliEntry() {
56
+ const value = process.argv[1];
57
+ if (value === undefined || value === '') return undefined;
58
+ const entry = value.startsWith('file:') ? fileURLToPath(value) : resolve(process.cwd(), value);
59
+ if (!existsSync(entry)) return undefined;
60
+ for (let directory = dirname(entry); ; directory = dirname(directory)) {
61
+ const manifestPath = resolve(directory, 'package.json');
62
+ if (existsSync(manifestPath)) {
63
+ try {
64
+ const manifest = JSON.parse(readFileSync(manifestPath, 'utf8'));
65
+ const bin = typeof manifest.bin === 'string'
66
+ ? manifest.bin
67
+ : typeof manifest.bin === 'object' && manifest.bin !== null
68
+ ? manifest.bin.dsh
69
+ : undefined;
70
+ if (manifest.name === '@deepseek-ai/dsh' && typeof bin === 'string'
71
+ && !isAbsolute(bin) && resolve(directory, bin) === resolve(entry)) return entry;
72
+ } catch {
73
+ // Continue searching parent directories
74
+ }
75
+ }
76
+ const parent = dirname(directory);
77
+ if (parent === directory) return undefined;
78
+ }
79
+ }
80
+
81
+ export function runtime() {
82
+ const profileDir = resolve(process.env.DSH_PROFILE_DIR
83
+ ?? resolve(homedir(), '.dsh', 'profiles', 'web'));
84
+ const selected = profileNameFromArgv(process.argv);
85
+ const profileName = validProfileName(selected)
86
+ ? selected
87
+ : validProfileName(basename(profileDir)) ? basename(profileDir) : 'web';
88
+ const cliEntry = findDshCliEntry();
89
+ return cliEntry === undefined ? { profileName, profileDir } : { profileName, profileDir, cliEntry };
90
+ }
91
+
92
+ export function parseSemver(value) {
93
+ const match = /^v?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/.exec(String(value || '').trim());
94
+ if (match === null) return undefined;
95
+ return {
96
+ core: [Number(match[1]), Number(match[2]), Number(match[3])],
97
+ prerelease: match[4]?.split('.') ?? [],
98
+ };
99
+ }
100
+
101
+ function comparePrerelease(left, right) {
102
+ if (left.length === 0 || right.length === 0) return left.length === right.length ? 0 : left.length === 0 ? 1 : -1;
103
+ const length = Math.max(left.length, right.length);
104
+ for (let index = 0; index < length; index += 1) {
105
+ const a = left[index];
106
+ const b = right[index];
107
+ if (a === undefined || b === undefined) return a === b ? 0 : a === undefined ? -1 : 1;
108
+ if (a === b) continue;
109
+ const aNumeric = /^\d+$/.test(a);
110
+ const bNumeric = /^\d+$/.test(b);
111
+ if (aNumeric && bNumeric) {
112
+ const aNumber = BigInt(a);
113
+ const bNumber = BigInt(b);
114
+ if (aNumber !== bNumber) return aNumber > bNumber ? 1 : -1;
115
+ continue;
116
+ }
117
+ if (aNumeric !== bNumeric) return aNumeric ? -1 : 1;
118
+ return a > b ? 1 : -1;
119
+ }
120
+ return 0;
121
+ }
122
+
123
+ export function isNewerVersion(currentValue, candidateValue) {
124
+ const current = parseSemver(currentValue);
125
+ const candidate = parseSemver(candidateValue);
126
+ if (current === undefined || candidate === undefined) return false;
127
+ for (let index = 0; index < 3; index += 1) {
128
+ if (candidate.core[index] !== current.core[index]) return candidate.core[index] > current.core[index];
129
+ }
130
+ return comparePrerelease(candidate.prerelease, current.prerelease) > 0;
131
+ }
132
+
133
+ export async function latestVersion(packageName, registry = 'https://registry.npmjs.org') {
134
+ if (latestCache?.packageName === packageName && latestCache.registry === registry && Date.now() < latestCache.expiresAt) {
135
+ return latestCache.version;
136
+ }
137
+ try {
138
+ const cleanReg = registry.replace(/\/$/, '');
139
+ const response = await fetch(`${cleanReg}/${encodeURIComponent(packageName)}/latest`, {
140
+ signal: AbortSignal.timeout(8000),
141
+ headers: { accept: 'application/json' },
142
+ });
143
+ if (!response.ok) return undefined;
144
+ const value = await response.json();
145
+ if (typeof value.version !== 'string' || value.version === '') return undefined;
146
+ latestCache = { packageName, registry, version: value.version, expiresAt: Date.now() + VERSION_CACHE_MS };
147
+ return value.version;
148
+ } catch {
149
+ return undefined;
150
+ }
151
+ }
152
+
153
+ export async function currentVersion(manifestUrl) {
154
+ const raw = await readFile(manifestUrl, 'utf8');
155
+ const value = JSON.parse(raw);
156
+ if (typeof value.version !== 'string' || value.version === '') throw new Error('Cannot read current plugin version.');
157
+ return value.version;
158
+ }
159
+
160
+ export async function getUpdateStatus(options, target = runtime()) {
161
+ const current = await currentVersion(options.manifestUrl);
162
+ const latest = await latestVersion(options.packageName, options.registry ?? 'https://registry.npmjs.org');
163
+ return {
164
+ packageName: options.packageName,
165
+ currentVersion: current,
166
+ ...(latest === undefined ? {} : { latestVersion: latest }),
167
+ latestCheckFailed: latest === undefined,
168
+ updateAvailable: latest !== undefined && isNewerVersion(current, latest),
169
+ profileName: target.profileName,
170
+ canAutoUpdate: target.cliEntry !== undefined,
171
+ };
172
+ }
173
+
174
+ export async function installExact(target, packageSpec, options = {}) {
175
+ if (target.cliEntry === undefined) throw new Error('Automatic update is unavailable in this runtime.');
176
+ return new Promise((resolvePromise, reject) => {
177
+ const args = [
178
+ target.cliEntry, 'plugin', '--profile', target.profileName, 'add',
179
+ packageSpec,
180
+ `--registry=${options.registry ?? 'https://registry.npmjs.org/'}`,
181
+ ];
182
+ const child = spawn(process.execPath, args, {
183
+ cwd: target.profileDir,
184
+ windowsHide: true,
185
+ stdio: ['ignore', 'pipe', 'pipe'],
186
+ env: { ...process.env, NO_COLOR: '1' },
187
+ });
188
+ let detail = '';
189
+ child.stdout?.on('data', chunk => { detail = (detail + String(chunk)).slice(-4000); });
190
+ child.stderr?.on('data', chunk => { detail = (detail + String(chunk)).slice(-4000); });
191
+ const timer = setTimeout(() => {
192
+ child.kill();
193
+ reject(new Error('Update timed out; use the normal DSH update flow.'));
194
+ }, UPDATE_TIMEOUT_MS);
195
+ child.once('error', error => { clearTimeout(timer); reject(error); });
196
+ child.once('exit', code => {
197
+ clearTimeout(timer);
198
+ if (code === 0) resolvePromise();
199
+ else reject(new Error(detail.trim() || `Update exited with code ${String(code)}.`));
200
+ });
201
+ });
202
+ }
203
+
204
+ export function registerPluginUpdater(ctx, options) {
205
+ let installing = false;
206
+ return ctx.webServer.register({
207
+ kind: 'exact',
208
+ path: options.endpoint || '/api/dsh-dsml-artifact-guard/update',
209
+ handler: async (req, res) => {
210
+ try {
211
+ const target = runtime();
212
+ if (req.method === 'GET' || req.method === 'HEAD') {
213
+ const payload = await getUpdateStatus(options, target);
214
+ res.writeHead(200, {
215
+ 'content-type': 'application/json; charset=utf-8',
216
+ 'cache-control': 'no-store',
217
+ });
218
+ res.end(req.method === 'HEAD' ? undefined : JSON.stringify(payload));
219
+ return;
220
+ }
221
+ if (req.method !== 'POST') {
222
+ res.writeHead(405, { allow: 'GET, HEAD, POST' });
223
+ res.end();
224
+ return;
225
+ }
226
+ if (!isTrustedUpdateRequest(req)) {
227
+ res.writeHead(403, { 'content-type': 'application/json; charset=utf-8' });
228
+ res.end(JSON.stringify({ ok: false, error: 'Untrusted or cross-origin request rejected.' }));
229
+ return;
230
+ }
231
+ if (installing) {
232
+ res.writeHead(409, { 'content-type': 'application/json; charset=utf-8' });
233
+ res.end(JSON.stringify({ ok: false, error: 'This plugin is already updating.' }));
234
+ return;
235
+ }
236
+ installing = true;
237
+ try {
238
+ const before = await getUpdateStatus(options, target);
239
+ if (before.latestVersion === undefined) {
240
+ res.writeHead(503, { 'content-type': 'application/json; charset=utf-8' });
241
+ res.end(JSON.stringify({ ok: false, error: 'The latest version is temporarily unavailable.' }));
242
+ return;
243
+ }
244
+ if (!before.updateAvailable) {
245
+ res.writeHead(200, { 'content-type': 'application/json; charset=utf-8' });
246
+ res.end(JSON.stringify({ ...before, updated: false, message: 'Already up to date.' }));
247
+ return;
248
+ }
249
+ await installExact(target, `${options.packageName}@${before.latestVersion}`, options);
250
+ res.writeHead(200, { 'content-type': 'application/json; charset=utf-8' });
251
+ res.end(JSON.stringify({
252
+ ...before,
253
+ updated: true,
254
+ updatedVersion: before.latestVersion,
255
+ restartRequired: true,
256
+ }));
257
+ } finally {
258
+ installing = false;
259
+ }
260
+ } catch (error) {
261
+ (ctx.logger || console).warn?.(`[dsh-dsml-artifact-guard] plugin updater failed: ${String(error)}`);
262
+ res.writeHead(503, { 'content-type': 'application/json; charset=utf-8' });
263
+ res.end(JSON.stringify({ ok: false, error: 'Plugin update failed; see server logs.' }));
264
+ }
265
+ },
266
+ });
267
+ }
package/package.json CHANGED
@@ -1,15 +1,16 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-dsml-artifact-guard",
3
- "version": "0.1.4",
3
+ "version": "0.2.0",
4
4
  "description": "Sanitizes leaked DeepSeek DSML closing tags from model text streams",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
7
7
  "license": "MIT",
8
8
  "files": [
9
- "lib",
9
+ "lib/*.js",
10
10
  "cordis.patch.yml",
11
11
  "README.md",
12
- "docs",
12
+ "README.ru.md",
13
+ "README.zh.md",
13
14
  "LICENSE"
14
15
  ],
15
16
  "scripts": {
@@ -19,10 +20,24 @@
19
20
  "dsh": {
20
21
  "bundle": {
21
22
  "patch": "./cordis.patch.yml"
23
+ },
24
+ "client": {
25
+ "platform": "web",
26
+ "inject": [
27
+ "@deepseek-ai/dsh-client-locale",
28
+ "@deepseek-ai/dsh-client-ui-slots",
29
+ "@deepseek-ai/dsh-client-ui-settings"
30
+ ]
22
31
  }
23
32
  },
24
33
  "peerDependencies": {
25
34
  "@deepseek-ai/cordis": "^4.0.1",
26
35
  "@deepseek-ai/schemastery": "^3.18.1"
36
+ },
37
+ "exports": {
38
+ ".": "./lib/index.js",
39
+ "./client": "./lib/client.js",
40
+ "./cordis.patch.yml": "./cordis.patch.yml",
41
+ "./package.json": "./package.json"
27
42
  }
28
43
  }
@@ -1,36 +0,0 @@
1
- # Design Contract: dsh-dsml-artifact-guard
2
-
3
- ## 1. Product Purpose & User Scenarios
4
- - **Purpose**: Host-only streaming guard for DeepSeek Harness that intercepts assistant stream chunks and strips leaked DSML closing tags (`</|DSML|parameter>`, `</|DSML|invoke>`, `</|DSML|tool_calls>`) emitted by upstream providers before text reaches the user interface.
5
- - **Primary Scenarios**:
6
- - Assistant responds through an upstream gateway exhibiting DSML leakage (e.g. `opencode-go` + `deepseek-v4-flash`). Leaked protocol closing tags at the end of the response are stripped automatically.
7
- - User and assistant discuss DSML syntax or generate markdown/code examples with DSML tags. Legitimate open tags and their matching closing tags are preserved intact (fail-open).
8
- - Out-of-scope providers/models pass through with zero buffering or regex evaluation.
9
-
10
- ## 2. User Surfaces
11
- - **Web UI**: UI отсутствует на текущем этапе (host-only middleware plugin).
12
- - **DSH Configuration**:
13
- - `mode`: `"sanitize"` (default, strips tags), `"audit"` (logs to `ctx.logger.info`), `"disabled"` (bypasses).
14
- - `providerId`: target provider (default `"opencode-go"`).
15
- - `modelId`: target model (default `"deepseek-v4-flash"`).
16
- - **Documentation**:
17
- - Full trilingual documentation: `README.md` (EN), `docs/README.ru.md` (RU), `docs/README.zh.md` (ZH).
18
-
19
- ## 3. Streaming & Buffering Contract
20
- - **Synchronous Hook Contract**: The `llm/stream` listener returns an `AsyncIterable` synchronously. Returning a `Promise` breaks the Cordis event dispatcher.
21
- - **Sliding Buffer (KEEP = 96)**: Preserves the terminal window across chunk splits.
22
- - **Tag Balancing**: Tracks opening and closing DSML tags across the stream so that only orphaned closing tags at the terminal tail are sanitized, leaving balanced code blocks untouched.
23
-
24
- ## 4. Do / Don't
25
- - **Do**:
26
- - Preserve fail-open guarantees for all user prose.
27
- - Dispose hook registration with `ctx.effect`.
28
- - Pass non-target provider/model streams directly through.
29
- - **Don't**:
30
- - Do not make the stream hook async.
31
- - Do not drop legitimate DSML discussion in chat.
32
- - Do not add heavy client-side UI for simple host middleware.
33
-
34
- ## 5. Decisions Log
35
- - **2026-09-06**: Added `ctx.effect` lifecycle wrapper and switched patch to schema-driven defaults (`#8, #9`).
36
- - **2026-09-12**: Changed schema default mode to `sanitize` for out-of-the-box protection, implemented robust open/close tag balancing across streaming chunks, and tracked `package-lock.json` (`#11`).