dsh-github-router 0.1.0 → 0.2.1

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
@@ -5,7 +5,19 @@ All notable changes to this project are documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
- ## [Unreleased]
8
+ ## [0.2.1] - 2026-08-28
9
+
10
+ ### Changed
11
+
12
+ - Declare compatibility for dsh 0.1.1-rc.2
13
+
14
+ ## [0.2.0] - 2026-08-21
15
+
16
+ ### Changed
17
+
18
+ - **Migrated plugin configuration to the framework's plugin-settings mechanism** (requires DSH ≥ 0.1.0-rc.7, which serves every registered settings namespace and dispatches plugin-owned configuration cards). The browser half now registers a `settings.plugin.item` card keyed by the `dsh-github-router` namespace inside Settings → Plugins, bound through `ctx.settingsScope` (shared describe mirror, revision fencing, recovery reads, reconnect invalidation); the plugin-owned `/dsh-github-router/config` web routes and the standalone `settings.section` page are removed. The Host keeps the same `settings.register` seam and adds `applies: 'live'`.
19
+ - The token control is now a true write-only field: the configured state comes from the describe mirror's secret slot list (badge: "已配置/configured"), a typed value writes the token, and a blank save clears a configured one.
20
+ - Runtime requirements: the settings card needs DSH ≥ 0.1.0-rc.7; the Host-side settings seam alone still works on older compositions (tools resolve the composition config as before).
9
21
 
10
22
  ## [0.1.0] - 2026-08-19
11
23
 
package/README.md CHANGED
@@ -76,13 +76,14 @@ Behavior notes:
76
76
 
77
77
  ## Configuration
78
78
 
79
- Settings → **GitHub Router** opens an independent settings page (a nav
80
- entry registered into `settings.section`, the same mechanism as the 通知
81
- section): edits are staged locally and written only on save, fields
82
- overridden by the user are badged, and blank fields fall back to the
83
- defaults below. The same values can be set in the composition (profile
84
- `cordis.patch.yml`) as the plugin's base config; the Settings UI overrides
85
- per user.
79
+ Settings → **Plugins** shows the **GitHub Router** card on the configurable
80
+ tab (the framework's `settings.plugin.item` card slot keyed by the settings
81
+ namespace; requires DSH ≥ 0.1.0-rc.7): edits are staged locally and written
82
+ only on save, fields overridden by the user are badged, and blank fields
83
+ fall back to the defaults below. The token is a write-only field — a blank
84
+ save clears a configured token. The same values can be set in the
85
+ composition (profile `cordis.patch.yml`) as the plugin's base config; the
86
+ Settings UI overrides per user.
86
87
 
87
88
  | Field | Default | Meaning |
88
89
  | --- | --- | --- |
@@ -112,8 +113,8 @@ per user.
112
113
  | Path | Purpose |
113
114
  | ---- | ------- |
114
115
  | `cordis.patch.yml` | Profile patch layer inserting the `dsh-github-router` row |
115
- | `lib/index.js` | Host plugin: settings section, five tools, skill, guidance |
116
- | `lib/client.js` | Browser half: the independent Settings page (hand-written factory bundle, no build step) |
116
+ | `lib/index.js` | Host plugin: settings namespace, five tools, skill, guidance |
117
+ | `lib/client.js` | Browser half: the settings card (hand-written factory bundle, no build step) |
117
118
  | `lib/config.js` | Settings schema, defaults, runtime option resolution |
118
119
  | `lib/net.js`, `lib/tunnel.js` | Route-aware HTTP layer; zero-dependency CONNECT proxy tunnel |
119
120
  | `lib/routes/` | One module per route: `api` (GET-only REST), `gh` (CLI), `git` (protocol), `html` (page parse), `mirror` (raw mirrors) |
package/README.zh.md CHANGED
@@ -15,7 +15,7 @@
15
15
  - **一次调用、结构化结果** —— PR 一次返回元数据、讨论、评审、提交、变更文件与 diff,每个部件标注来源路由;失败时返回路由矩阵,而不是十几条重试过的终端命令。
16
16
  - **连通性探针** —— `github_probe` 一次调用报告宿主侧哪些路由存活,附耗时与推荐路由链。
17
17
  - **零运行时依赖** —— peer 依赖由 DSH profile 解析;代理请求走插件自带的 CONNECT 隧道(不依赖第三方 HTTP 栈),因此可以完全离线安装。
18
- - **独立设置页** —— 设置中出现独立的「GitHub 路由」入口(与「通知」同机制):`dsh-github-router` 设置命名空间(secret token、代理、路由开关、缓存 TTL、字节上限),编辑暂存、保存/放弃、覆盖徽标。
18
+ - **框架级插件设置卡片** —— 设置 → **Plugins(插件)** 的可配置页中出现「GitHub 路由」卡片(框架的 `settings.plugin.item` 机制,按设置命名空间配对,需 DSH ≥ 0.1.0-rc.7):`dsh-github-router` 设置命名空间(secret token、代理、路由开关、缓存 TTL、字节上限),编辑暂存、保存/放弃、覆盖徽标、token 已配置徽标。
19
19
  - **上下文效率** —— 按类别 TTL 的响应缓存、全局字节上限、带截断注记的列表上限,以及限流余量提示。
20
20
 
21
21
  ## 环境要求
@@ -78,11 +78,12 @@ github_api { path: "/repos/o/r/commits", query: { per_page: 5 } }
78
78
 
79
79
  ## 配置
80
80
 
81
- 设置 → **GitHub 路由** 打开独立设置页(注册进 `settings.section` 的导航
82
- 入口,与「通知」区块同机制):编辑内容先本地暂存、点保存才落盘,被用户
83
- 覆盖的字段带标记,留空字段回退到下方默认值。同样的值也可以在组合
84
- (profile 的 `cordis.patch.yml`)中作为插件基础配置写入;设置 UI 按用户
85
- 覆盖。
81
+ 设置 → **Plugins(插件)** → 可配置页中的「GitHub 路由」卡片(注册进
82
+ `settings.plugin.item` 槽、以设置命名空间为 key 的框架插件卡片,需要
83
+ DSH ≥ 0.1.0-rc.7):编辑内容先本地暂存、点保存才落盘,被用户覆盖的
84
+ 字段带标记,留空字段回退到下方默认值;token 为只写字段,留空保存会
85
+ 清除已配置的 token。同样的值也可以在组合(profile 的
86
+ `cordis.patch.yml`)中作为插件基础配置写入;设置 UI 按用户覆盖。
86
87
 
