dsh-permission-rules 0.7.4 → 0.7.7

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-permission-rules",
3
- "version": "0.7.4",
3
+ "version": "0.7.7",
4
4
  "description": "Declarative Claude Code-style permission rules plus a Codex-style process-level network policy for DeepSeek Harness: ordered allow/deny/ask rules with tool-name, argument (glob/regex), workspace-path, and network-target (domain/ip/port/scheme) matching on the tools/pre-execute waterfall, a built-in local HTTP/CONNECT proxy governing shell subprocess traffic, three network modes mapped onto the official sandbox presets, full session-log audit, HMR rule reload, and a settings page with a rule editor, block counters, and recent interception lists.",
5
5
  "private": false,
6
6
  "type": "module",
@@ -77,7 +77,7 @@
77
77
  "network:outbound"
78
78
  ],
79
79
  "compatibility": {
80
- "dshVersions": ["0.1.2-rc.1","0.1.5-rc.2"]
80
+ "dshVersions": ["0.1.2-rc.1","0.1.5-rc.2","0.1.7-alpha.2"]
81
81
  },
82
82
  "capability": {
83
83
  "id": "rules",
@@ -116,22 +116,22 @@
116
116
  },
117
117
  "engines": {
118
118
  "node": "^22.19.0 || >=24.0.0",
119
- "dsh": ">=0.1.2-rc.1 <0.2.0 || >=0.1.5-alpha.1 <0.2.0 || >=0.1.6-0 <0.2.0"
119
+ "dsh": ">=0.1.2-rc.1 <0.2.0 || >=0.1.5-alpha.1 <0.2.0 || >=0.1.6-0 <0.2.0 || >=0.1.7-0 <0.2.0"
120
120
  },
121
121
  "peerDependencies": {
122
- "@deepseek-ai/cordis": "^4.0.2",
123
- "@deepseek-ai/dsh-agent": ">=0.1.2-rc.1 <0.2.0 || >=0.1.5-alpha.1 <0.2.0 || >=0.1.6-0 <0.2.0",
124
- "@deepseek-ai/dsh-commands": ">=0.1.2-rc.1 <0.2.0 || >=0.1.5-alpha.1 <0.2.0 || >=0.1.6-0 <0.2.0",
125
- "@deepseek-ai/dsh-llm": ">=0.1.2-rc.1 <0.2.0 || >=0.1.5-alpha.1 <0.2.0 || >=0.1.6-0 <0.2.0",
126
- "@deepseek-ai/dsh-session": ">=0.1.2-rc.1 <0.2.0 || >=0.1.5-alpha.1 <0.2.0 || >=0.1.6-0 <0.2.0",
127
- "@deepseek-ai/dsh-tools": ">=0.1.2-rc.1 <0.2.0 || >=0.1.5-alpha.1 <0.2.0 || >=0.1.6-0 <0.2.0",
128
- "@deepseek-ai/dsh-typert-protocol": ">=0.1.2-rc.1 <0.2.0 || >=0.1.5-alpha.1 <0.2.0 || >=0.1.6-0 <0.2.0",
122
+ "@deepseek-ai/cordis": "^4.0.4",
123
+ "@deepseek-ai/dsh-agent": ">=0.1.2-rc.1 <0.2.0 || >=0.1.5-alpha.1 <0.2.0 || >=0.1.6-0 <0.2.0 || >=0.1.7-0 <0.2.0",
124
+ "@deepseek-ai/dsh-commands": ">=0.1.2-rc.1 <0.2.0 || >=0.1.5-alpha.1 <0.2.0 || >=0.1.6-0 <0.2.0 || >=0.1.7-0 <0.2.0",
125
+ "@deepseek-ai/dsh-llm": ">=0.1.2-rc.1 <0.2.0 || >=0.1.5-alpha.1 <0.2.0 || >=0.1.6-0 <0.2.0 || >=0.1.7-0 <0.2.0",
126
+ "@deepseek-ai/dsh-session": ">=0.1.2-rc.1 <0.2.0 || >=0.1.5-alpha.1 <0.2.0 || >=0.1.6-0 <0.2.0 || >=0.1.7-0 <0.2.0",
127
+ "@deepseek-ai/dsh-tools": ">=0.1.2-rc.1 <0.2.0 || >=0.1.5-alpha.1 <0.2.0 || >=0.1.6-0 <0.2.0 || >=0.1.7-0 <0.2.0",
128
+ "@deepseek-ai/dsh-typert-protocol": ">=0.1.2-rc.1 <0.2.0 || >=0.1.5-alpha.1 <0.2.0 || >=0.1.6-0 <0.2.0 || >=0.1.7-0 <0.2.0",
129
129
  "@deepseek-ai/schemastery": "^3.18.2",
130
130
  "react": "^18.2.0",
131
- "@deepseek-ai/dsh-client-connection": ">=0.1.2-rc.1 <0.2.0 || >=0.1.5-alpha.1 <0.2.0 || >=0.1.6-0 <0.2.0",
132
- "@deepseek-ai/dsh-client-locale": ">=0.1.2-rc.1 <0.2.0 || >=0.1.5-alpha.1 <0.2.0 || >=0.1.6-0 <0.2.0",
133
- "@deepseek-ai/dsh-client-ui-settings": ">=0.1.2-rc.1 <0.2.0 || >=0.1.5-alpha.1 <0.2.0 || >=0.1.6-0 <0.2.0",
134
- "@deepseek-ai/dsh-client-web": ">=0.1.2-rc.1 <0.2.0 || >=0.1.5-alpha.1 <0.2.0 || >=0.1.6-0 <0.2.0"
131
+ "@deepseek-ai/dsh-client-connection": ">=0.1.2-rc.1 <0.2.0 || >=0.1.5-alpha.1 <0.2.0 || >=0.1.6-0 <0.2.0 || >=0.1.7-0 <0.2.0",
132
+ "@deepseek-ai/dsh-client-locale": ">=0.1.2-rc.1 <0.2.0 || >=0.1.5-alpha.1 <0.2.0 || >=0.1.6-0 <0.2.0 || >=0.1.7-0 <0.2.0",
133
+ "@deepseek-ai/dsh-client-ui-settings": ">=0.1.2-rc.1 <0.2.0 || >=0.1.5-alpha.1 <0.2.0 || >=0.1.6-0 <0.2.0 || >=0.1.7-0 <0.2.0",
134
+ "@deepseek-ai/dsh-client-web": ">=0.1.2-rc.1 <0.2.0 || >=0.1.5-alpha.1 <0.2.0 || >=0.1.6-0 <0.2.0 || >=0.1.7-0 <0.2.0"
135
135
  },
