dsh-server-connector 0.2.3 → 0.3.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/lib/index.js CHANGED
@@ -1,266 +1,316 @@
1
- // dsh-server-connector — Host half.
2
- //
3
- // Unit map (doc §2.1):
4
- //
5
- // policy.js built-in high-risk catalog + per-server decisions (§4)
6
- // registry.js servers.json CRUD (§3.1)
7
- // known-hosts.js known_hosts.json fingerprints (§3.2)
8
- // pool.js ssh2 connection pool + execution (§7)
9
- // audit.js audit.log JSONL append/tail (§3.4)
10
- // pending.js pending.json two-step queue (§3.3, §8.3)
11
- // tools.js the six srv_* tools + the §5.3 system-prompt section (§5, §6)
12
- //
13
- // This entry wires those units, the plugin's lifecycle and the Client half's
14
- // JSON routes (doc §8.5, lib/routes.js). The browser bundle itself is
15
- // lib/client.js, loaded by the client module system, not by this file.
16
- //
17
- // Config MUST be a @deepseek-ai/schemastery schema, never zod: the loader
18
- // rejects a row whose Config is a foreign schema (dsh-remote records this as
19
- // "zod rejects the undefined row config").
20
-
21
- import z from '@deepseek-ai/schemastery'
22
-
23
- import { AuditLog, defaultAuditFile } from './audit.js'
24
- import { KnownHosts, defaultKnownHostsFile } from './known-hosts.js'
25
- import { PendingQueue, createPendingApplier, defaultPendingFile } from './pending.js'
26
- import { Pool } from './pool.js'
27
- import { Registry, defaultServersFile } from './registry.js'
28
- import { registerRoutes } from './routes.js'
29
- import { TOOL_NAMES, ToolSurface } from './tools.js'
30
-
31
- export const name = 'dsh-server-connector'
32
-
33
- // Hard dependencies of the finished plugin (doc §9.4). Declared from phase 1 so
34
- // the row's requirements are visible; credentials / jobs / webServer are read
35
- // optionally with ctx.get() once those units exist.
36
- export const inject = ['tools', 'systemPrompt']
37
-
38
- /** Global configuration (doc §10). Every key has a default, so an empty row config validates. */
39
- export const Config = z.object({
40
- /** SSH connection timeout. */
41
- connectTimeoutMs: z.number().step(1).min(1000).default(15000),
42
- /** Default timeout of one command; a tool argument may override it. */
43
- commandTimeoutMs: z.number().step(1).min(1000).default(60000),
44
- /** A synchronous call longer than this becomes a ctx.jobs job. */
45
- syncTimeoutMs: z.number().step(1).min(1000).default(20000),
46
- /** Per-stream output ceiling, applied to stdout and to stderr each; beyond it that stream is marked TRUNCATED. */
47
- maxOutputChars: z.number().step(1).min(1024).default(200000),
48
- /** Idle timeout after which the pool closes a connection. */
49
- idleTimeoutMs: z.number().step(1).min(1000).default(600000),
50
- /** Whether to append a line to audit.log for every srv_exec. */
51
- auditEnabled: z.boolean().default(true),
52
- /** Rotate the audit log once it exceeds this size; 0 disables rotation. */
53
- auditMaxBytes: z.number().step(1).min(0).default(0),
54
- /** Lifetime of a pending (two-step) dangerous operation. */
55
- pendingTtlHours: z.number().step(1).min(1).default(24),
56
- /** How many servers the system prompt lists before collapsing the rest. */
57
- maxListedServers: z.number().step(1).min(1).default(8),
58
- })
59
-
60
- /**
61
- * The validated configuration shape (doc §10).
62
- * @typedef {object} PluginConfig
63
- * @property {number} connectTimeoutMs
64
- * @property {number} commandTimeoutMs
65
- * @property {number} syncTimeoutMs
66
- * @property {number} maxOutputChars
67
- * @property {number} idleTimeoutMs
68
- * @property {boolean} auditEnabled
69
- * @property {number} auditMaxBytes
70
- * @property {number} pendingTtlHours
71
- * @property {number} maxListedServers
72
- */
73
-
74
- /**
75
- * Build the registry for these settings (doc §3.5).
76
- *
77
- * `file` exists for tests and for an embedding host that keeps the data
78
- * directory somewhere else; production code passes nothing and gets
79
- * `$DSH_HOME/server-connector/servers.json`.
80
- * @param {PluginConfig} [config] - unused today, accepted so every unit factory
81
- * has the same `(config, options)` signature.
82
- * @param {{file?: string, env?: NodeJS.ProcessEnv}} [options]
83
- * @returns {Registry}
84
- */
85
- export function buildRegistry(config, options = {}) {
86
- void config
87
- return new Registry({ file: options.file ?? defaultServersFile(options.env) })
88
- }
89
-
90
- /**
91
- * Build the audit log for these settings (doc §3.4, §6.2, §10).
92
- * `auditEnabled: false` keeps the log object usable but makes `append()` a
93
- * no-op, which is what the gate needs: the command still runs.
94
- * @param {PluginConfig} config - supplies `auditEnabled` and `auditMaxBytes`.
95
- * @param {{file?: string, env?: NodeJS.ProcessEnv}} [options]
96
- * @returns {AuditLog}
97
- */
98
- export function buildAuditLog(config, options = {}) {
99
- return new AuditLog({
100
- file: options.file ?? defaultAuditFile(options.env),
101
- enabled: config?.auditEnabled ?? true,
102
- maxBytes: config?.auditMaxBytes ?? 0,
103
- })
104
- }
105
-
106
- /**
107
- * Build the host fingerprint store for these settings (doc §3.2, §6.4).
108
- * @param {PluginConfig} [config] - unused today, accepted so every unit factory
109
- * has the same `(config, options)` signature.
110
- * @param {{file?: string, env?: NodeJS.ProcessEnv}} [options]
111
- * @returns {KnownHosts}
112
- */
113
- export function buildKnownHosts(config, options = {}) {
114
- void config
115
- return new KnownHosts({ file: options.file ?? defaultKnownHostsFile(options.env) })
116
- }
117
-
118
- /**
119
- * Build the SSH connection pool for these settings (doc §7, §10).
120
- *
121
- * `resolvePassword` is injected rather than looked up here: the pool must not
122
- * depend on `ctx.credentials` (doc §9.4 makes that service optional, and the
123
- * tools layer owns credential refs). When no resolver is passed, a password
124
- * server reports `NO_CREDENTIAL` instead of dialling with an empty password.
125
- * @param {PluginConfig} config - supplies the timeouts and output ceiling.
126
- * @param {{
127
- * knownHosts: KnownHosts,
128
- * resolvePassword?: (server: import('./registry.js').ServerRecord) => Promise<string|null>,
129
- * logger?: {info: Function, warn: Function, error: Function} | null,
130
- * }} options
131
- * @returns {Pool}
132
- */
133
- export function buildPool(config, options) {
134
- return new Pool({
135
- knownHosts: options.knownHosts,
136
- resolvePassword: options.resolvePassword,
137
- logger: options.logger ?? null,
138
- connectTimeoutMs: config?.connectTimeoutMs ?? undefined,
139
- commandTimeoutMs: config?.commandTimeoutMs ?? undefined,
140
- idleTimeoutMs: config?.idleTimeoutMs ?? undefined,
141
- maxOutputChars: config?.maxOutputChars ?? undefined,
142
- })
143
- }
144
-
145
- /**
146
- * Build the two-step confirmation queue for these settings (doc §3.3, §8.3).
147
- * @param {PluginConfig} config - supplies `pendingTtlHours`.
148
- * @param {{file?: string, env?: NodeJS.ProcessEnv, now?: () => Date}} [options]
149
- * @returns {PendingQueue}
150
- */
151
- export function buildPendingQueue(config, options = {}) {
152
- return new PendingQueue({
153
- file: options.file ?? defaultPendingFile(options.env),
154
- ttlHours: config?.pendingTtlHours ?? 24,
155
- ...(options.now === undefined ? {} : { now: options.now }),
156
- })
157
- }
158
-
159
- /**
160
- * Mount the plugin.
161
- * @param {import('@deepseek-ai/cordis').Context} ctx - the plugin's cordis context.
162
- * @param {PluginConfig} config - the validated {@link Config} value.
163
- * @returns {void}
164
- */
165
- export function apply(ctx, config) {
166
- const knownHosts = buildKnownHosts(config)
167
- const registry = buildRegistry(config)
168
- const audit = buildAuditLog(config)
169
- const pending = buildPendingQueue(config)
170
- const pool = buildPool(config, {
171
- knownHosts,
172
- logger: ctx.logger,
173
- // `credentials` is an optional service (doc §9.4): without it only key and
174
- // agent authentication work, which is exactly NO_CREDENTIAL's message.
175
- resolvePassword: async (server) => {
176
- if (server.auth.kind !== 'password') return null
177
- const credentials = ctx.get('credentials')
178
- if (credentials === undefined || credentials === null) return null
179
- const resolved = await credentials.resolve?.(server.auth.credentialRef)
180
- const value = typeof resolved === 'string' ? resolved : resolved?.value
181
- return typeof value === 'string' && value !== '' ? value : null
182
- },
183
- })
184
-
185
- // Confirmed pending operations need the registry, the pool, the audit log and
186
- // (for a deleted password server) the credentials service. The applier is
187
- // injected so lib/pending.js stays free of those units (doc §2.2).
188
- pending.setApplier(
189
- createPendingApplier({
190
- registry,
191
- pool,
192
- audit,
193
- getCredentials: () => ctx.get('credentials'),
194
- logger: ctx.logger,
195
- }),
196
- )
197
-
198
- // `approval` (doc §6.1) and `jobs` (doc §5.4) are optional services: without
199
- // them a confirmation fails closed and every command runs synchronously.
200
- const surface = new ToolSurface({
201
- registry,
202
- pool,
203
- audit,
204
- approval: ctx.get('approval') ?? null,
205
- jobs: ctx.get('jobs') ?? null,
206
- config,
207
- logger: ctx.logger,
208
- })
209
- surface.register(ctx)
210
- surface.registerSystemPrompt(ctx)
211
-
212
- // The system-prompt provider is synchronous, so it serves this cache; refresh
213
- // it at startup and after the registry changes.
214
- const refresh = () => {
215
- surface.refresh().catch((error) => {
216
- ctx.logger.warn('dsh-server-connector: could not read servers.json: %s', String(error))
217
- })
218
- }
219
- refresh()
220
-
221
- // Two-step operations expire on their own (doc §8.3: "进程启动时与每次读写时
222
- // 各检查一次"); the startup sweep reports what it discarded.
223
- void pending
224
- .expire()
225
- .then((discarded) => {
226
- if (discarded.length === 0) return
227
- ctx.logger.info('dsh-server-connector: discarded %d expired pending operation(s)', discarded.length)
228
- })
229
- .catch((error) => {
230
- ctx.logger.warn('dsh-server-connector: could not read pending.json: %s', String(error))
231
- })
232
-
233
- // A pool holds live sockets and idle timers; the plugin row must release both
234
- // when it is unloaded, disabled or replaced. `ctx.effect` is cordis's
235
- // lifecycle hook (an 'error'-style dispose event does not exist).
236
- ctx.effect(() => () => pool.dispose(), 'dsh-server-connector: ssh pool')
237
-
238
- // The Client half's JSON routes (doc §8.5). `webServer` is an optional service
239
- // (§9.4): a headless composition keeps the tools and simply has no panel. The
240
- // panel's own changes go through the same registry, so the system-prompt cache
241
- // is refreshed from there or the model would keep seeing the old server list.
242
- ctx.inject(['webServer'], (webCtx) => {
243
- const { dispose } = registerRoutes(webCtx, {
244
- registry,
245
- pool,
246
- audit,
247
- pending,
248
- getCredentials: () => webCtx.get('credentials'),
249
- config,
250
- onChange: async () => {
251
- await surface.refresh()
252
- },
253
- logger: webCtx.logger,
254
- })
255
- webCtx.effect(() => dispose, 'dsh-server-connector: json routes')
256
- })
257
-
258
- ctx.logger.info(
259
- 'dsh-server-connector: %d srv_* tools registered (approval=%s, jobs=%s, credentials=%s, routes=%s)',
260
- TOOL_NAMES.length,
261
- ctx.get('approval') === undefined ? 'unavailable' : 'available',
262
- ctx.get('jobs') === undefined ? 'unavailable (synchronous only)' : 'available',
263
- ctx.get('credentials') === undefined ? 'unavailable (key/agent auth only)' : 'available',
264
- ctx.get('webServer') === undefined ? 'unavailable (no panel)' : 'available',
265
- )
266
- }
1
+ // dsh-server-connector — Host half.
2
+ //
3
+ // Unit map (doc §2.1):
4
+ //
5
+ // policy.js built-in high-risk catalog + per-server decisions (§4)
6
+ // registry.js servers.json CRUD (§3.1)
7
+ // known-hosts.js known_hosts.json fingerprints (§3.2)
8
+ // pool.js ssh2 connection pool + execution (§7)
9
+ // audit.js audit.log JSONL append/tail (§3.4)
10
+ // pending.js pending.json two-step queue (§3.3, §8.3)
11
+ // tools.js the six srv_* tools + the §5.3 system-prompt section (§5, §6)
12
+ //
13
+ // This entry wires those units, the plugin's lifecycle and the Client half's
14
+ // JSON routes (doc §8.5, lib/routes.js). The browser bundle itself is
15
+ // lib/client.js, loaded by the client module system, not by this file.
16
+ //
17
+ // Config MUST be a @deepseek-ai/schemastery schema, never zod: the loader
18
+ // rejects a row whose Config is a foreign schema (dsh-remote records this as
19
+ // "zod rejects the undefined row config").
20
+
21
+ import z from '@deepseek-ai/schemastery'
22
+
23
+ import { AuditLog, defaultAuditFile } from './audit.js'
24
+ import { readConfig } from './config.js'
25
+ import { KnownHosts, defaultKnownHostsFile } from './known-hosts.js'
26
+ import { PendingQueue, createPendingApplier, defaultPendingFile } from './pending.js'
27
+ import { Pool } from './pool.js'
28
+ import { Registry, defaultServersFile } from './registry.js'
29
+ import { registerRoutes } from './routes.js'
30
+ import { TOOL_NAMES, ToolSurface } from './tools.js'
31
+
32
+ export const name = 'dsh-server-connector'
33
+
34
+ // Hard dependencies of the finished plugin (doc §9.4). Declared from phase 1 so
35
+ // the row's requirements are visible; credentials / jobs / webServer are read
36
+ // optionally with ctx.get() once those units exist.
37
+ export const inject = ['tools', 'systemPrompt']
38
+
39
+ /**
40
+ * Global configuration (doc §10). Every key has a default, so an empty row config validates.
41
+ *
42
+ * Every key is `.volatile()`, and that is not decoration. DSH 0.2.2 exposes a
43
+ * plugin's settings form only through `volatileForm()`, which keeps a field only
44
+ * when it is marked volatile (`dsh-settings/lib/index.js`); a schema with no
45
+ * volatile field projects to `undefined`, the settings page lists no namespace
46
+ * for this row, and `ctx.configForms.get('dsh-server-connector')` stays
47
+ * `unavailable` forever. That is why the settings page used to show nine
48
+ * read-only numbers with no way to edit them — the row was never projected.
49
+ *
50
+ * A volatile field resolves to a cosmokit *cell*, not to its value: the loader
51
+ * writes an edit into that same cell in place and emits `loader/volatile-update`
52
+ * instead of remounting the plugin. Read every value through `readConfig`
53
+ * (lib/config.js), and treat the plugin's config object as a live view.
54
+ */
55
+ export const Config = z.object({
56
+ /** SSH connection timeout. */
57
+ connectTimeoutMs: z.number().step(1).min(1000).default(15000).volatile(),
58
+ /** Default timeout of one command; a tool argument may override it. */
59
+ commandTimeoutMs: z.number().step(1).min(1000).default(60000).volatile(),
60
+ /** A synchronous call longer than this becomes a ctx.jobs job. */
61
+ syncTimeoutMs: z.number().step(1).min(1000).default(20000).volatile(),
62
+ /** Per-stream output ceiling, applied to stdout and to stderr each; beyond it that stream is marked TRUNCATED. */
63
+ maxOutputChars: z.number().step(1).min(1024).default(200000).volatile(),
64
+ /** Idle timeout after which the pool closes a connection. */
65
+ idleTimeoutMs: z.number().step(1).min(1000).default(600000).volatile(),
66
+ /** Whether to append a line to audit.log for every srv_exec. */
67
+ auditEnabled: z.boolean().default(true).volatile(),
68
+ /** Rotate the audit log once it exceeds this size; 0 disables rotation. */
69
+ auditMaxBytes: z.number().step(1).min(0).default(0).volatile(),
70
+ /** Lifetime of a pending (two-step) dangerous operation. */
71
+ pendingTtlHours: z.number().step(1).min(1).default(24).volatile(),
72
+ /** How many servers the system prompt lists before collapsing the rest. */
73
+ maxListedServers: z.number().step(1).min(1).default(8).volatile(),
74
+ })
75
+
76
+ export { CONFIG_DEFAULTS, CONFIG_KEYS, readConfig, unbox } from './config.js'
77
+
78
+ /**
79
+ * The resolved configuration shape (doc §10) — plain values, as {@link readConfig}
80
+ * returns them.
81
+ * @typedef {object} PluginConfig
82
+ * @property {number} connectTimeoutMs
83
+ * @property {number} commandTimeoutMs
84
+ * @property {number} syncTimeoutMs
85
+ * @property {number} maxOutputChars
86
+ * @property {number} idleTimeoutMs
87
+ * @property {boolean} auditEnabled
88
+ * @property {number} auditMaxBytes
89
+ * @property {number} pendingTtlHours
90
+ * @property {number} maxListedServers
91
+ */
92
+
93
+ /**
94
+ * The configuration object the loader hands `apply`.
95
+ *
96
+ * Every field is `.volatile()`, so schemastery resolves each one to a cosmokit
97
+ * cell rather than to its value — the type is therefore deliberately opaque, and
98
+ * {@link readConfig} is the only sanctioned reader.
99
+ * @typedef {Record<string, unknown>} RawConfig
100
+ */
101
+
102
+ /**
103
+ * Build the registry for these settings (doc §3.5).
104
+ *
105
+ * `file` exists for tests and for an embedding host that keeps the data
106
+ * directory somewhere else; production code passes nothing and gets
107
+ * `$DSH_HOME/server-connector/servers.json`.
108
+ * @param {RawConfig} [config] - unused today, accepted so every unit factory
109
+ * has the same `(config, options)` signature.
110
+ * @param {{file?: string, env?: NodeJS.ProcessEnv}} [options]
111
+ * @returns {Registry}
112
+ */
113
+ export function buildRegistry(config, options = {}) {
114
+ void config
115
+ return new Registry({ file: options.file ?? defaultServersFile(options.env) })
116
+ }
117
+
118
+ /**
119
+ * Build the audit log for these settings (doc §3.4, §6.2, §10).
120
+ * `auditEnabled: false` keeps the log object usable but makes `append()` a
121
+ * no-op, which is what the gate needs: the command still runs.
122
+ * @param {RawConfig} config - supplies `auditEnabled` and `auditMaxBytes`.
123
+ * @param {{file?: string, env?: NodeJS.ProcessEnv}} [options]
124
+ * @returns {AuditLog}
125
+ */
126
+ export function buildAuditLog(config, options = {}) {
127
+ const values = readConfig(config)
128
+ return new AuditLog({
129
+ file: options.file ?? defaultAuditFile(options.env),
130
+ enabled: values.auditEnabled,
131
+ maxBytes: values.auditMaxBytes,
132
+ })
133
+ }
134
+
135
+ /**
136
+ * Build the host fingerprint store for these settings (doc §3.2, §6.4).
137
+ * @param {RawConfig} [config] - unused today, accepted so every unit factory
138
+ * has the same `(config, options)` signature.
139
+ * @param {{file?: string, env?: NodeJS.ProcessEnv}} [options]
140
+ * @returns {KnownHosts}
141
+ */
142
+ export function buildKnownHosts(config, options = {}) {
143
+ void config
144
+ return new KnownHosts({ file: options.file ?? defaultKnownHostsFile(options.env) })
145
+ }
146
+
147
+ /**
148
+ * Build the SSH connection pool for these settings (doc §7, §10).
149
+ *
150
+ * `resolvePassword` is injected rather than looked up here: the pool must not
151
+ * depend on `ctx.credentials` (doc §9.4 makes that service optional, and the
152
+ * tools layer owns credential refs). When no resolver is passed, a password
153
+ * server reports `NO_CREDENTIAL` instead of dialling with an empty password.
154
+ * @param {RawConfig} config - supplies the timeouts and output ceiling.
155
+ * @param {{
156
+ * knownHosts: KnownHosts,
157
+ * resolvePassword?: (server: import('./registry.js').ServerRecord) => Promise<string|null>,
158
+ * logger?: {info: Function, warn: Function, error: Function} | null,
159
+ * }} options
160
+ * @returns {Pool}
161
+ */
162
+ export function buildPool(config, options) {
163
+ const values = readConfig(config)
164
+ return new Pool({
165
+ knownHosts: options.knownHosts,
166
+ resolvePassword: options.resolvePassword,
167
+ logger: options.logger ?? null,
168
+ connectTimeoutMs: values.connectTimeoutMs,
169
+ commandTimeoutMs: values.commandTimeoutMs,
170
+ idleTimeoutMs: values.idleTimeoutMs,
171
+ maxOutputChars: values.maxOutputChars,
172
+ })
173
+ }
174
+
175
+ /**
176
+ * Build the two-step confirmation queue for these settings (doc §3.3, §8.3).
177
+ * @param {RawConfig} config - supplies `pendingTtlHours`.
178
+ * @param {{file?: string, env?: NodeJS.ProcessEnv, now?: () => Date}} [options]
179
+ * @returns {PendingQueue}
180
+ */
181
+ export function buildPendingQueue(config, options = {}) {
182
+ return new PendingQueue({
183
+ file: options.file ?? defaultPendingFile(options.env),
184
+ ttlHours: readConfig(config).pendingTtlHours,
185
+ ...(options.now === undefined ? {} : { now: options.now }),
186
+ })
187
+ }
188
+
189
+ /**
190
+ * Mount the plugin.
191
+ * @param {import('@deepseek-ai/cordis').Context} ctx - the plugin's cordis context.
192
+ * @param {RawConfig} config - the validated {@link Config} value (volatile cells).
193
+ * @returns {void}
194
+ */
195
+ export function apply(ctx, config) {
196
+ const knownHosts = buildKnownHosts(config)
197
+ const registry = buildRegistry(config)
198
+ const audit = buildAuditLog(config)
199
+ const pending = buildPendingQueue(config)
200
+ const pool = buildPool(config, {
201
+ knownHosts,
202
+ logger: ctx.logger,
203
+ // `credentials` is an optional service (doc §9.4): without it only key and
204
+ // agent authentication work, which is exactly NO_CREDENTIAL's message.
205
+ resolvePassword: async (server) => {
206
+ if (server.auth.kind !== 'password') return null
207
+ const credentials = ctx.get('credentials')
208
+ if (credentials === undefined || credentials === null) return null
209
+ const resolved = await credentials.resolve?.(server.auth.credentialRef)
210
+ const value = typeof resolved === 'string' ? resolved : resolved?.value
211
+ return typeof value === 'string' && value !== '' ? value : null
212
+ },
213
+ })
214
+
215
+ // Confirmed pending operations need the registry, the pool, the audit log and
216
+ // (for a deleted password server) the credentials service. The applier is
217
+ // injected so lib/pending.js stays free of those units (doc §2.2).
218
+ pending.setApplier(
219
+ createPendingApplier({
220
+ registry,
221
+ pool,
222
+ audit,
223
+ getCredentials: () => ctx.get('credentials'),
224
+ logger: ctx.logger,
225
+ }),
226
+ )
227
+
228
+ // `approval` (doc §6.1) and `jobs` (doc §5.4) are optional services: without
229
+ // them a confirmation fails closed and every command runs synchronously.
230
+ const surface = new ToolSurface({
231
+ registry,
232
+ pool,
233
+ audit,
234
+ approval: ctx.get('approval') ?? null,
235
+ jobs: ctx.get('jobs') ?? null,
236
+ config,
237
+ logger: ctx.logger,
238
+ })
239
+ surface.register(ctx)
240
+ surface.registerSystemPrompt(ctx)
241
+
242
+ // The system-prompt provider is synchronous, so it serves this cache; refresh
243
+ // it at startup and after the registry changes.
244
+ const refresh = () => {
245
+ surface.refresh().catch((error) => {
246
+ ctx.logger.warn('dsh-server-connector: could not read servers.json: %s', String(error))
247
+ })
248
+ }
249
+ refresh()
250
+
251
+ // Two-step operations expire on their own (doc §8.3: "进程启动时与每次读写时
252
+ // 各检查一次"); the startup sweep reports what it discarded.
253
+ void pending
254
+ .expire()
255
+ .then((discarded) => {
256
+ if (discarded.length === 0) return
257
+ ctx.logger.info('dsh-server-connector: discarded %d expired pending operation(s)', discarded.length)
258
+ })
259
+ .catch((error) => {
260
+ ctx.logger.warn('dsh-server-connector: could not read pending.json: %s', String(error))
261
+ })
262
+
263
+ // A pool holds live sockets and idle timers; the plugin row must release both
264
+ // when it is unloaded, disabled or replaced. `ctx.effect` is cordis's
265
+ // lifecycle hook (an 'error'-style dispose event does not exist).
266
+ ctx.effect(() => () => pool.dispose(), 'dsh-server-connector: ssh pool')
267
+
268
+ // A volatile settings edit is written into the config object the loader gave
269
+ // this row and then announced — the plugin is NOT remounted, and `apply` is
270
+ // not called again. So every unit that cached a value has to be handed the new
271
+ // ones here, or the settings page would save and change nothing. The units
272
+ // read their other values at call time already (see `readConfig`).
273
+ //
274
+ // The event is emitted by the loader (`_commitVolatile` in dsh-loader), not
275
+ // declared in this plugin's own event map, hence the cast.
276
+ const onVolatileUpdate = /** @type {(name: any, listener: () => void, label?: string) => void} */ (ctx.on.bind(ctx))
277
+ onVolatileUpdate('loader/volatile-update', () => {
278
+ try {
279
+ const values = readConfig(config)
280
+ pool.configure(values)
281
+ audit.configure({ enabled: values.auditEnabled, maxBytes: values.auditMaxBytes })
282
+ pending.configure(values.pendingTtlHours)
283
+ } catch (error) {
284
+ ctx.logger.warn('dsh-server-connector: applying a live settings change failed: %s', String(error))
285
+ }
286
+ }, 'dsh-server-connector: live settings')
287
+
288
+ // The Client half's JSON routes (doc §8.5). `webServer` is an optional service
289
+ // (§9.4): a headless composition keeps the tools and simply has no panel. The
290
+ // panel's own changes go through the same registry, so the system-prompt cache
291
+ // is refreshed from there or the model would keep seeing the old server list.
292
+ ctx.inject(['webServer'], (webCtx) => {
293
+ const { dispose } = registerRoutes(webCtx, {
294
+ registry,
295
+ pool,
296
+ audit,
297
+ pending,
298
+ getCredentials: () => webCtx.get('credentials'),
299
+ config,
300
+ onChange: async () => {
301
+ await surface.refresh()
302
+ },
303
+ logger: webCtx.logger,
304
+ })
305
+ webCtx.effect(() => dispose, 'dsh-server-connector: json routes')
306
+ })
307
+
308
+ ctx.logger.info(
309
+ 'dsh-server-connector: %d srv_* tools registered (approval=%s, jobs=%s, credentials=%s, routes=%s)',
310
+ TOOL_NAMES.length,
311
+ ctx.get('approval') === undefined ? 'unavailable' : 'available',
312
+ ctx.get('jobs') === undefined ? 'unavailable (synchronous only)' : 'available',
313
+ ctx.get('credentials') === undefined ? 'unavailable (key/agent auth only)' : 'available',
314
+ ctx.get('webServer') === undefined ? 'unavailable (no panel)' : 'available',
315
+ )
316
+ }