87
88
  | 字段 | 默认 | 含义 |
88
89
  | --- | --- | --- |
package/docs/design.md CHANGED
@@ -107,44 +107,50 @@ substitute bytes. When enabled, each configured base yields two candidates
107
107
  per file (raw passthrough and the `github.com` `/raw/` route), tried in
108
108
  order.
109
109
 
110
- ## Settings page
110
+ ## Settings card
111
111
 
112
112
  The Host registers the `dsh-github-router` settings namespace on the
113
113
  official settings seam (durable document, schema validation, revision
114
- fencing); the browser half (`lib/client.js`, a hand-written ModuleLoader
115
- factory bundle with no build step) registers an INDEPENDENT settings nav
116
- entry into the `settings.section` slot — the same mechanism
117
- dsh-notification uses.
118
-
119
- ### Framework limitation: the settings exposure allowlist
120
-
121
- The framework's api-proxy serves settings namespaces to configuration
122
- clients ONLY through its hardcoded allowlist (`WEB_SETTINGS_NAMESPACES` /
123
- `PRODUCT_SETTINGS_NAMESPACES` / model-provider namespaces in
124
- `dsh-host-apiproxy`) — a third-party namespace answers
125
- `settings-not-exposed` on both reads and writes even when registered, and
126
- the browser `settingsScope` therefore reports it `unavailable`. Exposing a
127
- namespace from `settings.register()` is explicitly deferred framework
128
- work.
129
-
130
- ### Plugin-owned configuration routes
131
-
132
- The channel is the dsh-market pattern: the Host mounts its own routes on
133
- the shared web server (`ctx.webServer.register`, prefix
134
- `/dsh-github-router`) and the browser page talks to them with plain
135
- same-origin fetch — no typert, no gateway, no allowlist. The routes are
136
- backed by the SAME settings seam (`lib/remote.js`): GET returns a redacted
137
- view (`value`/`base`/`user`/`revision`/`writable`, secrets stripped); POST
138
- applies single-field `set`/`unset` ops with revision fencing (conflicts
139
- answer 409 and the client reloads the view); writes require same-origin
140
- POSTs and are body-capped. The page's row activates on the
141
- notification-proven service set (`slots`, `locale`).
142
-
143
- Because the routes write **scalar fields by name**, the settings schema
144
- is deliberately flat (`routesApi`, `cacheTtlMeta`, …); the Host projects
145
- it into the nested runtime shape (`routes.api`, `cacheTtlSeconds.meta`) in
114
+ fencing, `applies: 'live'`). The browser half (`lib/client.js`, a
115
+ hand-written ModuleLoader factory bundle with no build step) registers one
116
+ plugin configuration CARD into the `settings.plugin.item` slot keyed by the
117
+ namespace — the framework's mechanism for plugins distributed outside the
118
+ repository (DSH ≥ 0.1.0-rc.7): the Settings → Plugins configurable tab
119
+ reads which namespaces the Host serves and dispatches the intersection of
120
+ that ledger and the registered cards, so the card appears only when the
121
+ namespace is registered and served.
122
+
123
+ ### Framework transport
124
+
125
+ Since DSH 0.1.0-rc.7 the api-proxy serves every registered settings
126
+ namespace (the earlier `WEB_SETTINGS_NAMESPACES` allowlist and its
127
+ `settings-not-exposed` answer retired in PR #2404), so the card binds the
128
+ namespace through the framework settings transport:
129
+ `ctx.settingsScope.bind({ namespace })` returns a `SettingsScope` whose
130
+ snapshot carries the redacted resolved section
131
+ (`status`/`value`/`base`/`user`/`revision`/`writable`). All consumers
132
+ derive from one shared describe mirror, so reads never block activation,
133
+ writes carry revision fencing and recovery re-reads, and the mirror
134
+ refreshes on `settings/document-updated` and `connection/reset` — a Host
135
+ restart no longer strands the form in a failed state. No plugin-owned HTTP
136
+ route exists.
137
+
138
+ ### Card form
139
+
140
+ Edits are staged locally and written only on save, one field per
141
+ `scope.set`/`scope.unset`; after each write the card verifies the user
142
+ layer (JSON-shaped deep equality) and keeps drafts that did not land.
143
+ Secret fields never ride a response, so the token control is write-only:
144
+ its configured state comes from the describe mirror's secret slot list
145
+ (the snapshot itself redacts it from every layer), a typed value writes the
146
+ token through `settings.mutate` (the one direction secrets cross the wire),
147
+ and a blank draft clears a configured one.
148
+
149
+ Because the scope writes **scalar fields by name**, the settings schema is
150
+ deliberately flat (`routesApi`, `cacheTtlMeta`, …); the Host projects it
151
+ into the nested runtime shape (`routes.api`, `cacheTtlSeconds.meta`) in
146
152
  `resolveOptions`. The composition layer can still carry the same flat
147
- keys. The page shows the common fields up top (token, proxy, main route
153
+ keys. The card shows the common fields up top (token, proxy, main route
148
154
  switches) and the long tail (timeouts, retries, cache TTLs, mirrors,
149
155
  repos, git cache dir) in a collapsed "Advanced settings" disclosure.
150
156
 
package/lib/client.js CHANGED
@@ -1,18 +1,22 @@
1
1
  /**
2
2
  * dsh-github-router — client half (hand-written factory bundle, no build step).
3
3
  *
4
- * One INDEPENDENT settings page (Settings → "GitHub 路由"), registered into
5
- * the `settings.section` slot exactly like dsh-notification does. The
6
- * configuration channel is the dsh-market pattern: the Host mounts
7
- * `/dsh-github-router/config` on the shared web server and this page talks
8
- * to it with plain same-origin fetch (GET = redacted view, POST = staged
9
- * writes with revision fencing). Edits are staged locally and written only
10
- * on save; a rejected write reloads the view so the next save fences
11
- * correctly.
4
+ * One plugin configuration CARD inside the framework's Settings → Plugins →
5
+ * configurable tab, registered into the `settings.plugin.item` slot keyed by
6
+ * the settings namespace — the framework mechanism for plugins distributed
7
+ * outside the repository (DSH ≥ 0.1.0-rc.7): the Host registers the
8
+ * namespace, the browser registers the card under the same key, and the tab
9
+ * pairs the two. No plugin-owned HTTP route exists anymore; reads and writes
10
+ * ride the framework settings transport (`ctx.settingsScope`).
12
11
  *
13
- * Layout: the common fields (token, proxy, route switches) sit up top; the
14
- * long tail (timeouts, retries, cache TTLs, mirrors, repos, git cache dir)
15
- * lives in a collapsed "Advanced settings" disclosure below.
12
+ * The form keeps the staged-editing model: edits are staged locally and
13
+ * written only on save; each write goes through the bound settings scope
14
+ * (revision fencing, recovery reads on failure) and the card verifies the
15
+ * user layer afterwards, keeping drafts that did not land. Secret fields
16
+ * (the GitHub token) never ride a response, so the token control is
17
+ * write-only: it starts blank, a typed value writes the token, and a blank
18
+ * draft clears a configured one — the configured state comes from the
19
+ * describe mirror's secret slot list.
16
20
  *
17
21
  * The module requires only `react` and
18
22
  * `@deepseek-ai/dsh-client-runtime/client` (the browser subpath — the bare
@@ -27,10 +31,10 @@ window.__ModuleLoader__.load({
27
31
  var React = require('react');
28
32
  var runtime = require('@deepseek-ai/dsh-client-runtime/client');
29
33
 
34
+ var NAMESPACE = 'dsh-github-router';
35
+ var NS = 'dsh-github-router';
36
+
30
37
  // ------------------------------------------------------------ specs
31
- // Flat scalar fields only: the client settings scope writes one field
32
- // per call (`scope.set(field, value)`), so nested schema objects are
33
- // deliberately absent from this page.
34
38
  function textSpec(field) {
35
39
  return {
36
40
  field: field,
@@ -83,7 +87,7 @@ window.__ModuleLoader__.load({
83
87
 
84
88
  // ------------------------------------------------------ field catalog
85
89
  var PRIMARY_FIELDS = [
86
- { field: 'token', kind: 'text', spec: textSpec('token'), labelKey: 'field.token', hintKey: 'hint.token' },
90
+ { field: 'token', kind: 'secret', spec: textSpec('token'), labelKey: 'field.token', hintKey: 'hint.token' },
87
91
  { field: 'tokenEnv', kind: 'text', spec: textSpec('tokenEnv'), labelKey: 'field.tokenEnv', hintKey: 'hint.tokenEnv' },
88
92
  { field: 'proxy', kind: 'text', spec: textSpec('proxy'), labelKey: 'field.proxy', hintKey: 'hint.proxy' },
89
93
  { field: 'routesApi', kind: 'bool', spec: boolSpec('routesApi'), labelKey: 'field.routesApi', hintKey: null },
@@ -108,12 +112,10 @@ window.__ModuleLoader__.load({
108
112
  FIELDS.forEach(function (f) { SPECS[f.field] = f.spec; });
109
113
 
110
114
  // ------------------------------------------------------------- i18n
111
- var NS = 'dsh-github-router';
112
115
  var zhDict = {
113
116
  'nav': 'GitHub 路由',
114
117
  'description': 'GitHub 读取的路由与缓存配置。',
115
118
  'advanced': '高级设置',
116
- 'unavailable': '无法从宿主加载配置。',
117
119
  'save': '保存',
118
120
  'saving': '保存中…',
119
121
  'discard': '放弃修改',
@@ -121,6 +123,7 @@ window.__ModuleLoader__.load({
121
123
  'readOnly': '当前部署不允许修改设置。',
122
124
  'invalid': '无效值',
123
125
  'overridden': '已覆盖',
126
+ 'configured': '已配置',
124
127
  'field.token': 'GitHub Token',
125
128
  'field.tokenEnv': 'Token 环境变量',
126
129
  'field.proxy': '代理地址',
@@ -138,7 +141,7 @@ window.__ModuleLoader__.load({
138
141
  'field.mirrors': '镜像基址(逗号分隔)',
139
142
  'field.repos': '本地仓库白名单(逗号分隔)',
140
143
  'field.gitCacheDir': 'git 缓存目录',
141
- 'hint.token': '留空表示清除;优先使用 tokenEnv。',
144
+ 'hint.token': '仅写入:留空并保存 = 清除已配置的 token;优先使用 tokenEnv。',
142
145
  'hint.tokenEnv': '指向 token 的环境变量名,例如 GITHUB_TOKEN。',
143
146
  'hint.proxy': '空 = 继承环境代理;direct = 永不代理;其余为代理 URL。',
144
147
  'hint.maxBytes': '每个响应体的字节上限(16384..8388608)。',
@@ -153,7 +156,6 @@ window.__ModuleLoader__.load({
153
156
  'nav': 'GitHub Router',
154
157
  'description': 'Routing and cache configuration for GitHub reads.',
155
158
  'advanced': 'Advanced settings',
156
- 'unavailable': 'The configuration could not be loaded from the host.',
157
159
  'save': 'Save',
158
160
  'saving': 'Saving…',
159
161
  'discard': 'Discard',
@@ -161,6 +163,7 @@ window.__ModuleLoader__.load({
161
163
  'readOnly': 'This deployment does not allow settings edits.',
162
164
  'invalid': 'invalid',
163
165
  'overridden': 'overridden',
166
+ 'configured': 'configured',
164
167
  'field.token': 'GitHub token',
165
168
  'field.tokenEnv': 'Token env var',
166
169
  'field.proxy': 'Proxy URL',
@@ -178,7 +181,7 @@ window.__ModuleLoader__.load({
178
181
  'field.mirrors': 'Mirror bases (comma-separated)',
179
182
  'field.repos': 'Local repo allowlist (comma-separated)',
180
183
  'field.gitCacheDir': 'Git cache directory',
181
- 'hint.token': 'Blank clears the value; prefer tokenEnv.',
184
+ 'hint.token': 'Write-only: a blank save clears the configured token; prefer tokenEnv.',
182
185
  'hint.tokenEnv': 'Environment variable naming the token, e.g. GITHUB_TOKEN.',
183
186
  'hint.proxy': "Empty = inherit ambient proxy env; 'direct' = never proxy; anything else is a proxy URL.",
184
187
  'hint.maxBytes': 'Byte cap per response body (16384..8388608).',
@@ -191,100 +194,22 @@ window.__ModuleLoader__.load({
191
194
  };
192
195
 
193
196
  // ------------------------------------------------------------- form
194
- /**
195
- * The configuration channel is the dsh-market pattern: the Host mounts
196
- * `/dsh-github-router/config` on the shared web server, and this page
197
- * uses plain same-origin fetch (GET = redacted view, POST = writes).
198
- * The framework's settings scope is NOT used — its api-proxy serves
199
- * settings namespaces through a hardcoded allowlist that third-party
200
- * plugins cannot join.
201
- */
202
-
203
- /** One `/dsh-github-router/config` call (GET or POST). */
204
- function configCall(payload) {
205
- var init = payload === undefined
206
- ? { method: 'GET', cache: 'no-store' }
207
- : {
208
- method: 'POST',
209
- cache: 'no-store',
210
- headers: { 'content-type': 'application/json' },
211
- body: JSON.stringify(payload),
212
- };
213
- return fetch('/dsh-github-router/config', init).then(function (response) {
214
- return response.json().then(function (envelope) {
215
- if (!envelope || envelope.ok !== true) {
216
- var message = envelope && envelope.error !== undefined ? String(envelope.error) : 'config call failed (HTTP ' + response.status + ')';
217
- throw new Error('dsh-github-router: ' + message);
218
- }
219
- return envelope.value;
220
- });
221
- });
197
+ /** JSON-shaped deep equality for verifying writes against the user layer. */
198
+ function sameJson(a, b) {
199
+ return JSON.stringify(a) === JSON.stringify(b);
222
200
  }
223
201
 
224
- /** Scope adapter over the plugin routes: same interface the form uses. */
225
- function RemoteScope() {
226
- var listeners = new Set();
227
- var snapshot = { status: 'loading', value: {}, base: {}, user: {}, revision: undefined, writable: false, mode: 'host' };
228
-
229
- function publish() {
230
- listeners.forEach(function (fn) { fn(); });
231
- }
232
- function accept(view) {
233
- snapshot = {
234
- status: 'ready',
235
- value: view && typeof view.value === 'object' ? view.value : {},
236
- base: view && typeof view.base === 'object' ? view.base : {},
237
- user: view && typeof view.user === 'object' ? view.user : {},
238
- revision: view && view.revision,
239
- writable: !!(view && view.writable),
240
- mode: 'host',
241
- };
242
- publish();
243
- }
244
- function fail(message) {
245
- snapshot = { status: 'unavailable', value: {}, base: {}, user: {}, revision: undefined, writable: false, mode: 'memory' };
246
- console.error('[dsh-github-router] config load failed', message);
247
- publish();
248
- }
249
- function load() {
250
- return configCall().then(accept, fail);
251
- }
252
- function write(op) {
253
- return configCall({ ops: [op], expectedRevision: snapshot.revision }).then(accept, function (error) {
254
- // A rejected write may mean the document moved (revision conflict)
255
- // or storage failed — re-read so the next save fences correctly.
256
- return load().then(function () { throw error; });
257
- });
258
- }
259
-
260
- load();
261
- return {
262
- getSnapshot: function () { return snapshot; },
263
- subscribe: function (fn) {
264
- listeners.add(fn);
265
- return function () { listeners.delete(fn); };
266
- },
267
- set: function (field, value) {
268
- return write({ op: 'set', path: [field], value: value });
269
- },
270
- unset: function (field) {
271
- return write({ op: 'unset', path: [field] });
272
- },
273
- };
274
- }
275
-
276
- /** A static scope stub for environments without fetch. */
277
- function unavailableScope() {
278
- var snapshot = { status: 'unavailable', value: {}, base: {}, user: {}, revision: undefined, writable: false, mode: 'memory' };
279
- return {
280
- getSnapshot: function () { return snapshot; },
281
- subscribe: function () { return function () {}; },
282
- set: function () { return Promise.resolve(); },
283
- unset: function () { return Promise.resolve(); },
284
- };
285
- }
286
-
287
- function GithubRouterForm(scope) {
202
+ /**
203
+ * Stages one card's edits over the plugin's settings namespace and writes
204
+ * them on save. The scope is the framework-bound settings scope
205
+ * (`ctx.settingsScope.bind({ namespace })`): its snapshot carries the
206
+ * redacted resolved section (`status`/`value`/`base`/`user`/`revision`/
207
+ * `writable`), and `set`/`unset` write one field each with revision
208
+ * fencing and recovery reads. The describe mirror supplements the
209
+ * snapshot with the secret slot list, the only signal that a redacted
210
+ * field (the token) is configured.
211
+ */
212
+ function GithubRouterForm(scope, mirror) {
288
213
  var staged = new Map(); // field -> { text, clear }
289
214
  var saving = false;
290
215
  var failed = false;
@@ -311,6 +236,17 @@ window.__ModuleLoader__.load({
311
236
  var s = snap();
312
237
  return !!(s.user && typeof s.user === 'object' && Object.prototype.hasOwnProperty.call(s.user, field));
313
238
  }
239
+ /** Whether a redacted secret slot holds a value (from the describe mirror). */
240
+ function secretSet(field) {
241
+ if (mirror === null || typeof mirror.namespace !== 'function') return false;
242
+ var row = mirror.namespace(NAMESPACE);
243
+ if (row === undefined || !Array.isArray(row.secrets)) return false;
244
+ for (var i = 0; i < row.secrets.length; i++) {
245
+ var slot = row.secrets[i];
246
+ if (Array.isArray(slot.path) && slot.path.length === 1 && slot.path[0] === field) return slot.set === true;
247
+ }
248
+ return false;
249
+ }
314
250
 
315
251
  function publish() {
316
252
  var s = snap();
@@ -323,14 +259,26 @@ window.__ModuleLoader__.load({
323
259
  var invalidField = false;
324
260
  if (stagedEntry !== undefined) {
325
261
  text = stagedEntry.text;
326
- overridden = !stagedEntry.clear;
327
- if (!stagedEntry.clear) invalidField = f.spec.parse(stagedEntry.text) === undefined;
262
+ if (stagedEntry.clear) {
263
+ overridden = false;
264
+ } else {
265
+ var parsedStaged = f.spec.parse(stagedEntry.text);
266
+ // The badge previews the save outcome: a staged blank that
267
+ // parses to a clear is not an override anymore.
268
+ overridden = parsedStaged !== undefined && parsedStaged.kind === 'set';
269
+ invalidField = parsedStaged === undefined;
270
+ }
328
271
  } else {
329
- text = f.spec.format(sectionValue(f.field));
330
- overridden = stored(f.field);
272
+ text = f.kind === 'secret' ? '' : f.spec.format(sectionValue(f.field));
273
+ overridden = f.kind === 'secret' ? secretSet(f.field) : stored(f.field);
331
274
  }
332
275
  if (invalidField) invalid = true;
333
- fields[f.field] = { text: text, overridden: overridden, invalid: invalidField };
276
+ fields[f.field] = {
277
+ text: text,
278
+ overridden: overridden,
279
+ invalid: invalidField,
280
+ configured: f.kind === 'secret' && stagedEntry === undefined ? secretSet(f.field) : undefined,
281
+ };
334
282
  });
335
283
  store.set({
336
284
  available: s.status === 'ready',
@@ -343,6 +291,7 @@ window.__ModuleLoader__.load({
343
291
  });
344
292
  }
345
293
  scope.subscribe(publish);
294
+ if (mirror !== null && typeof mirror.subscribe === 'function') mirror.subscribe(publish);
346
295
  publish();
347
296
 
348
297
  function edit(field, text) {
@@ -368,9 +317,42 @@ window.__ModuleLoader__.load({
368
317
  staged.forEach(function (stagedEntry, field) {
369
318
  var spec = SPECS[field];
370
319
  if (stagedEntry.clear) {
320
+ if (field === 'token') {
321
+ // A blank draft clears the token only when one is configured.
322
+ if (secretSet(field)) {
323
+ writes.push({
324
+ field: field,
325
+ run: function () { return scope.unset(field).then(function () { return !secretSet(field); }); },
326
+ });
327
+ }
328
+ return;
329
+ }
371
330
  if (stored(field)) {
372
- writes.push({ field: field, run: function () { return scope.unset(field); } });
331
+ writes.push({
332
+ field: field,
333
+ run: function () { return scope.unset(field).then(function () { return !stored(field); }); },
334
+ });
335
+ }
336
+ return;
337
+ }
338
+ if (field === 'token') {
339
+ var tokenText = stagedEntry.text.trim();
340
+ if (tokenText === '') {
341
+ // A blank draft clears the token when one is configured.
342
+ if (secretSet(field)) {
343
+ writes.push({
344
+ field: field,
345
+ run: function () { return scope.unset(field).then(function () { return !secretSet(field); }); },
346
+ });
347
+ }
348
+ return;
373
349
  }
350
+ writes.push({
351
+ field: field,
352
+ run: (function (value) {
353
+ return function () { return scope.set(field, value).then(function () { return secretSet(field); }); };
354
+ })(tokenText),
355
+ });
374
356
  return;
375
357
  }
376
358
  if (stagedEntry.text === spec.format(sectionValue(field))) return;
@@ -380,9 +362,22 @@ window.__ModuleLoader__.load({
380
362
  return;
381
363
  }
382
364
  if (write.kind === 'clear') {
383
- writes.push({ field: field, run: function () { return scope.unset(field); } });
365
+ writes.push({
366
+ field: field,
367
+ run: function () { return scope.unset(field).then(function () { return !stored(field); }); },
368
+ });
384
369
  } else {
385
- writes.push({ field: field, run: (function (value) { return function () { return scope.set(field, value); }; })(write.value) });
370
+ writes.push({
371
+ field: field,
372
+ run: (function (value) {
373
+ return function () {
374
+ return scope.set(field, value).then(function () {
375
+ var current = snap().user;
376
+ return !!(current && typeof current === 'object' && Object.prototype.hasOwnProperty.call(current, field) && sameJson(current[field], value));
377
+ });
378
+ };
379
+ })(write.value),
380
+ });
386
381
  }
387
382
  });
388
383
  if (blocked || writes.length === 0) return;
@@ -393,7 +388,7 @@ window.__ModuleLoader__.load({
393
388
  var chain = Promise.resolve();
394
389
  writes.forEach(function (w) {
395
390
  chain = chain.then(function () {
396
- return w.run().catch(function () { landed = false; });
391
+ return w.run().then(function (ok) { if (ok !== true) landed = false; }, function () { landed = false; });
397
392
  });
398
393
  });
399
394
  chain.then(function () {
@@ -406,9 +401,9 @@ window.__ModuleLoader__.load({
406
401
 
407
402
  function inject(t) {
408
403
  return {
409
- // The settings section shell synthesizes a `use<Key>` hook for
410
- // every entry of this `hooks` object (dsh-notification's
411
- // `hooks: { settings }` → `useSettings` is the working example).
404
+ // The slots runtime synthesizes a `use<Key>` hook for every entry
405
+ // of this `hooks` object (the shipped plugin cards' `hooks` →
406
+ // `useBashCard` contract).
412
407
  hooks: { settings: store },
413
408
  t: t,
414
409
  edit: edit,
@@ -420,8 +415,8 @@ window.__ModuleLoader__.load({
420
415
  return { inject: inject, store: store, save: save, edit: edit, discard: discard, resetField: resetField };
421
416
  }
422
417
 
423
- // ------------------------------------------------------------- page
424
- var pageStyle = { padding: '4px 0 24px' };
418
+ // ------------------------------------------------------------- card
419
+ var cardStyle = { padding: '4px 0 16px' };
425
420
  var gridStyle = {
426
421
  display: 'grid',
427
422
  gridTemplateColumns: 'minmax(180px, 1fr) minmax(180px, 1fr)',
@@ -447,12 +442,16 @@ window.__ModuleLoader__.load({
447
442
  cursor: 'pointer',
448
443
  };
449
444
 
450
- function GithubRouterSection(props) {
445
+ function GithubRouterCard(props) {
451
446
  var state = props.useSettings(function (s) { return s; });
452
447
  var t = props.t;
453
448
 
449
+ // The framework cards render nothing until their namespace is served;
450
+ // this card follows the same posture.
451
+ if (!state.available) return null;
452
+
454
453
  function renderField(f) {
455
- var fieldState = state.fields[f.field] || { text: '', overridden: false, invalid: false };
454
+ var fieldState = state.fields[f.field] || { text: '', overridden: false, invalid: false, configured: false };
456
455
  if (f.kind === 'bool') {
457
456
  return React.createElement(
458
457
  'label',
@@ -466,15 +465,22 @@ window.__ModuleLoader__.load({
466
465
  t(f.labelKey),
467
466
  );
468
467
  }
468
+ var marker;
469
+ if (f.kind === 'secret') {
470
+ marker = fieldState.configured === true
471
+ ? React.createElement('span', { style: { marginLeft: '6px', opacity: 0.6 } }, t('configured'))
472
+ : null;
473
+ } else {
474
+ marker = fieldState.overridden
475
+ ? React.createElement('span', { style: { marginLeft: '6px', opacity: 0.6 } }, t('overridden'))
476
+ : null;
477
+ }
469
478
  return React.createElement(
470
479
  'label',
471
480
  { key: f.field, style: rowStyle, title: f.hintKey ? t(f.hintKey) : undefined },
472
- React.createElement('span', { style: labelStyle },
473
- t(f.labelKey),
474
- fieldState.overridden ? React.createElement('span', { style: { marginLeft: '6px', opacity: 0.6 } }, t('overridden')) : null,
475
- ),
481
+ React.createElement('span', { style: labelStyle }, t(f.labelKey), marker),
476
482
  React.createElement('input', {
477
- type: f.kind === 'number' ? 'number' : 'text',
483
+ type: f.kind === 'number' ? 'number' : f.kind === 'secret' ? 'password' : 'text',
478
484
  value: fieldState.text,
479
485
  disabled: !state.writable,
480
486
  style: inputStyle,
@@ -486,18 +492,17 @@ window.__ModuleLoader__.load({
486
492
 
487
493
  return React.createElement(
488
494
  'section',
489
- { style: pageStyle },
495
+ { style: cardStyle },
490
496
  React.createElement('p', { style: { margin: '0 0 12px', opacity: 0.75, fontSize: '13px' } }, t('description')),
491
- !state.available ? React.createElement('div', { style: { color: '#c00', fontSize: '13px' } }, t('unavailable')) : null,
492
- state.available && !state.writable ? React.createElement('div', { style: { color: '#c00', fontSize: '13px' } }, t('readOnly')) : null,
493
- state.available ? React.createElement('div', { style: gridStyle }, PRIMARY_FIELDS.map(renderField)) : null,
494
- state.available ? React.createElement(
497
+ !state.writable ? React.createElement('div', { style: { color: '#c00', fontSize: '13px', marginBottom: '8px' } }, t('readOnly')) : null,
498
+ React.createElement('div', { style: gridStyle }, PRIMARY_FIELDS.map(renderField)),
499
+ React.createElement(
495
500
  'details',
496
501
  { style: { marginTop: '12px' } },
497
502
  React.createElement('summary', { style: { cursor: 'pointer', fontSize: '13px', opacity: 0.85 } }, t('advanced')),
498
503
  React.createElement('div', { style: Object.assign({ marginTop: '8px' }, gridStyle) }, ADVANCED_FIELDS.map(renderField)),
499
- ) : null,
500
- state.available ? React.createElement('footer', { style: footerStyle },
504
+ ),
505
+ React.createElement('footer', { style: footerStyle },
501
506
  React.createElement('button', {
502
507
  style: buttonStyle,
503
508
  disabled: !state.dirty || state.invalid || state.saving,
@@ -509,13 +514,13 @@ window.__ModuleLoader__.load({
509
514
  onClick: props.discard,
510
515
  }, t('discard')),
511
516
  state.failed ? React.createElement('span', { style: { color: '#c00', fontSize: '13px' } }, t('failed')) : null,
512
- ) : null,
517
+ ),
513
518
  );
514
519
  }
515
520
 
516
521
  // ------------------------------------------------------------- plugin
517
522
  var name = 'dsh-github-router';
518
- var inject = ['slots', 'locale'];
523
+ var inject = ['slots', 'locale', 'settingsScope', 'connection'];
519
524
  function apply(ctx) {
520
525
  console.info('[dsh-github-router] client bundle loaded');
521
526
  try {
@@ -523,24 +528,18 @@ window.__ModuleLoader__.load({
523
528
  ctx.effect(function () {
524
529
  ctx.locale.register(NS, { zh: zhDict, en: enDict });
525
530
  }, 'dsh-github-router: locale dictionaries');
526
- var form = null;
527
- ctx.slots.inject('settings.section', function* () {
531
+ var scope = ctx.settingsScope.bind({ namespace: NAMESPACE });
532
+ var mirror = typeof ctx.settingsScope.describe === 'function' ? ctx.settingsScope.describe() : null;
533
+ var form = new GithubRouterForm(scope, mirror);
534
+ ctx.slots.inject('settings.plugin.item', function* () {
528
535
  yield ctx.slots.register({
529
- name: 'settings.section',
530
- id: 'dsh-github-router',
531
- order: 65,
532
- label: function () { return t('nav'); },
536
+ name: 'settings.plugin.item',
537
+ key: NAMESPACE,
533
538
  locale: NS,
534
- inject: function () {
535
- if (form === null) {
536
- var scope = typeof fetch === 'function' ? new RemoteScope() : unavailableScope();
537
- form = new GithubRouterForm(scope);
538
- }
539
- return form.inject(t);
540
- },
541
- }, GithubRouterSection);
539
+ inject: function () { return form.inject(t); },
540
+ }, GithubRouterCard);
542
541
  });
543
- console.info('[dsh-github-router] settings section registered');
542
+ console.info('[dsh-github-router] settings card registered');
544
543
  } catch (error) {
545
544
  console.error('[dsh-github-router] client apply failed', error);
546
545
  }
package/lib/config.js CHANGED
@@ -2,9 +2,10 @@
2
2
  * Settings schema and runtime-option resolution for dsh-github-router.
3
3
  *
4
4
  * The settings section (`dsh-github-router` namespace) is registered through
5
- * `installSettingsSection`; the Settings UI renders a plugin card for it.
6
- * Secrets are declared with `.role('secret')` so they are redacted on every
7
- * wire boundary and rendered as write-only inputs.
5
+ * the official settings seam (`ctx.settings.register`, see `lib/settings.js`);
6
+ * the Settings UI renders a plugin card for it (Settings → Plugins, DSH ≥
7
+ * 0.1.0-rc.7). Secrets are declared with `.role('secret')` so they are
8
+ * redacted on every wire boundary and rendered as write-only inputs.
8
9
  *
9
10
  * The schema is deliberately FLAT: the client settings scope writes scalar
10
11
  * fields by name (`scope.set(field, value)`), so nested objects cannot be
package/lib/index.js CHANGED
@@ -19,14 +19,15 @@
19
19
  * 4. Plugin-owned state (fetch cache, response cache) lives under
20
20
  * <DSH_HOME>/storages/dsh-github-router; user repositories are only ever
21
21
  * read (log/diff/show) and only when explicitly granted.
22
- * 5. Configuration rides the official settings seam (`dsh-github-router`
23
- * namespace: durable document, schema validation, revision fencing) and
24
- * is exposed to the browser through a plugin-owned remote API
25
- * (`githubRouterSettings/{describe,mutate}`), because the framework's
26
- * api-proxy only serves settings namespaces on its hardcoded allowlist.
22
+ * 5. Configuration rides the official settings seam end to end
23
+ * (`dsh-github-router` namespace: durable document, schema validation,
24
+ * revision fencing). Since DSH 0.1.0-rc.7 the framework serves every
25
+ * registered settings namespace to the browser and renders the plugin's
26
+ * configuration card in Settings → Plugins, so no plugin-owned HTTP
27
+ * route exists.
27
28
  * @module dsh-github-router
28
29
  */
29
- import { installRemote } from './remote.js'
30
+ import { installSettings } from './settings.js'
30
31
  import { registerGuidance } from './guidance.js'
31
32
  import { registerSkill } from './skill.js'
32
33
  import { registerApiTool } from './tools/api.js'
@@ -39,8 +40,7 @@ export const name = 'dsh-github-router'
39
40
  export const inject = ['tools', 'subprocess', 'skills', 'systemPrompt']
40
41
 
41
42
  export function apply(ctx, config = {}) {
42
- const remote = installRemote(ctx, config)
43
- const options = remote.options
43
+ const options = installSettings(ctx, config).options
44
44
 
45
45
  registerSkill(ctx)
46
46
  registerGuidance(ctx)
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Host-side settings wiring for dsh-github-router.
3
+ *
4
+ * The `dsh-github-router` namespace rides the official settings seam
5
+ * (`ctx.settings.register`): the framework persists the section in the user
6
+ * settings document, validates it against the schema, fences writes with
7
+ * revisions, and redacts secret-role fields on every wire boundary.
8
+ *
9
+ * Since DSH 0.1.0-rc.7 the api-proxy serves every registered settings
10
+ * namespace to the (loopback) browser and the Settings → Plugins section
11
+ * dispatches a plugin-owned card for it, so the client half binds the same
12
+ * namespace through `ctx.settingsScope` and the plugin needs no HTTP routes
13
+ * of its own. This module only registers the namespace and exposes the
14
+ * runtime-options thunk the tools consume; a settings change therefore
15
+ * applies to the NEXT tool call.
16
+ * @module dsh-github-router/settings
17
+ */
18
+ import { Config, NAMESPACE, resolveOptions } from './config.js'
19
+
20
+ /**
21
+ * Register the settings namespace and return the runtime-options thunk.
22
+ * @param ctx - the plugin context.
23
+ * @param config - the composition-layer config subset (the `base` layer).
24
+ */
25
+ export function installSettings(ctx, config) {
26
+ const holder = { scope: null }
27
+
28
+ ctx.inject(['settings'], (sctx) => {
29
+ holder.scope = sctx.settings.register(NAMESPACE, Config, { base: config, applies: 'live' })
30
+ sctx.effect(() => () => {
31
+ holder.scope = null
32
+ }, 'dsh-github-router: settings scope teardown')
33
+ })
34
+
35
+ return {
36
+ options: () => (holder.scope !== null ? resolveOptions(holder.scope.get()) : resolveOptions(config)),
37
+ }
38
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-github-router",
3
- "version": "0.1.0",
3
+ "version": "0.2.1",
4
4
  "description": "DeepSeek Harness plugin: read-only GitHub access for agents (github_probe / github_pr / github_issue / github_file / github_api). Routes every request inside the tool — api.github.com (direct or proxy), gh CLI, git protocol (plugin-owned fetch cache plus read-only local repo reads), PR/issue page HTML with strict JSON embeddedData extraction, and optional user-configured raw mirrors — so agents never burn turns fighting sandbox TLS/proxy failures in a shell. No write/push capability exists anywhere in the plugin.",
5
5
  "keywords": [
6
6
  "dsh-plugin",
@@ -46,6 +46,12 @@
46
46
  "docs"
47
47
  ],
48
48
  "dsh": {
49
+ "compatibility": {
50
+ "node": ">=20",
51
+ "dshReleases": {
52
+ "0.1.1-rc.2": "compatible"
53
+ }
54
+ },
49
55
  "bundle": {
50
56
  "patch": "./cordis.patch.yml"
51
57
  },
package/lib/remote.js DELETED
@@ -1,169 +0,0 @@
1
- /**
2
- * Host-side configuration routes for the browser settings page.
3
- *
4
- * The channel is the dshmarket pattern: the plugin mounts its own HTTP
5
- * routes on the shared web server (`ctx.webServer.register`, prefix
6
- * `/dsh-github-router`) and the browser page talks to them with plain
7
- * same-origin fetch — no typert, no gateway, no framework allowlist. The
8
- * routes are backed by the SAME official settings seam: the namespace
9
- * stays durable in the settings document, with schema validation,
10
- * revision fencing, and secret redaction.
11
- *
12
- * Mutating requests are POST-only, same-origin-checked (Origin === Host)
13
- * and body-capped, mirroring dsh-market's route security posture.
14
- * @module dsh-github-router/remote
15
- */
16
- import { Config, NAMESPACE, resolveOptions } from './config.js'
17
-
18
- const ROUTE_PREFIX = '/dsh-github-router'
19
- const MAX_BODY_BYTES = 16384
20
-
21
- function sendJson(response, status, payload) {
22
- response.writeHead(status, {
23
- 'cache-control': 'no-store',
24
- 'content-type': 'application/json; charset=utf-8',
25
- })
26
- response.end(JSON.stringify(payload))
27
- }
28
-
29
- /** Same-origin enforcement for mutating routes (dsh-market posture). */
30
- function sameOrigin(request) {
31
- const origin = request.headers.origin
32
- const host = request.headers.host
33
- if (origin === undefined || host === undefined) return false
34
- try {
35
- return new URL(origin).host === host
36
- } catch {
37
- return false
38
- }
39
- }
40
-
41
- /** Read and parse a JSON request body with a hard byte cap. */
42
- async function readJsonBody(request, maxBytes = MAX_BODY_BYTES) {
43
- const chunks = []
44
- let size = 0
45
- for await (const chunk of request) {
46
- const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)
47
- size += buffer.length
48
- if (size > maxBytes) throw new Error('request body too large')
49
- chunks.push(buffer)
50
- }
51
- return JSON.parse(Buffer.concat(chunks).toString('utf8'))
52
- }
53
-
54
- /**
55
- * Install the settings-namespace registration, the options thunk, and the
56
- * `/dsh-github-router/config` routes in one wiring. Returns the
57
- * runtime-options thunk the tools consume.
58
- */
59
- export function installRemote(ctx, config) {
60
- const holder = { scope: null, settings: null }
61
-
62
- ctx.inject(['settings'], (sctx) => {
63
- holder.settings = sctx.settings
64
- holder.scope = sctx.settings.register(NAMESPACE, Config, { base: config })
65
- sctx.effect(() => () => {
66
- holder.scope = null
67
- holder.settings = null
68
- }, 'dsh-github-router: settings scope teardown')
69
- })
70
-
71
- /** Redacted wire view of this namespace (value/base/user/revision/writable). */
72
- function viewOf() {
73
- if (holder.scope === null || holder.settings === null) return null
74
- const descriptors = holder.settings.describe({ redactSecrets: true })
75
- const descriptor = descriptors.find((d) => String(d.ns) === NAMESPACE)
76
- if (descriptor === undefined) return null
77
- return {
78
- value: descriptor.value ?? {},
79
- base: descriptor.base ?? {},
80
- user: descriptor.user ?? {},
81
- revision: descriptor.revision,
82
- writable: holder.settings.writable === true,
83
- }
84
- }
85
-
86
- function validateOps(payload) {
87
- if (payload === null || typeof payload !== 'object' || !Array.isArray(payload.ops) || payload.ops.length === 0) {
88
- return 'payload.ops must be a non-empty array'
89
- }
90
- if (payload.ops.length > 40) return 'too many ops in one write'
91
- for (const op of payload.ops) {
92
- if (op === null || typeof op !== 'object' || (op.op !== 'set' && op.op !== 'unset')) {
93
- return 'each op must be { op: "set" | "unset", path: [field] }'
94
- }
95
- if (!Array.isArray(op.path) || op.path.length !== 1 || typeof op.path[0] !== 'string' || op.path[0].length === 0 || op.path[0].length > 64) {
96
- return 'op.path must be a single field name'
97
- }
98
- if (op.op === 'set' && op.value === undefined) return 'set ops require a value'
99
- }
100
- return null
101
- }
102
-
103
- const routeHandler = async (request, response) => {
104
- const pathname = new URL(request.url ?? '/', 'http://x').pathname
105
- if (pathname !== ROUTE_PREFIX + '/config') {
106
- sendJson(response, 404, { ok: false, error: 'unknown dsh-github-router route' })
107
- return
108
- }
109
- if (request.method === 'GET') {
110
- if (holder.scope === null || holder.settings === null) {
111
- sendJson(response, 503, { ok: false, error: 'the settings service is not mounted in this deployment' })
112
- return
113
- }
114
- const view = viewOf()
115
- if (view === null) {
116
- sendJson(response, 503, { ok: false, error: 'settings namespace is not registered' })
117
- return
118
- }
119
- sendJson(response, 200, { ok: true, value: view })
120
- return
121
- }
122
- if (request.method === 'POST') {
123
- if (!sameOrigin(request)) {
124
- sendJson(response, 403, { ok: false, error: 'configuration writes are limited to same-origin requests' })
125
- return
126
- }
127
- if (holder.scope === null || holder.settings === null) {
128
- sendJson(response, 503, { ok: false, error: 'the settings service is not mounted in this deployment' })
129
- return
130
- }
131
- let payload
132
- try {
133
- payload = await readJsonBody(request)
134
- } catch (error) {
135
- sendJson(response, 400, { ok: false, error: `unreadable request body: ${String(error && error.message ? error.message : error)}` })
136
- return
137
- }
138
- const invalid = validateOps(payload)
139
- if (invalid !== null) {
140
- sendJson(response, 400, { ok: false, error: invalid })
141
- return
142
- }
143
- const expectedRevision = Number.isInteger(payload.expectedRevision) ? payload.expectedRevision : undefined
144
- try {
145
- await holder.scope.mutate(payload.ops, expectedRevision)
146
- } catch (error) {
147
- sendJson(response, 409, { ok: false, error: error instanceof Error ? error.message : String(error) })
148
- return
149
- }
150
- const view = viewOf()
151
- if (view === null) {
152
- sendJson(response, 503, { ok: false, error: 'settings namespace disappeared after the write' })
153
- return
154
- }
155
- sendJson(response, 200, { ok: true, value: view })
156
- return
157
- }
158
- response.writeHead(405, { allow: 'GET, POST' })
159
- response.end()
160
- }
161
-
162
- ctx.inject(['webServer'], (hctx) => {
163
- hctx.effect(() => hctx.webServer.register({ kind: 'prefix', path: ROUTE_PREFIX, handler: routeHandler }), 'dsh-github-router: settings routes')
164
- })
165
-
166
- return {
167
- options: () => (holder.scope !== null ? resolveOptions(holder.scope.get()) : resolveOptions(config)),
168
- }
169
- }