136
136
  "peerDependenciesMeta": {
137
137
  "@deepseek-ai/dsh-client-connection": {
@@ -156,27 +156,28 @@
156
156
  "zod": "^4.4.3"
157
157
  },
158
158
  "devDependencies": {
159
- "@deepseek-ai/dsh-attachment": "0.1.5-rc.2",
160
- "@deepseek-ai/cordis": "^4.0.2",
159
+ "@deepseek-ai/dsh-attachment": "0.1.7-alpha.2",
160
+ "@deepseek-ai/cordis": "^4.0.4",
161
161
  "@deepseek-ai/cordis-plugin-include": "^1.0.7",
162
- "@deepseek-ai/cordis-plugin-loader": "^1.0.3",
163
- "@deepseek-ai/dsh-agent": "0.1.5-rc.2",
164
- "@deepseek-ai/dsh-brand": "0.1.5-rc.2",
165
- "@deepseek-ai/dsh-client-connection": "0.1.5-rc.2",
166
- "@deepseek-ai/dsh-client-locale": "0.1.5-rc.2",
167
- "@deepseek-ai/dsh-client-ui-settings": "0.1.5-rc.2",
168
- "@deepseek-ai/dsh-client-ui-slots": "0.1.5-rc.2",
169
- "@deepseek-ai/dsh-client-web": "0.1.5-rc.2",
170
- "@deepseek-ai/dsh-commands": "0.1.5-rc.2",
171
- "@deepseek-ai/dsh-invariants": "0.1.5-rc.2",
172
- "@deepseek-ai/dsh-llm": "0.1.5-rc.2",
173
- "@deepseek-ai/dsh-session": "0.1.5-rc.2",
174
- "@deepseek-ai/dsh-session-projection": "0.1.5-rc.2",
175
- "@deepseek-ai/dsh-subagent": "0.1.5-rc.2",
176
- "@deepseek-ai/dsh-tools": "0.1.5-rc.2",
177
- "@deepseek-ai/dsh-typert-protocol": "0.1.5-rc.2",
178
- "@deepseek-ai/dsh-user-approval": "0.1.5-rc.2",
179
- "@deepseek-ai/schemastery": "^3.18.2",
162
+ "@deepseek-ai/cordis-plugin-loader": "^1.0.4",
163
+ "@deepseek-ai/dsh-agent": "0.1.7-alpha.2",
164
+ "@deepseek-ai/dsh-brand": "0.1.7-alpha.2",
165
+ "@deepseek-ai/dsh-client-connection": "0.1.7-alpha.2",
166
+ "@deepseek-ai/dsh-client-locale": "0.1.7-alpha.2",
167
+ "@deepseek-ai/dsh-client-ui-settings": "0.1.7-alpha.2",
168
+ "@deepseek-ai/dsh-client-ui-slots": "0.1.7-alpha.2",
169
+ "@deepseek-ai/dsh-client-web": "0.1.7-alpha.2",
170
+ "@deepseek-ai/dsh-commands": "0.1.7-alpha.2",
171
+ "@deepseek-ai/dsh-invariants": "0.1.7-alpha.2",
172
+ "@deepseek-ai/dsh-llm": "0.1.7-alpha.2",
173
+ "@deepseek-ai/dsh-session": "0.1.7-alpha.2",
174
+ "@deepseek-ai/dsh-session-projection": "0.1.7-alpha.2",
175
+ "@deepseek-ai/dsh-subagent": "0.1.7-alpha.2",
176
+ "@deepseek-ai/dsh-system-prompt": "0.1.7-alpha.2",
177
+ "@deepseek-ai/dsh-tools": "0.1.7-alpha.2",
178
+ "@deepseek-ai/dsh-typert-protocol": "0.1.7-alpha.2",
179
+ "@deepseek-ai/dsh-user-approval": "0.1.7-alpha.2",
180
+ "@deepseek-ai/schemastery": "^3.18.3",
180
181
  "@eslint/js": "^10.0.1",
181
182
  "@types/node": "^22.19.0",
182
183
  "@vitest/coverage-v8": "^3.2.0",
@@ -82,14 +82,15 @@ const STORAGE_ROW_TYPES = new Set([
82
82
  /**
83
83
  * The harness build's generated event vocabulary
84
84
  * (`KNOWN_SESSION_EVENT_TYPES`, `packages/core/session/src/known-event-types.ts`,
85
- * synced with the `0.1.5-rc.1` catalog), plus the v1/v2-only spellings the
85
+ * synced with the `0.1.7-alpha.1` catalog), plus the v1/v2-only spellings the
86
86
  * older generations wrote (`assistant/chunk`, `tool/code-dispatch`,
87
87
  * `tool/code-dispatch-start`; V3 renamed the latter two to `tool/ptc-dispatch`
88
88
  * and `tool/ptc-dispatch-start`), so every generation scans clean. Rows whose
89
89
  * type is outside this set are what the read path refuses when the envelope
90
90
  * lacks `ignorable: true`; the scan reports them so nothing is missed.
91
91
  * Regenerate this list from the harness checkout when it changes (0.1.5-rc.1
92
- * added `deliverables/presented` and `subagent/catalog`).
92
+ * added `deliverables/presented` and `subagent/catalog`; `0.1.7-alpha.1` added
93
+ * `developer/message`, `image/offload` and `workspace/changes`).
93
94
  */
94
95
  const KNOWN_SESSION_EVENT_TYPES = new Set([
95
96
  'agent-preset/selected',
@@ -109,12 +110,14 @@ const KNOWN_SESSION_EVENT_TYPES = new Set([
109
110
  'compaction/start',
110
111
  'compaction/summary',
111
112
  'deliverables/presented',
113
+ 'developer/message',
112
114
  'feedback/message-delete',
113
115
  'feedback/message-put',
114
116
  'feedback/record',
115
117
  'goal/change',
116
118
  'hook/invoked',
117
119
  'hook/result',
120
+ 'image/offload',
118
121
  'llm/retry',
119
122
  'llm/retry-started',
120
123
  'model/selection',
@@ -153,6 +156,7 @@ const KNOWN_SESSION_EVENT_TYPES = new Set([
153
156
  'turn/start',
154
157
  'user/message',
155
158
  'web/deepseek-search-llm-request',
159
+ 'workspace/changes',
156
160
  ])
157
161
 
158
162
  /**
package/src/config.ts CHANGED
@@ -3,6 +3,15 @@
3
3
  * a validated {@link Config} field changeable from cordis.yml; the
4
4
  * resolution step validates the numeric bounds and compiles nothing — rule
5
5
  * files are external documents resolved per session cwd at load time.
6
+ *
7
+ * Since the `0.1.7-alpha` settings contract every field is declared
8
+ * `.volatile()`, so the Loader hands `apply` a live reference per field
9
+ * ({@link LiveConfig}) and the settings surface edits them in place. The
10
+ * numeric bounds live in the SCHEMA (`min`/`max`/`step`) as well as in
11
+ * {@link resolveConfig}: the config editor resolves the merged config
12
+ * against this schema before it persists anything, so an out-of-range value
13
+ * is still refused at write time — the tradeoff the removed
14
+ * `settings.register(..., { validate })` callback used to carry.
6
15
  * @module dsh-permission-rules/config
7
16
  */
8
17
 
@@ -98,8 +107,10 @@ export interface Config {
98
107
  /**
99
108
  * Fallback rule file used when per-cwd discovery finds no rule file.
100
109
  * Absolute, or relative to `process.cwd()`. Unset = an empty rule set.
110
+ * Declared `| undefined` because a live reference to a field the schema
111
+ * gives no default reports an absent value explicitly.
101
112
  */
102
- fallbackPath?: string
113
+ fallbackPath?: string | undefined
103
114
  /**
104
115
  * How an unreadable/invalid rule file is handled at load: `'fail'` throws
105
116
  * (the pending tool call errors loudly; HMR reloads keep the previous
@@ -146,9 +157,9 @@ export interface Config {
146
157
  */
147
158
  allowUnmarkedAudit?: boolean
148
159
  /** Process-level network policy (all optional; defaults inside). */
149
- network?: NetworkConfig
160
+ network?: NetworkConfig | undefined
150
161
  /** Built-in high-risk baseline (all optional; defaults inside). */
151
- builtin?: BuiltinConfig
162
+ builtin?: BuiltinConfig | undefined
152
163
  }
153
164
 
154
165
  /** Network config after {@link resolveConfig}: every optional field has its explicit default. */
@@ -195,43 +206,125 @@ export interface ResolvedConfig {
195
206
  readonly builtin: ResolvedBuiltinConfig
196
207
  }
197
208
 
198
- /** Schemastery schema: the loader validates and fills defaults before `apply`. */
199
- export const Config: z<Config> = z.object({
200
- rulesFile: z.string().default('.dsh/rules.yaml'),
201
- fallbackPath: z.string(),
202
- badFilePolicy: z.union(['fail', 'ignore-with-warning'] as const).default('fail'),
203
- maxRules: z.number().default(256),
204
- maxCachedWorkspaces: z.number().default(512),
205
- patternMode: z.union(['glob', 'regex'] as const).default('glob'),
206
- watch: z.boolean().default(true),
207
- watchStabilityThresholdMs: z.number().default(200),
208
- language: z.union(['en', 'zh', 'es', 'pt', 'hi'] as const).default('en'),
209
- caseInsensitivePaths: z.boolean().default(process.platform === 'win32'),
210
- audit: z.union(['all', 'hits'] as const).default('all'),
211
- searchUp: z.boolean().default(false),
212
- maxGlobStars: z.number().default(2),
213
- enforce: z.boolean().default(true),
214
- allowUnmarkedAudit: z.boolean().default(false),
215
- network: z.object({
209
+ /**
210
+ * Mark one schema field as a live reference, where the host can.
211
+ *
212
+ * `.volatile()` first appeared in `@deepseek-ai/schemastery` 3.18.3, and this
213
+ * schema is built while the plugin module is evaluated — an unguarded call
214
+ * would turn every host line the package's peer ranges still advertise
215
+ * (`0.1.2-rc`, `0.1.5-alpha`, `0.1.6-0`) into a hard mount crash. Probed
216
+ * instead: on a Schemastery without the method the field stays an ordinary
217
+ * value, the Loader hands `apply` a plain config ({@link plainConfig} passes
218
+ * it straight through), and the plugin runs exactly as it did before live
219
+ * forms existed — only the settings form is unavailable there, which is the
220
+ * same degradation as a host that composes no settings service at all.
221
+ * @param schema - the field schema.
222
+ * @returns the same field schema, live where supported.
223
+ */
224
+ function live<S extends { volatile(): unknown }>(schema: S): ReturnType<S['volatile']> {
225
+ const builder = schema as { volatile?: () => ReturnType<S['volatile']> }
226
+ return typeof builder.volatile === 'function'
227
+ ? builder.volatile()
228
+ : (schema as unknown as ReturnType<S['volatile']>)
229
+ }
230
+
231
+ /**
232
+ * Schemastery schema: the loader validates and fills defaults before
233
+ * `apply`. Every field is live (see {@link live}), so on a `0.1.7-alpha`
234
+ * host the Loader hands `apply` a reference per field and the settings
235
+ * surface can edit each one in place. `network` and `builtin` are marked
236
+ * live as WHOLE objects — Schemastery refuses a volatile inside a volatile,
237
+ * and the only supported shape is a fixed object path.
238
+ *
239
+ * The numeric bounds are declared here (not only in {@link resolveConfig})
240
+ * because the config editor resolves the merged config against THIS schema
241
+ * before persisting it: an out-of-range edit is refused at write time
242
+ * instead of disabling the plugin on the next read.
243
+ */
244
+ export const Config = z.object({
245
+ rulesFile: live(z.string().default('.dsh/rules.yaml')),
246
+ fallbackPath: live(z.string()),
247
+ badFilePolicy: live(z.union(['fail', 'ignore-with-warning'] as const).default('fail')),
248
+ maxRules: live(z.number().step(1).min(1).default(256)),
249
+ maxCachedWorkspaces: live(z.number().step(1).min(1).default(512)),
250
+ patternMode: live(z.union(['glob', 'regex'] as const).default('glob')),
251
+ watch: live(z.boolean().default(true)),
252
+ watchStabilityThresholdMs: live(z.number().step(1).min(0).default(200)),
253
+ language: live(z.union(['en', 'zh', 'es', 'pt', 'hi'] as const).default('en')),
254
+ caseInsensitivePaths: live(z.boolean().default(process.platform === 'win32')),
255
+ audit: live(z.union(['all', 'hits'] as const).default('all')),
256
+ searchUp: live(z.boolean().default(false)),
257
+ maxGlobStars: live(z.number().step(1).min(1).default(2)),
258
+ enforce: live(z.boolean().default(true)),
259
+ allowUnmarkedAudit: live(z.boolean().default(false)),
260
+ network: live(z.object({
216
261
  enabled: z.boolean().default(true),
217
262
  mode: z.union(['auto', ...NETWORK_MODES] as const).default('auto'),
218
263
  autoFallback: z.union(NETWORK_MODES as [NetworkMode, ...NetworkMode[]]).default('allow-all'),
219
264
  unlisted: z.union(['ask', 'deny'] as const).default('ask'),
220
265
  proxyBind: z.string().default('127.0.0.1'),
221
- proxyPort: z.number().default(0),
222
- proxyMaxRecent: z.number().default(100),
266
+ proxyPort: z.number().step(1).min(0).max(65535).default(0),
267
+ proxyMaxRecent: z.number().step(1).min(1).default(100),
223
268
  loopback: z.union(['allow', 'policy'] as const).default('allow'),
224
269
  injectEnv: z.boolean().default(true),
225
270
  noProxy: z.union(['clear', 'preserve'] as const).default('clear'),
226
271
  upstreamProxy: z.string().default('off'),
227
272
  allowHostAction: z.boolean().default(true),
228
- }),
229
- builtin: z.object({
273
+ })),
274
+ builtin: live(z.object({
230
275
  enabled: z.boolean().default(true),
231
276
  path: z.string(),
232
- }),
277
+ })),
233
278
  })
234
279
 
280
+ /** The Loader-delivered config: every field is a live reference to its current value. */
281
+ export type LiveConfig = ReturnType<typeof Config>
282
+
283
+ /** What {@link resolveConfig} accepts: the raw config, or the Loader's live one. */
284
+ export type ConfigInput = Config | LiveConfig
285
+
286
+ /**
287
+ * Whether `value` is the Loader's live config rather than a plain one.
288
+ * `rulesFile` is the discriminator: it always has a schema default, so the
289
+ * Loader always materializes its reference, plain or absent.
290
+ * @param value - a raw or live config.
291
+ * @returns true when the fields are live references.
292
+ */
293
+ function isLiveConfig(value: ConfigInput): value is LiveConfig {
294
+ return typeof (value as { rulesFile?: { get?: unknown } }).rulesFile?.get === 'function'
295
+ }
296
+
297
+ /**
298
+ * Unwrap the Loader's live references into the plain config
299
+ * {@link resolveConfig} validates. Reading each reference HERE is what keeps
300
+ * a caller's config source lazy: call it inside the closure, never at
301
+ * closure-construction time, or a live edit stops being visible.
302
+ * @param config - the raw config, or the Loader's live one.
303
+ * @returns the plain raw config (the input itself when it was already plain).
304
+ */
305
+ export function plainConfig(config: ConfigInput): Config {
306
+ if (!isLiveConfig(config)) return config
307
+ return {
308
+ rulesFile: config.rulesFile.get(),
309
+ fallbackPath: config.fallbackPath.get(),
310
+ badFilePolicy: config.badFilePolicy.get(),
311
+ maxRules: config.maxRules.get(),
312
+ maxCachedWorkspaces: config.maxCachedWorkspaces.get(),
313
+ patternMode: config.patternMode.get(),
314
+ watch: config.watch.get(),
315
+ watchStabilityThresholdMs: config.watchStabilityThresholdMs.get(),
316
+ language: config.language.get(),
317
+ caseInsensitivePaths: config.caseInsensitivePaths.get(),
318
+ audit: config.audit.get(),
319
+ searchUp: config.searchUp.get(),
320
+ maxGlobStars: config.maxGlobStars.get(),
321
+ enforce: config.enforce.get(),
322
+ allowUnmarkedAudit: config.allowUnmarkedAudit.get(),
323
+ network: config.network.get(),
324
+ builtin: config.builtin.get(),
325
+ }
326
+ }
327
+
235
328
  /**
236
329
  * Validate raw values and fill explicit defaults. A `maxRules`,
237
330
  * `maxCachedWorkspaces`, or `maxGlobStars` that is not a positive safe
@@ -239,10 +332,15 @@ export const Config: z<Config> = z.object({
239
332
  * absolute `rulesFile`, a value outside a closed enum, or a non-boolean
240
333
  * flag throws here — misconfiguration fails loud at mount even when the
241
334
  * plugin is mounted without the Schemastery loader.
242
- * @param config - raw (possibly partial) plugin config.
335
+ *
336
+ * Accepts the Loader's live config directly and unwraps it through
337
+ * {@link plainConfig}, so a caller can keep this call inside its config
338
+ * source and see every live edit on the next read.
339
+ * @param input - raw (possibly partial) plugin config, or the Loader's live one.
243
340
  * @returns the fully resolved config.
244
341
  */
245
- export function resolveConfig(config: Config = {}): ResolvedConfig {
342
+ export function resolveConfig(input: ConfigInput = {}): ResolvedConfig {
343
+ const config = plainConfig(input)
246
344
  const rulesFile = config.rulesFile ?? '.dsh/rules.yaml'
247
345
  const searchUp = config.searchUp ?? false
248
346
  if (searchUp && isAbsolute(rulesFile)) {
package/src/runtime.ts CHANGED
@@ -21,7 +21,7 @@ import type { CommandInvocation, CommandResult } from '@deepseek-ai/dsh-commands
21
21
  import type { PreToolDecision, ToolExecution } from '@deepseek-ai/dsh-tools'
22
22
  import type { SessionEvent } from '@deepseek-ai/dsh-session'
23
23
  import { resolveConfig } from './config.ts'
24
- import type { Config, ResolvedConfig } from './config.ts'
24
+ import type { LiveConfig, ResolvedConfig } from './config.ts'
25
25
  import type { CallId } from './call-id.ts'
26
26
  import { insertAllowRule, isWritableHost, normalizeAllowHost, resolveAllowHostTarget } from './allow-host.ts'
27
27
  import type { AllowHostInsertion } from './allow-host.ts'
@@ -1639,10 +1639,15 @@ export class PermissionRulesRuntime {
1639
1639
  /**
1640
1640
  * Mount the plugin: resolve config, validate deployment-level rule files,
1641
1641
  * register the pre-execute listener and the `/rules` command.
1642
+ *
1643
+ * The `config` argument is the Loader's LIVE config (every field a
1644
+ * `.volatile()` reference, see {@link LiveConfig}); `resolveConfig` unwraps
1645
+ * it, and the same object is handed to {@link attachSettingsSection} so the
1646
+ * runtime keeps reading the current values instead of a mount-time snapshot.
1642
1647
  * @param ctx - the host context.
1643
- * @param config - raw plugin config.
1648
+ * @param config - the Loader-delivered live plugin config.
1644
1649
  */
1645
- export async function apply(ctx: Context, config: Config): Promise<void> {
1650
+ export async function apply(ctx: Context, config: LiveConfig): Promise<void> {
1646
1651
  const resolved = resolveConfig(config)
1647
1652
  const runtime = new PermissionRulesRuntime(ctx, resolved)
1648
1653
  runtime.validateDeploymentFiles()
package/src/settings.ts CHANGED
@@ -1,74 +1,89 @@
1
1
  /**
2
- * Host-side settings-namespace wiring: registers the whole plugin config
3
- * as the `permission-rules` settings section (editable from the Web
4
- * settings surface, loopback-only), keeps the runtime's config source
5
- * pointed at the resolved section, and rebinds the network proxy when a
6
- * bind/env-relevant knob changes live. On hosts without a settings
7
- * service (the rc.6 line) nothing registers and the composition entry
8
- * keeps serving the config — the plugin stays fully functional.
2
+ * Host-side live-configuration wiring for the `0.1.7-alpha` settings
3
+ * contract. The plugin's Config fields are declared `.volatile()`, the
4
+ * Loader hands `apply` one live reference per field, and the settings
5
+ * surface edits those references in place. This module therefore owns three
6
+ * things and nothing else: it tells the settings service that this plugin
7
+ * ships its OWN page (so no automatic form is generated for it), it keeps
8
+ * the runtime's config source pointed at the CURRENT reference values, and
9
+ * it rebinds the network proxy when a bind/env-relevant knob changes live.
9
10
  *
10
- * The registration uses only structural interfaces: `@deepseek-ai/
11
- * dsh-settings` is not an rc.6 peer, so importing it would hard-fail
12
- * older hosts; the real service satisfies these shapes.
11
+ * There is no registration any more: the namespace of a settings form is the
12
+ * profile entry id, chosen by the composition, not by the plugin. The
13
+ * service is still reached structurally (`ctx.inject(['settings'])` plus
14
+ * `ctx.get('settings')`) because `@deepseek-ai/dsh-settings` is not a
15
+ * dependency of this package — on a host without it nothing registers and
16
+ * the composition entry keeps serving the config, because the references
17
+ * ARE the entry's own values.
13
18
  * @module dsh-permission-rules/settings
14
19
  */
15
20
 
16
21
  import type { Context } from '@deepseek-ai/cordis'
17
- import type z from '@deepseek-ai/schemastery'
18
- import { Config, resolveConfig } from './config.ts'
19
- import type { Config as PluginConfig, ResolvedConfig } from './config.ts'
22
+ // Type-only: merges the Loader's `loader/volatile-update` event into `Events`.
23
+ // It is a devDependency (the Loader is composed by the host), and the import is
24
+ // erased, so nothing is required at runtime — a host whose Loader predates the
25
+ // event simply never emits it, and this plugin has nothing to reconcile then.
26
+ import type {} from '@deepseek-ai/cordis-plugin-loader'
27
+ import { resolveConfig } from './config.ts'
28
+ import type { LiveConfig, ResolvedConfig } from './config.ts'
20
29
  import type { PermissionRulesRuntime } from './runtime.ts'
21
30
 
22
- /** The settings namespace this plugin owns (kebab-case, per the provider contract). */
23
- export const SETTINGS_NAMESPACE = 'permission-rules'
24
-
25
- /** The owner-facing scope the real settings service hands back. */
26
- interface SettingsScopeLike<T> {
27
- get(): T
28
- watch(callback: (next: T, prev: T) => void | Promise<void>): () => void
29
- }
30
-
31
- /** The structural settings-provider shape the real `ctx.settings` satisfies. */
32
- interface SettingsProviderLike {
33
- register<T>(ns: string, schema: z<T>, options?: {
34
- base?: Partial<T>
35
- expose?: boolean
36
- applies?: string
37
- validate?: (value: T) => void
38
- }): SettingsScopeLike<T>
31
+ /**
32
+ * The structural settings-service face this plugin uses. The real
33
+ * `ctx.settings` is a `SettingsForms` service; only `configure` is read, and
34
+ * only through this shape, so an older or replaced service is tolerated.
35
+ */
36
+ interface SettingsLike {
37
+ configure(presentation: { auto?: boolean }, owner: unknown): () => void
39
38
  }
40
39
 
41
40
  /**
42
- * Register the settings section and bind the runtime's live config
43
- * source. Runs only where a settings service is composed.
41
+ * Point the runtime's live config source at the Loader's references and
42
+ * register this instance's settings-page policy.
44
43
  * @param ctx - the plugin context.
45
- * @param runtime - the runtime whose config source follows the section.
46
- * @param entry - the composition entry config (the section's `base` layer).
44
+ * @param runtime - the runtime whose config source follows the references.
45
+ * @param config - the Loader-delivered live config (references to the entry's values).
47
46
  */
48
- export function attachSettingsSection(ctx: Context, runtime: PermissionRulesRuntime, entry: PluginConfig): void {
47
+ export function attachSettingsSection(ctx: Context, runtime: PermissionRulesRuntime, config: LiveConfig): void {
49
48
  ctx.inject(['settings'], (scope) => {
50
- const settings = scope.get('settings') as SettingsProviderLike | undefined
51
- if (settings === undefined) return
52
- const registration = settings.register<PluginConfig>(SETTINGS_NAMESPACE, Config as z<PluginConfig>, {
53
- base: entry,
54
- expose: true,
55
- applies: 'live',
56
- validate: (value: PluginConfig) => {
57
- // Schemastery already enforced the closed enums; this re-runs the
58
- // numeric-bounds checks so an out-of-range stored value is refused
59
- // at write time instead of disabling the owner later.
60
- resolveConfig(value)
61
- },
62
- })
63
- runtime.setConfigSource(() => resolveConfig(registration.get()))
64
- registration.watch((next, prev) => {
65
- const nextConfig = resolveConfig(next) as ResolvedConfig
66
- const prevConfig = resolveConfig(prev) as ResolvedConfig
67
- const rebind = nextConfig.network.proxyPort !== prevConfig.network.proxyPort
68
- || nextConfig.network.proxyBind !== prevConfig.network.proxyBind
69
- || nextConfig.network.injectEnv !== prevConfig.network.injectEnv
70
- || nextConfig.network.noProxy !== prevConfig.network.noProxy
71
- if (rebind) void runtime.onNetworkConfigChanged()
72
- })
49
+ const settings = scope.get('settings') as SettingsLike | undefined
50
+ if (settings?.configure === undefined) return
51
+ // The policy belongs to THIS plugin instance's fiber, and riding an effect
52
+ // means a late-loading or replaced Settings service still picks it up and
53
+ // an unload withdraws it.
54
+ scope.effect(() => settings.configure({ auto: false }, ctx.fiber))
55
+ })
56
+
57
+ // LAZY on purpose: `resolveConfig` unwraps the references on every call, so
58
+ // a live edit is visible to the very next read. Resolving once here instead
59
+ // would freeze the config at mount and silently break every later change —
60
+ // which is exactly what the rebind listener below exists to serve.
61
+ runtime.setConfigSource(() => resolveConfig(config))
62
+
63
+ // A volatile-only update does NOT remount the plugin (the Loader commits the
64
+ // references and emits), so every bind-time side effect has to be
65
+ // reconciled here. Only the four knobs below decide the proxy's socket, its
66
+ // injected environment, and the NO_PROXY policy; every other field is read
67
+ // live through the config source, so a mode or rule-file change needs no
68
+ // work at all.
69
+ let previous = resolveConfig(config)
70
+ ctx.on('loader/volatile-update', () => {
71
+ let next: ResolvedConfig
72
+ try {
73
+ next = resolveConfig(config)
74
+ } catch (error: unknown) {
75
+ // Every reference is already committed when this fires, and the Loader
76
+ // catches and warns on a listener throw anyway — reporting it in this
77
+ // plugin's own vocabulary is the only signal an operator gets that a
78
+ // live edit left the config unusable.
79
+ ctx.logger.warn(`permission-rules: live config update is not usable: ${error instanceof Error ? error.message : String(error)}`)
80
+ return
81
+ }
82
+ const rebind = next.network.proxyPort !== previous.network.proxyPort
83
+ || next.network.proxyBind !== previous.network.proxyBind
84
+ || next.network.injectEnv !== previous.network.injectEnv
85
+ || next.network.noProxy !== previous.network.noProxy
86
+ previous = next
87
+ if (rebind) void runtime.onNetworkConfigChanged()
73
88
  })
74
89
  }
package/src/wire.ts CHANGED
@@ -12,14 +12,15 @@ import { z } from 'zod'
12
12
  import type { InvocationDescriptor } from '@deepseek-ai/dsh-typert-protocol'
13
13
 
14
14
  /**
15
- * Strict wire codec carrying BOTH published and checkout faces: the `schema`
16
- * field feeds the npm-published 0.1.5-rc.2 line, while `create` feeds the
17
- * 0.1.6-alpha.2+ line, whose typert-loader requires the factory on every
18
- * codec and materializes schemas lazily on first use. Built through a
19
- * variable, so neither typecheck ruler flags the other face's field as excess.
15
+ * Strict wire codec. `create` is the ONLY schema face the `0.1.7-alpha`
16
+ * `TypertCodec` declares — it materializes the process-realm schema on first
17
+ * boundary use — and the typert-loader rejects any codec without it. The
18
+ * older published lines also carried a literal `schema` field on the codec;
19
+ * that face is gone from the contract, so carrying it here would be dead
20
+ * weight (and only the `create` factory is ever consulted at run time).
20
21
  */
21
22
  function strictWire<T>(typeSymbol: string, schema: T) {
22
- return Object.freeze({ ...{ mode: 'strict' as const, typeSymbol, schema }, create: () => schema })
23
+ return Object.freeze({ mode: 'strict' as const, typeSymbol, create: () => schema })
23
24
  }
24
25
 
25
26
  /** The three network policy modes (wire vocabulary). */