dsh-server-connector 0.2.2 → 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/tools.js CHANGED
@@ -1,1357 +1,1367 @@
1
- // dsh-server-connector — the agent-facing tool surface (design doc §5, §6).
2
- //
3
- // Six `srv_*` tools, the system-prompt section that advertises the configured
4
- // servers (§5.3), and the three gates every execution goes through (§6.1):
5
- //
6
- // srv_exec(command)
7
- // ├─① policy.check(command, server.policy, server.disabledRules)
8
- // │ block → POLICY_BLOCKED, no connection, no execution
9
- // │ confirm → ②
10
- // │ allow → ③
11
- // ├─② ctx.approval.request({agent, toolName: 'srv_exec', reason})
12
- // │ allowed-once → ③
13
- // │ rejected/cancelled/unavailable → POLICY_DENIED_BY_USER
14
- // └─③ execute through the pool → audit → result
15
- //
16
- // This module is the only model-visible face of the plugin. It orchestrates the
17
- // units below it and owns no storage of its own: the registry (§3.1), the pool
18
- // (§7), the policy engine (§4) and the audit log (§3.4) are injected.
19
- //
20
- // Two deliberate decisions, both visible to the model and documented in the PR:
21
- //
22
- // 1. **Policy/approval/transport failures are thrown; a nonzero exit and a
23
- // timeout are results.** §5.5 lists `EXIT_NONZERO` and `TIMEOUT` among the
24
- // error types, but throwing would throw away the stdout/stderr the model
25
- // needs to act on. Both are therefore reported as a typed `code` field
26
- // (`EXIT_NONZERO` / `TIMEOUT`) on the normal result, whose rendered text
27
- // leads with the code and a next step. Everything that *prevented* the
28
- // command from running or from reporting (`POLICY_BLOCKED`,
29
- // `POLICY_DENIED_BY_USER`, `CONNECT_FAILED`, `HOST_KEY_UNKNOWN`,
30
- // `AUTH_FAILED`, `NO_CREDENTIAL`, `TIMEOUT` before any output, …) is thrown
31
- // as a {@link ToolError} whose message starts with the §5.5 code.
32
- //
33
- // 2. **Long commands are promoted, not migrated.** `async: true` or a call that
34
- // outlives `syncTimeoutMs` returns a `ctx.jobs` job id that keeps the very
35
- // same SSH channel open. A foreground command therefore never runs twice and
36
- // never loses output it already emitted. Without `ctx.jobs` (§9.4 optional
37
- // service) execution degrades to a synchronous call bounded by `timeoutMs`.
38
-
39
- import { defineTool } from '@deepseek-ai/dsh-tools'
40
-
41
- import { RULES } from './policy.js'
42
- import { check as checkPolicy } from './policy.js'
43
-
44
- /** The six tool names of doc §5.1, in registration order. */
45
- export const TOOL_NAMES = Object.freeze([
46
- 'srv_list',
47
- 'srv_connect',
48
- 'srv_exec',
49
- 'srv_job_output',
50
- 'srv_job_kill',
51
- 'srv_policy_check',
52
- ])
53
-
54
- /**
55
- * Machine-readable error codes (§5.5). `INVALID_ARGUMENT` is the one addition:
56
- * it marks a malformed tool argument, which the §5.5 table does not enumerate
57
- * because it describes transport and policy failures.
58
- * @typedef {'CONNECT_FAILED'|'HOST_KEY_UNKNOWN'|'AUTH_FAILED'|'POLICY_BLOCKED'|'POLICY_DENIED_BY_USER'|'TIMEOUT'|'EXIT_NONZERO'|'UNKNOWN_SERVER'|'AMBIGUOUS_SERVER'|'SERVER_DISABLED'|'NO_CREDENTIAL'|'INVALID_ARGUMENT'} ToolErrorCode
59
- */
60
-
61
- /**
62
- * A tool failure the model can read. The message always starts with the code so
63
- * the model can branch on it without parsing prose (§5.5: "每种都带一句可执行的
64
- * 下一步提示").
65
- */
66
- export class ToolError extends Error {
67
- /**
68
- * @param {ToolErrorCode} code
69
- * @param {string} detail - what happened, in one sentence.
70
- * @param {string} [next] - the actionable next step.
71
- */
72
- constructor(code, detail, next) {
73
- super(`${code}: ${detail}${next === undefined || next === '' ? '' : ` ${next}`}`)
74
- this.name = 'ToolError'
75
- /** @type {ToolErrorCode} */
76
- this.code = code
77
- }
78
- }
79
-
80
- /**
81
- * The probe `srv_connect` runs after the handshake: platform, shell and sudo
82
- * availability in one round trip (§5.1). Every line is `key=value`; an unknown
83
- * key is ignored, a missing one reports `unknown`. The trailing `true` keeps the
84
- * exit code 0 so a `grep`-style failure inside the probe is not mistaken for a
85
- * failed connection.
86
- * @type {string}
87
- */
88
- export const CONNECT_PROBE = [
89
- '. /etc/os-release 2>/dev/null; printf "os=%s\\n" "${PRETTY_NAME:-${ID:-unknown}}"',
90
- 'printf "kernel=%s\\n" "$(uname -srm 2>/dev/null || echo unknown)"',
91
- 'printf "shell=%s\\n" "${SHELL:-/bin/sh}"',
92
- 'if command -v sudo >/dev/null 2>&1; then',
93
- ' if sudo -n true >/dev/null 2>&1; then echo "sudo=passwordless"; else echo "sudo=password (needs a password)"; fi',
94
- 'else echo "sudo=absent"; fi',
95
- 'true',
96
- ].join('; ')
97
-
98
- /**
99
- * Parse the {@link CONNECT_PROBE} output into the fields the tool returns.
100
- * @param {string} stdout
101
- * @returns {{platform: string, kernel: string, shell: string, sudo: string}}
102
- */
103
- export function parseProbe(stdout) {
104
- /** @type {Record<string, string>} */
105
- const fields = {}
106
- for (const line of stdout.split('\n')) {
107
- const index = line.indexOf('=')
108
- if (index <= 0) continue
109
- const key = line.slice(0, index).trim()
110
- const value = line.slice(index + 1).trim()
111
- if (key !== '' && value !== '') fields[key] = value
112
- }
113
- return {
114
- platform: fields.os ?? 'unknown',
115
- kernel: fields.kernel ?? 'unknown',
116
- shell: fields.shell ?? 'unknown',
117
- sudo: fields.sudo ?? 'unknown',
118
- }
119
- }
120
-
121
- /**
122
- * Render the §5.3 system-prompt section for the configured servers.
123
- *
124
- * Returns an empty string when nothing is configured: the section then
125
- * contributes nothing (§5.3: "仅在已配置服务器存在时出现"), and an empty section
126
- * is how `ctx.systemPrompt.section` spells "nothing to say".
127
- * @param {import('./registry.js').ServerRecord[]} servers
128
- * @param {{maxListedServers?: number}} [options]
129
- * @returns {string}
130
- */
131
- export function renderServerSection(servers, options = {}) {
132
- if (servers.length === 0) return ''
133
- const max = options.maxListedServers ?? 8
134
- const listed = servers.slice(0, max)
135
- const lines = ['可运维服务器(调用 srv_exec 时必须传 server):']
136
- for (const server of listed) {
137
- const workdir = server.defaultWorkDir === null ? '(无默认目录)' : server.defaultWorkDir
138
- lines.push(
139
- `- ${server.id} ${server.name} ${server.username}@${server.host}:${server.port} 默认目录 ${workdir}`,
140
- )
141
- }
142
- const rest = servers.length - listed.length
143
- if (rest > 0) lines.push(`…另有 ${rest} 台,调用 srv_list 查看全部`)
144
-
145
- // Per-server overrides are what an operator actually configured; when every
146
- // server inherits the built-in catalog, summarise the catalog instead of
147
- // claiming an override that does not exist.
148
- /** @type {Map<string, Set<string>>} */
149
- const overrides = new Map()
150
- for (const server of servers) {
151
- for (const [ruleId, decision] of Object.entries(server.policy)) {
152
- const key = `${ruleId}=${decision}`
153
- if (!overrides.has(key)) overrides.set(key, new Set())
154
- overrides.get(key)?.add(server.id)
155
- }
156
- }
157
- if (overrides.size > 0) {
158
- const summary = [...overrides.keys()].sort().join('、')
159
- lines.push(`高危策略:${summary}(可用 srv_policy_check 预检)`)
160
- } else {
161
- const blocked = RULES.filter((rule) => rule.defaultDecision === 'block').length
162
- const confirmed = RULES.length - blocked
163
- lines.push(
164
- `高危策略:内置 ${RULES.length} 条规则(${blocked} 条 block、${confirmed} 条 confirm),未做逐台覆盖(可用 srv_policy_check 预检)`,
165
- )
166
- }
167
- // Custom rules are counted, not listed (doc §4.4.4): the model needs to know a
168
- // server is stricter than the catalog, not what its private regexes say.
169
- const customCount = servers.reduce((total, server) => total + (server.customRules ?? []).length, 0)
170
- if (customCount > 0) lines.push(`另有 ${customCount} 条自定义规则(可用 srv_policy_check 预检具体命令)`)
171
- return lines.join('\n')
172
- }
173
-
174
- /**
175
- * Order of the §5.3 section in the assembled system prompt.
176
- *
177
- * Newer DSH releases allocate section orders centrally
178
- * (`ctx.systemPrompt.getSectionOrder('MCP_SERVERS')`); the release candidates
179
- * this package targets expose only plain numeric orders. Both are supported so
180
- * the plugin does not pin one harness build: the fallback numbers are the ones
181
- * `SECTION_ORDERS` assigns (`MCP_SERVERS = 3100`, "other tool guidance").
182
- * @param {any} ctx
183
- * @param {keyof typeof FALLBACK_SECTION_ORDERS} name
184
- * @returns {number}
185
- */
186
- export function systemPromptOrder(ctx, name) {
187
- const resolveOrder = ctx?.systemPrompt?.getSectionOrder
188
- if (typeof resolveOrder === 'function') {
189
- try {
190
- const order = resolveOrder.call(ctx.systemPrompt, name)
191
- if (typeof order === 'number' && Number.isFinite(order)) return order
192
- } catch {
193
- // Fall through to the literal order below.
194
- }
195
- }
196
- return FALLBACK_SECTION_ORDERS[name]
197
- }
198
-
199
- /**
200
- * The `SECTION_ORDERS` values this plugin uses, for harness builds without
201
- * `getSectionOrder`. Keep in sync with `@deepseek-ai/dsh-system-prompt`.
202
- * @type {{MCP_SERVERS: number}}
203
- */
204
- export const FALLBACK_SECTION_ORDERS = Object.freeze({
205
- MCP_SERVERS: 3100,
206
- })
207
-
208
- /**
209
- * Whether one approval outcome granted the action (§6.1: only `allowed-once`
210
- * executes).
211
- * @param {string} outcome
212
- * @returns {boolean}
213
- */
214
- export function isGranted(outcome) {
215
- return outcome === 'allowed-once'
216
- }
217
-
218
- /**
219
- * The session's effective approval policy, read without prompting anyone.
220
- *
221
- * `ApprovalService` computes it as "the session override, else the deployment
222
- * default". The public surface exposes neither directly (`overrideOf` is
223
- * declared private, `config` is public), so both are probed defensively: a
224
- * future shape change degrades to `'ask'`, which only affects the wording of a
225
- * rejection message, never whether something runs.
226
- * @param {any} approval - the `ctx.approval` service.
227
- * @param {any} agent - the calling agent.
228
- * @returns {'ask'|'never'}
229
- */
230
- export function approvalPolicyOf(approval, agent) {
231
- const session = agent?.session
232
- if (session !== undefined && typeof approval?.overrideOf === 'function') {
233
- try {
234
- const override = approval.overrideOf(session)
235
- if (override === 'ask' || override === 'never') return override
236
- } catch {
237
- // Fall through to the configured default.
238
- }
239
- }
240
- const configured = approval?.config?.policy
241
- return configured === 'never' ? 'never' : 'ask'
242
- }
243
-
244
- /**
245
- * Build the §6.1 approval reason: rule id + server name + the full command.
246
- * @param {{ruleId: string|null, ruleLabel?: string, server: import('./registry.js').ServerRecord, command: string}} input
247
- * @returns {string}
248
- */
249
- export function approvalReason(input) {
250
- const rule = input.ruleId === null ? 'server policy' : `rule ${input.ruleId}${input.ruleLabel ? ` (${input.ruleLabel})` : ''}`
251
- return `${rule} matched on server ${input.server.name} (${input.server.id}): ${input.command}`
252
- }
253
-
254
- /**
255
- * Render a job-read delta the way the model reads it: stdout in order, then the
256
- * stderr chunks in one `[stderr]` section, so a promoted call and the
257
- * `srv_job_output` reads that follow have the same shape.
258
- * @param {readonly {text: string, channel?: string}[]} chunks
259
- * @returns {string}
260
- */
261
- export function renderChunks(chunks) {
262
- const out = chunks.filter((chunk) => chunk.channel !== 'stderr').map((chunk) => chunk.text).join('')
263
- const err = chunks.filter((chunk) => chunk.channel === 'stderr').map((chunk) => chunk.text).join('')
264
- const separator = out.length > 0 && !out.endsWith('\n') ? '\n' : ''
265
- return out + (err.length > 0 ? `${separator}[stderr]\n${err}` : '')
266
- }
267
-
268
- /**
269
- * One line describing how a finished command ended. Kept separately from the
270
- * output so a test (and the model) can locate it deterministically.
271
- * @param {{exitCode: number|null, timedOut?: boolean, durationMs?: number}} result
272
- * @returns {string}
273
- */
274
- export function statusLine(result) {
275
- if (result.timedOut === true) {
276
- return 'TIMEOUT: the remote watchdog killed the process group; raise timeoutMs or run it with async: true.'
277
- }
278
- if (result.exitCode === null || result.exitCode === undefined) return 'exit: unknown'
279
- if (result.exitCode === 0) return 'exit 0'
280
- return `exit ${result.exitCode} (EXIT_NONZERO)`
281
- }
282
-
283
- /**
284
- * One `srv_exec` result, canonical shape (doc §5.4). `running` marks a promoted
285
- * call (poll with `srv_job_output`); `code` carries the §5.5 failure code for a
286
- * command that did run but did not succeed.
287
- * @typedef {object} SrvExecValue
288
- * @property {string} serverId
289
- * @property {string} serverName
290
- * @property {string} command
291
- * @property {'allow'|'confirm'} decision
292
- * @property {string} [policyRule]
293
- * @property {string} [jobId]
294
- * @property {boolean} [running]
295
- * @property {number} [exitCode]
296
- * @property {'EXIT_NONZERO'|'TIMEOUT'} [code]
297
- * @property {string} [stdout]
298
- * @property {string} [stderr]
299
- * @property {boolean} [truncated]
300
- * @property {number} [durationMs]
301
- * @property {string} [note]
302
- */
303
-
304
- /**
305
- * The model-visible tool surface. One instance per plugin process.
306
- *
307
- * `jobs` and `approval` are optional (§9.4): without `approval` every `confirm`
308
- * decision fails closed as `POLICY_DENIED_BY_USER`; without `jobs` every call
309
- * is synchronous.
310
- */
311
- export class ToolSurface {
312
- /** @type {import('./registry.js').Registry} */
313
- #registry
314
- /** @type {import('./pool.js').Pool} */
315
- #pool
316
- /** @type {import('./audit.js').AuditLog|null} */
317
- #audit
318
- /** @type {any} */
319
- #approval
320
- /** @type {any} */
321
- #jobs
322
- /** @type {import('./index.js').PluginConfig} */
323
- #config
324
- /** @type {{info?: Function, warn?: Function, error?: Function}|null} */
325
- #logger
326
- /** @type {import('./registry.js').ServerRecord[]} */
327
- #servers = []
328
- /** @type {boolean} */
329
- #loaded = false
330
-
331
- /**
332
- * @param {{
333
- * registry: import('./registry.js').Registry,
334
- * pool: import('./pool.js').Pool,
335
- * audit?: import('./audit.js').AuditLog|null,
336
- * approval?: any,
337
- * jobs?: any,
338
- * config?: Partial<import('./index.js').PluginConfig>,
339
- * logger?: {info?: Function, warn?: Function, error?: Function}|null,
340
- * }} options
341
- */
342
- constructor(options) {
343
- if (options?.registry === undefined || options.registry === null) {
344
- throw new TypeError('ToolSurface requires a registry.')
345
- }
346
- if (options?.pool === undefined || options.pool === null) {
347
- throw new TypeError('ToolSurface requires a pool.')
348
- }
349
- this.#registry = options.registry
350
- this.#pool = options.pool
351
- this.#audit = options.audit ?? null
352
- this.#approval = options.approval ?? null
353
- this.#jobs = options.jobs ?? null
354
- this.#config = /** @type {import('./index.js').PluginConfig} */ ({
355
- commandTimeoutMs: 60000,
356
- syncTimeoutMs: 20000,
357
- maxOutputChars: 200000,
358
- maxListedServers: 8,
359
- ...options.config,
360
- })
361
- this.#logger = options.logger ?? null
362
- }
363
-
364
- /** The cached server list used by the synchronous system-prompt provider. */
365
- get servers() {
366
- return this.#servers
367
- }
368
-
369
- /** Whether {@link refresh} has completed at least once. */
370
- get loaded() {
371
- return this.#loaded
372
- }
373
-
374
- /**
375
- * Re-read `servers.json` into the cache used by the system-prompt provider
376
- * (which must be synchronous, `ctx.systemPrompt.section` accepts no promise).
377
- * Called at startup and after every tool call; phase 5's HTTP routes call it
378
- * after a mutation so the prompt follows the panel.
379
- * @returns {Promise<import('./registry.js').ServerRecord[]>}
380
- */
381
- async refresh() {
382
- this.#servers = await this.#registry.list()
383
- this.#loaded = true
384
- return this.#servers
385
- }
386
-
387
- /**
388
- * Register the six tools on `ctx.tools` (§5.1).
389
- * @param {import('@deepseek-ai/cordis').Context} ctx
390
- * @returns {void}
391
- */
392
- register(ctx) {
393
- for (const definition of [
394
- this.#listTool(),
395
- this.#connectTool(),
396
- this.#execTool(),
397
- this.#jobOutputTool(),
398
- this.#jobKillTool(),
399
- this.#policyCheckTool(),
400
- ]) {
401
- ctx.tools.register(definition)
402
- }
403
- }
404
-
405
- /**
406
- * Register the §5.3 system-prompt section. The provider is synchronous, so it
407
- * serves the cache; the cache is refreshed at startup and after every tool
408
- * call.
409
- * @param {import('@deepseek-ai/cordis').Context} ctx
410
- * @returns {void}
411
- */
412
- registerSystemPrompt(ctx) {
413
- ctx.effect(() =>
414
- ctx.systemPrompt.section({
415
- name: 'dsh-server-connector:servers',
416
- // Next to the MCP server list: both describe systems reachable from this
417
- // deployment, and neither is session-scoped.
418
- order: systemPromptOrder(ctx, 'MCP_SERVERS'),
419
- text: () =>
420
- renderServerSection(
421
- // Disabled servers are invisible here too (doc §5.1): the prompt is
422
- // the model's map of what it may address, so listing a target that
423
- // every call would reject only invites wasted attempts.
424
- (this.#loaded ? this.#servers : []).filter((server) => server.enabled !== false),
425
- { maxListedServers: this.#config.maxListedServers },
426
- ),
427
- }),
428
- )
429
- }
430
-
431
- // ---------------------------------------------------------------- tools --
432
-
433
- /** @returns {any} */
434
- #listTool() {
435
- const surface = this
436
- return defineTool({
437
- name: 'srv_list',
438
- description:
439
- 'List the servers configured on this machine: id, name, host, default working directory, and whether a connection is currently open. Call this first when unsure which server to address.',
440
- parameters: {},
441
- output: {
442
- schema: {
443
- type: 'array',
444
- items: {
445
- type: 'object',
446
- additionalProperties: false,
447
- properties: {
448
- id: { type: 'string', required: true },
449
- name: { type: 'string', required: true },
450
- host: { type: 'string', required: true },
451
- port: { type: 'number', required: true },
452
- username: { type: 'string', required: true },
453
- defaultWorkDir: { type: 'string' },
454
- connected: { type: 'boolean', required: true },
455
- hostKeyMode: { type: 'string', enum: ['accept-new', 'verify', 'off'], required: true },
456
- },
457
- },
458
- },
459
- render: (_args, servers) => [
460
- {
461
- type: 'text',
462
- text:
463
- servers.length === 0
464
- ? 'No servers are configured yet. Add one in the "服务器" panel of this plugin.'
465
- : servers
466
- .map(
467
- (server) =>
468
- `${server.id} ${server.name} ${server.username}@${server.host}:${server.port}` +
469
- ` ${server.connected ? 'connected' : 'not connected'}` +
470
- ` ${server.defaultWorkDir === undefined ? '(no default dir)' : `dir ${server.defaultWorkDir}`}`,
471
- )
472
- .join('\n'),
473
- },
474
- ],
475
- },
476
- async execute() {
477
- await surface.refresh()
478
- const connected = new Set(surface.poolConnectedIds())
479
- // A disabled server is invisible to the model (doc §5.1): it is not
480
- // returned here, not listed in the system prompt, and any attempt to use
481
- // it fails in #resolveServer. Showing it only to reject it would make the
482
- // model retry a target it cannot reach.
483
- return surface.servers.filter((server) => server.enabled !== false).map((server) => ({
484
- id: server.id,
485
- name: server.name,
486
- host: server.host,
487
- port: server.port,
488
- username: server.username,
489
- ...(server.defaultWorkDir === null ? {} : { defaultWorkDir: server.defaultWorkDir }),
490
- connected: connected.has(server.id),
491
- hostKeyMode: server.hostKeyMode,
492
- }))
493
- },
494
- })
495
- }
496
-
497
- /** @returns {any} */
498
- #connectTool() {
499
- const surface = this
500
- return defineTool({
501
- name: 'srv_connect',
502
- description:
503
- 'Open (or reuse) the SSH connection for one server and report what the remote host is: platform, kernel, login shell and sudo availability. Safe to call repeatedly; srv_exec connects on its own.',
504
- parameters: {
505
- server: {
506
- type: 'string',
507
- required: true,
508
- description: 'Server id, exact name, or an unambiguous prefix of either (see srv_list).',
509
- },
510
- },
511
- output: {
512
- schema: {
513
- type: 'object',
514
- additionalProperties: false,
515
- properties: {
516
- serverId: { type: 'string', required: true },
517
- serverName: { type: 'string', required: true },
518
- platform: { type: 'string', required: true },
519
- kernel: { type: 'string', required: true },
520
- shell: { type: 'string', required: true },
521
- sudo: { type: 'string', required: true },
522
- reconnected: { type: 'boolean', required: true },
523
- durationMs: { type: 'number', required: true },
524
- },
525
- },
526
- render: (_args, value) => [
527
- {
528
- type: 'text',
529
- text: [
530
- `${value.serverId} (${value.serverName}) connected in ${value.durationMs} ms`,
531
- `platform: ${value.platform}`,
532
- `kernel: ${value.kernel}`,
533
- `shell: ${value.shell}`,
534
- `sudo: ${value.sudo}`,
535
- ].join('\n'),
536
- },
537
- ],
538
- },
539
- async execute(args, exec) {
540
- await surface.refresh()
541
- const server = await surface.#resolveServer(args.server)
542
- const startedAt = Date.now()
543
- const { reconnected } = await surface.#callPool('connect', server, exec)
544
- const probe = await surface.#callPool(
545
- 'exec',
546
- server,
547
- { command: CONNECT_PROBE, timeoutMs: Math.min(surface.#config.commandTimeoutMs ?? 60000, 15000) },
548
- exec,
549
- )
550
- const parsed = parseProbe(String(probe.stdout ?? ''))
551
- return {
552
- serverId: server.id,
553
- serverName: server.name,
554
- ...parsed,
555
- reconnected: reconnected !== false,
556
- durationMs: Date.now() - startedAt,
557
- }
558
- },
559
- })
560
- }
561
-
562
- /** @returns {any} */
563
- #execTool() {
564
- const surface = this
565
- return defineTool({
566
- name: 'srv_exec',
567
- description:
568
- 'Run a shell command on one configured server. High-risk commands are blocked or require the user to confirm first (see the server list in the system prompt, or pre-check with srv_policy_check). Output beyond the configured ceiling is cut and marked TRUNCATED; a command that outlives the sync budget returns a job id to poll with srv_job_output.',
569
- parameters: {
570
- server: {
571
- type: 'string',
572
- required: true,
573
- description: 'Server id, exact name, or an unambiguous prefix of either.',
574
- },
575
- command: {
576
- type: 'string',
577
- required: true,
578
- description: 'The shell command to run on the remote host.',
579
- },
580
- cwd: {
581
- type: 'string',
582
- description: "Working directory; defaults to the server's defaultWorkDir.",
583
- },
584
- timeoutMs: {
585
- type: 'number',
586
- description:
587
- 'Wall-clock budget in milliseconds (default from the plugin config, 60000). On expiry the remote watchdog kills the process group.',
588
- },
589
- async: {
590
- type: 'boolean',
591
- description: 'Return a job id immediately instead of waiting for the first syncTimeoutMs window.',
592
- },
593
- },
594
- output: {
595
- schema: {
596
- type: 'object',
597
- additionalProperties: false,
598
- properties: {
599
- serverId: { type: 'string', required: true },
600
- serverName: { type: 'string', required: true },
601
- command: { type: 'string', required: true },
602
- decision: { type: 'string', enum: ['allow', 'confirm'], required: true },
603
- policyRule: { type: 'string' },
604
- jobId: { type: 'string' },
605
- running: { type: 'boolean' },
606
- exitCode: { type: 'number' },
607
- code: { type: 'string', enum: ['EXIT_NONZERO', 'TIMEOUT'] },
608
- stdout: { type: 'string' },
609
- stderr: { type: 'string' },
610
- truncated: { type: 'boolean' },
611
- durationMs: { type: 'number' },
612
- note: { type: 'string' },
613
- },
614
- },
615
- render: (_args, value) => {
616
- if (value.running === true) {
617
- return [
618
- {
619
- type: 'text',
620
- text:
621
- `started background job ${value.jobId} on ${value.serverId} (${value.serverName})\n` +
622
- `command: ${value.command}\n` +
623
- 'Read it with srv_job_output, stop it with srv_job_kill.',
624
- },
625
- ]
626
- }
627
- const header = [
628
- `${value.serverId} (${value.serverName})`,
629
- statusLine({ exitCode: value.exitCode ?? null, timedOut: value.code === 'TIMEOUT' }),
630
- value.durationMs === undefined ? undefined : `${value.durationMs} ms`,
631
- value.decision === 'confirm' ? 'approved by user' : undefined,
632
- value.truncated === true ? 'TRUNCATED' : undefined,
633
- ]
634
- .filter((part) => part !== undefined)
635
- .join(' ')
636
- const body = [value.stdout ?? '', value.stderr === undefined ? '' : `[stderr]\n${value.stderr}`]
637
- .filter((part) => part !== '')
638
- .join(value.stdout !== undefined && !value.stdout.endsWith('\n') && value.stderr ? '\n' : '')
639
- return [{ type: 'text', text: `${header}\n${body}${value.note === undefined ? '' : `\n[${value.note}]`}` }]
640
- },
641
- },
642
- async execute(args, exec) {
643
- return surface.#exec(args, exec)
644
- },
645
- presentCall: (args) => ({
646
- card: 'terminal',
647
- title: args.command,
648
- description: `server ${args.server}`,
649
- }),
650
- })
651
- }
652
-
653
- /** @returns {any} */
654
- #jobOutputTool() {
655
- const surface = this
656
- return defineTool({
657
- name: 'srv_job_output',
658
- description:
659
- 'Read the output of a running or finished srv_exec job. Returns only what is new since the previous read (or since `offset`), plus the next byte offset to resume from. A job whose ring dropped bytes reports lossy: true with the spill file paths.',
660
- parameters: {
661
- jobId: { type: 'string', required: true, description: 'Job id returned by srv_exec.' },
662
- offset: {
663
- type: 'number',
664
- description: 'Absolute byte offset to read from. Omit to continue from the previous read.',
665
- },
666
- },
667
- output: {
668
- schema: {
669
- type: 'object',
670
- additionalProperties: false,
671
- properties: {
672
- jobId: { type: 'string', required: true },
673
- status: { type: 'string', required: true },
674
- text: { type: 'string', required: true },
675
- next: { type: 'number', required: true },
676
- lossy: { type: 'boolean', required: true },
677
- detail: { type: 'string' },
678
- spillPaths: { type: 'array', items: { type: 'string' } },
679
- result: { type: 'string' },
680
- },
681
- },
682
- render: (_args, value) => [
683
- {
684
- type: 'text',
685
- text: [
686
- `${value.jobId} ${value.status}${value.detail === undefined ? '' : ` (${value.detail})`}`,
687
- value.lossy ? `[some output was dropped; full output: ${(value.spillPaths ?? []).join(', ') || '(unavailable)'}]` : undefined,
688
- value.text === '' ? '(no new output)' : value.text,
689
- `next: ${value.next}`,
690
- ]
691
- .filter((part) => part !== undefined)
692
- .join('\n'),
693
- },
694
- ],
695
- },
696
- async execute(args, exec) {
697
- const jobs = surface.#jobs
698
- if (jobs === null || jobs === undefined) {
699
- throw new ToolError(
700
- 'INVALID_ARGUMENT',
701
- 'this deployment has no background-job service, so srv_exec never produces a job.',
702
- 'Run the command without async: true (it then runs synchronously).',
703
- )
704
- }
705
- const id = String(args.jobId)
706
- // The owner is the calling session, so it must come from this call's
707
- // `exec`. Passing `undefined` here made every read look unowned, and the
708
- // registry — which fences owned jobs by session — then rejected it with
709
- // "belongs to another session" even for the session that started it.
710
- const caller = surface.#ownerOf(exec)
711
- if (typeof args.offset === 'number') {
712
- if (!Number.isFinite(args.offset) || args.offset < 0) {
713
- throw new ToolError('INVALID_ARGUMENT', 'offset must be a non-negative byte offset.')
714
- }
715
- const read = jobs.readAt(id, Math.floor(args.offset), caller)
716
- const job = jobs.get(id, caller)
717
- return {
718
- jobId: id,
719
- status: job.status,
720
- text: renderChunks(read.chunks),
721
- next: read.next,
722
- lossy: read.lossy === true,
723
- ...(job.detail === undefined ? {} : { detail: job.detail }),
724
- ...(job.output?.spillPaths === undefined ? {} : { spillPaths: job.output.spillPaths }),
725
- }
726
- }
727
- const read = jobs.read(id, caller)
728
- return {
729
- jobId: id,
730
- status: read.job.status,
731
- text: renderChunks(read.chunks),
732
- next: read.job.output.total,
733
- lossy: read.lossy === true,
734
- ...(read.job.detail === undefined ? {} : { detail: read.job.detail }),
735
- ...(read.job.output.spillPaths === undefined ? {} : { spillPaths: read.job.output.spillPaths }),
736
- ...(read.result === undefined ? {} : { result: read.result }),
737
- }
738
- },
739
- })
740
- }
741
-
742
- /** @returns {any} */
743
- #jobKillTool() {
744
- const surface = this
745
- return defineTool({
746
- name: 'srv_job_kill',
747
- description: 'Cancel a running srv_exec job. A finished job reports already-finished.',
748
- parameters: {
749
- jobId: { type: 'string', required: true, description: 'Job id returned by srv_exec.' },
750
- },
751
- output: {
752
- schema: {
753
- type: 'object',
754
- additionalProperties: false,
755
- properties: {
756
- jobId: { type: 'string', required: true },
757
- outcome: { type: 'string', enum: ['cancellation-requested', 'already-finished'], required: true },
758
- status: { type: 'string', required: true },
759
- },
760
- },
761
- render: (_args, value) => [
762
- {
763
- type: 'text',
764
- text:
765
- value.outcome === 'cancellation-requested'
766
- ? `requested cancellation of job ${value.jobId} (status: ${value.status})`
767
- : `job ${value.jobId} had already finished (status: ${value.status})`,
768
- },
769
- ],
770
- },
771
- async execute(args, exec) {
772
- const jobs = surface.#jobs
773
- if (jobs === null || jobs === undefined) {
774
- throw new ToolError(
775
- 'INVALID_ARGUMENT',
776
- 'this deployment has no background-job service, so no srv_exec job can exist.',
777
- 'srv_exec ran synchronously; nothing is left running.',
778
- )
779
- }
780
- const id = String(args.jobId)
781
- // Same owner fence as srv_job_output: the caller is this call's session.
782
- const caller = surface.#ownerOf(exec)
783
- const outcome = jobs.kill(id, caller)
784
- const job = jobs.get(id, caller)
785
- /** @type {'cancellation-requested'|'already-finished'} */
786
- const cancellation = outcome === 'requested' ? 'cancellation-requested' : 'already-finished'
787
- return { jobId: id, outcome: cancellation, status: job.status }
788
- },
789
- })
790
- }
791
-
792
- /** @returns {any} */
793
- #policyCheckTool() {
794
- const surface = this
795
- return defineTool({
796
- name: 'srv_policy_check',
797
- description:
798
- 'Decide how srv_exec would treat a command on one server — allow, confirm (the user is asked) or block (never runs) — without connecting or executing anything. Use it before running something the built-in rules might catch.',
799
- parameters: {
800
- server: { type: 'string', required: true, description: 'Server id, exact name, or an unambiguous prefix.' },
801
- command: { type: 'string', required: true, description: 'The command to judge.' },
802
- },
803
- output: {
804
- schema: {
805
- type: 'object',
806
- additionalProperties: false,
807
- properties: {
808
- serverId: { type: 'string', required: true },
809
- serverName: { type: 'string', required: true },
810
- command: { type: 'string', required: true },
811
- decision: { type: 'string', enum: ['allow', 'confirm', 'block'], required: true },
812
- ruleId: { type: 'string' },
813
- ruleLabel: { type: 'string' },
814
- matches: {
815
- type: 'array',
816
- items: {
817
- type: 'object',
818
- additionalProperties: false,
819
- properties: {
820
- ruleId: { type: 'string', required: true },
821
- decision: { type: 'string', enum: ['allow', 'confirm', 'block'], required: true },
822
- },
823
- },
824
- },
825
- },
826
- },
827
- render: (_args, value) => [
828
- {
829
- type: 'text',
830
- text:
831
- `${value.decision.toUpperCase()} on ${value.serverId} (${value.serverName})` +
832
- `${value.ruleId === undefined ? '' : ` — rule ${value.ruleId}${value.ruleLabel === undefined ? '' : ` (${value.ruleLabel})`}`}\n` +
833
- `command: ${value.command}` +
834
- (value.decision === 'block' ? '\nThis command will not run: the rule blocks it. Ask the user to change the server policy if it is really needed.' : ''),
835
- },
836
- ],
837
- },
838
- async execute(args) {
839
- await surface.refresh()
840
- const server = await surface.#resolveServer(args.server)
841
- const command = requireCommand(args.command)
842
- const verdict = checkPolicy(command, server.policy, server.disabledRules)
843
- const matched = verdict.matchedRule
844
- return {
845
- serverId: server.id,
846
- serverName: server.name,
847
- command,
848
- decision: verdict.decision,
849
- ...(matched === null ? {} : { ruleId: matched.id, ruleLabel: matched.label }),
850
- matches: verdict.matches.map((match) => ({ ruleId: match.id, decision: match.decision })),
851
- }
852
- },
853
- })
854
- }
855
-
856
- // ------------------------------------------------------------- internals --
857
-
858
- /**
859
- * Resolve and cache-refresh one `server` argument (§5.2).
860
- * @param {unknown} query
861
- * @returns {Promise<import('./registry.js').ServerRecord>}
862
- */
863
- async #resolveServer(query) {
864
- /** @type {import('./registry.js').ServerRecord} */
865
- let server
866
- try {
867
- server = await this.#registry.resolve(String(query ?? ''))
868
- } catch (error) {
869
- const code = /** @type {{code?: string, message?: string}} */ (error).code
870
- if (code === 'UNKNOWN_SERVER' || code === 'AMBIGUOUS_SERVER' || code === 'INVALID_SERVER') {
871
- throw new ToolError(
872
- /** @type {ToolErrorCode} */ (code),
873
- String(/** @type {{message?: string}} */ (error).message ?? error),
874
- code === 'UNKNOWN_SERVER' ? 'Call srv_list to see the configured servers.' : undefined,
875
- )
876
- }
877
- throw error
878
- }
879
- // The enable switch (doc §5.1). Every srv_* tool resolves its target here,
880
- // so one check covers srv_exec, srv_connect, srv_policy_check and the job
881
- // tools alike. A disabled server is still addressable by id/name/prefix —
882
- // that is how the panel names it — so the check belongs after resolution,
883
- // not inside it.
884
- if (server.enabled === false) {
885
- throw new ToolError(
886
- 'SERVER_DISABLED',
887
- `server ${server.id} (${server.name}) is disabled.`,
888
- 'Enable it in the "服务器" panel of this plugin, then retry.',
889
- )
890
- }
891
- return server
892
- }
893
-
894
- /**
895
- * Call one pool method and normalise its failures into §5.5 tool errors.
896
- * @param {'connect'|'exec'|'execStream'|'disconnect'} method
897
- * @param {...any} args
898
- * @returns {Promise<any>}
899
- */
900
- async #callPool(method, ...args) {
901
- try {
902
- // The pool's methods have unrelated signatures; this dispatcher exists only
903
- // so every transport failure is normalised in one place.
904
- const call = /** @type {(...a: any[]) => Promise<any>} */ (/** @type {any} */ (this.#pool)[method]).bind(
905
- this.#pool,
906
- )
907
- return await call(...args)
908
- } catch (error) {
909
- throw normalizePoolFailure(error)
910
- }
911
- }
912
-
913
- /**
914
- * Ids with a live connection, for `srv_list` (§5.1 "是否已连接").
915
- * @returns {string[]}
916
- */
917
- poolConnectedIds() {
918
- try {
919
- return this.#pool.connectedServerIds()
920
- } catch {
921
- return []
922
- }
923
- }
924
-
925
- /**
926
- * The three gates of §6.1, then execution.
927
- * @param {{server: string, command: string, cwd?: string, timeoutMs?: number, async?: boolean}} args
928
- * @param {import('@deepseek-ai/dsh-tools').ToolRunContext} exec
929
- * @returns {Promise<SrvExecValue>}
930
- */
931
- async #exec(args, exec) {
932
- await this.refresh()
933
- const server = await this.#resolveServer(args.server)
934
- const command = requireCommand(args.command)
935
- const timeoutMs = positiveInteger(args.timeoutMs, 'timeoutMs') ?? this.#config.commandTimeoutMs ?? 60000
936
- const cwd = typeof args.cwd === 'string' && args.cwd.trim() !== '' ? args.cwd : undefined
937
- const auditBase = {
938
- sessionId: this.#sessionIdOf(exec),
939
- serverId: server.id,
940
- serverName: server.name,
941
- command,
942
- }
943
-
944
- // ① policy. A block never connects and never executes.
945
- const verdict = checkPolicy(command, server.policy, server.disabledRules)
946
- if (verdict.decision === 'block') {
947
- await this.#auditExec({ ...auditBase, decision: 'block', policyRule: verdict.ruleId })
948
- throw new ToolError(
949
- 'POLICY_BLOCKED',
950
- `command blocked by rule ${verdict.ruleId ?? 'unknown'}${
951
- verdict.matchedRule === null ? '' : ` (${verdict.matchedRule.label})`
952
- } on server ${server.name} (${server.id}): ${command}`,
953
- 'Nothing was sent to the server. Ask the user to allow this rule for that server, or rephrase the command.',
954
- )
955
- }
956
-
957
- // ② approval. Only 'allowed-once' proceeds; everything else fails closed.
958
- /** @type {boolean|null} */
959
- let approved = null
960
- if (verdict.decision === 'confirm') {
961
- const outcome = await this.#requestApproval({
962
- exec,
963
- server,
964
- command,
965
- ruleId: verdict.ruleId,
966
- ruleLabel: verdict.matchedRule?.label,
967
- })
968
- if (!isGranted(outcome)) {
969
- await this.#auditExec({ ...auditBase, decision: 'confirm', policyRule: verdict.ruleId, approved: false })
970
- throw this.#denialError(outcome, exec, server, command)
971
- }
972
- approved = true
973
- }
974
-
975
- // ③ execute, then audit.
976
- const jobs = this.#jobs
977
- if (jobs === null || jobs === undefined) {
978
- const result = await this.#runSync({ server, command, cwd, timeoutMs, exec, asyncRequested: args.async === true })
979
- await this.#auditExec({
980
- ...auditBase,
981
- decision: verdict.decision,
982
- policyRule: verdict.ruleId,
983
- approved,
984
- exitCode: result.exitCode,
985
- durationMs: result.durationMs,
986
- truncated: result.truncated,
987
- })
988
- return result
989
- }
990
-
991
- const started = this.#startJob({ server, command, cwd, timeoutMs, exec })
992
- const owner = this.#ownerOf(exec)
993
- if (args.async === true) {
994
- /** @type {SrvExecValue} */
995
- const startedValue = {
996
- serverId: server.id,
997
- serverName: server.name,
998
- command,
999
- // The block branch returned above, so the decision is allow or confirm.
1000
- decision: /** @type {'allow'|'confirm'} */ (verdict.decision),
1001
- ...(verdict.ruleId === null ? {} : { policyRule: verdict.ruleId }),
1002
- jobId: started.id,
1003
- running: true,
1004
- }
1005
- return startedValue
1006
- }
1007
-
1008
- let view
1009
- try {
1010
- view = await jobs.wait(started.id, this.#config.syncTimeoutMs ?? 20000, owner, exec.signal)
1011
- } catch (error) {
1012
- jobs.kill(started.id, owner, 'the srv_exec call was aborted')
1013
- throw error
1014
- }
1015
- this.#log('info', 'dsh-server-connector: srv_exec on %s settled as %s', server.id, view.status)
1016
-
1017
- if (view.status === 'running' || view.status === 'stopping') {
1018
- return {
1019
- serverId: server.id,
1020
- serverName: server.name,
1021
- command,
1022
- decision: verdict.decision,
1023
- ...(verdict.ruleId === null ? {} : { policyRule: verdict.ruleId }),
1024
- jobId: started.id,
1025
- running: true,
1026
- note: `still running after ${this.#config.syncTimeoutMs} ms; use srv_job_output`,
1027
- }
1028
- }
1029
-
1030
- // The call itself collected the terminal state and never handed the id out,
1031
- // so the record leaves with the call (§12 / jobs start+wait+remove pattern).
1032
- jobs.remove(started.id, owner)
1033
- const settled = started.outcome()
1034
- await this.#auditExec({
1035
- ...auditBase,
1036
- decision: verdict.decision,
1037
- policyRule: verdict.ruleId,
1038
- approved,
1039
- exitCode: settled?.exitCode ?? null,
1040
- durationMs: settled?.durationMs,
1041
- truncated: settled?.truncated ?? false,
1042
- })
1043
- return this.#resultOf({ server, command, verdict, approved, result: settled })
1044
- }
1045
-
1046
- /**
1047
- * Ask the user through the DSH approval service (§6.1) and report the closed
1048
- * outcome. A missing service or a missing agent fails closed as
1049
- * `'unavailable'` rather than running the command unasked.
1050
- * @param {{exec: any, server: any, command: string, ruleId: string|null, ruleLabel?: string}} input
1051
- * @returns {Promise<string>}
1052
- */
1053
- async #requestApproval(input) {
1054
- const approval = this.#approval
1055
- const agent = input.exec?.agent
1056
- if (approval === null || approval === undefined || typeof approval.request !== 'function') {
1057
- return 'unavailable'
1058
- }
1059
- if (agent === undefined) return 'unavailable'
1060
- try {
1061
- return await approval.request({
1062
- agent,
1063
- toolName: 'srv_exec',
1064
- ...(input.exec.callId === undefined ? {} : { callId: input.exec.callId }),
1065
- reason: approvalReason({
1066
- ruleId: input.ruleId,
1067
- ruleLabel: input.ruleLabel,
1068
- server: input.server,
1069
- command: input.command,
1070
- }),
1071
- ...(input.exec.signal === undefined ? {} : { signal: input.exec.signal }),
1072
- })
1073
- } catch (error) {
1074
- this.#log('warn', 'dsh-server-connector: approval.request failed: %s', String(error))
1075
- return 'unavailable'
1076
- }
1077
- }
1078
-
1079
- /**
1080
- * The §6.1 rejection error, wording the outcome and — when the session policy
1081
- * is `never` — saying so explicitly, because that is the case the user can fix
1082
- * without a prompt ever appearing.
1083
- * @param {string} outcome
1084
- * @param {any} exec
1085
- * @param {import('./registry.js').ServerRecord} server
1086
- * @param {string} command
1087
- * @returns {ToolError}
1088
- */
1089
- #denialError(outcome, exec, server, command) {
1090
- const context = `${server.name} (${server.id}): ${command}`
1091
- if (outcome === 'rejected' && approvalPolicyOf(this.#approval, exec?.agent) === 'never') {
1092
- return new ToolError(
1093
- 'POLICY_DENIED_BY_USER',
1094
- `当前会话审批策略为 never,高危命令无法确认,命令未执行 — ${context}`,
1095
- 'The session approval policy is "never", so confirmation prompts are auto-rejected. Switch the session to the "ask" policy (or run the command manually on the server) and retry.',
1096
- )
1097
- }
1098
- if (outcome === 'rejected') {
1099
- return new ToolError(
1100
- 'POLICY_DENIED_BY_USER',
1101
- `the user rejected this command — ${context}`,
1102
- 'Nothing was sent to the server. Ask the user what to do instead, or use srv_policy_check to find an allowed form.',
1103
- )
1104
- }
1105
- if (outcome === 'cancelled') {
1106
- return new ToolError(
1107
- 'POLICY_DENIED_BY_USER',
1108
- `the confirmation was cancelled before it was answered — ${context}`,
1109
- 'Nothing was sent to the server. Retry when the user is ready to decide.',
1110
- )
1111
- }
1112
- return new ToolError(
1113
- 'POLICY_DENIED_BY_USER',
1114
- `the confirmation could not be shown (no approval answerer is available) — ${context}`,
1115
- 'Nothing was sent to the server. Ask the user to run the command or to enable an interactive approval surface.',
1116
- )
1117
- }
1118
-
1119
- /**
1120
- * Execute without `ctx.jobs` (§9.4: "无 jobs → 强制同步执行 + 硬超时").
1121
- * @param {{server: any, command: string, cwd?: string, timeoutMs: number, exec: any, asyncRequested: boolean}} input
1122
- * @returns {Promise<SrvExecValue>}
1123
- */
1124
- async #runSync(input) {
1125
- const result = await this.#callPool(
1126
- 'exec',
1127
- input.server,
1128
- {
1129
- command: input.command,
1130
- ...(input.cwd === undefined ? {} : { cwd: input.cwd }),
1131
- timeoutMs: input.timeoutMs,
1132
- },
1133
- input.exec,
1134
- )
1135
- const verdict = checkPolicy(input.command, input.server.policy, input.server.disabledRules)
1136
- return this.#resultOf({
1137
- server: input.server,
1138
- command: input.command,
1139
- verdict,
1140
- approved: null,
1141
- result,
1142
- note: input.asyncRequested ? 'this deployment has no background-job service, so the call ran synchronously' : undefined,
1143
- })
1144
- }
1145
-
1146
- /**
1147
- * Shape one finished execution into the canonical tool result.
1148
- * @param {{server: any, command: string, verdict: any, approved: boolean|null, result: any, note?: string}} input
1149
- * @returns {SrvExecValue}
1150
- */
1151
- #resultOf(input) {
1152
- const result = input.result ?? {}
1153
- const timedOut = result.timedOut === true
1154
- const exitCode = typeof result.exitCode === 'number' ? result.exitCode : null
1155
- /** @type {SrvExecValue} */
1156
- const value = {
1157
- serverId: input.server.id,
1158
- serverName: input.server.name,
1159
- command: input.command,
1160
- decision: input.verdict.decision === 'block' ? 'allow' : input.verdict.decision,
1161
- ...(input.verdict.ruleId === null ? {} : { policyRule: input.verdict.ruleId }),
1162
- ...(exitCode === null ? {} : { exitCode }),
1163
- stdout: String(result.stdout ?? ''),
1164
- stderr: String(result.stderr ?? ''),
1165
- truncated: result.truncated === true,
1166
- durationMs: typeof result.durationMs === 'number' ? result.durationMs : 0,
1167
- }
1168
- if (timedOut) value.code = 'TIMEOUT'
1169
- else if (exitCode !== null && exitCode !== 0) value.code = 'EXIT_NONZERO'
1170
- if (input.note !== undefined) value.note = input.note
1171
- return value
1172
- }
1173
-
1174
- /**
1175
- * Register one `srv_exec` as a job and start the SSH channel inside it. The
1176
- * job owns the stream from its first byte, so a promoted call keeps emitting
1177
- * into the ring that `srv_job_output` reads (§5.4).
1178
- * @param {{server: any, command: string, cwd?: string, timeoutMs: number, exec: any}} input
1179
- * @returns {{id: string, outcome: () => any}}
1180
- */
1181
- #startJob(input) {
1182
- const jobs = this.#jobs
1183
- /** @type {any} */
1184
- let settled = null
1185
- /** @type {(() => void)|null} */
1186
- let cancelStream = null
1187
- const maxOutputChars = this.#config.maxOutputChars ?? 200000
1188
- const label = `${input.server.id}: ${input.command}`
1189
-
1190
- /** @type {any} */
1191
- const spec = {
1192
- kind: 'ssh',
1193
- label,
1194
- ...(this.#ownerOf(input.exec) === undefined ? {} : { owner: this.#ownerOf(input.exec) }),
1195
- ...(input.exec?.callId === undefined
1196
- ? {}
1197
- : { toolCall: { callId: input.exec.callId, rootCallId: input.exec.rootCallId ?? input.exec.callId } }),
1198
- run: (/** @type {any} */ job) => {
1199
- /** @type {Promise<any>} */
1200
- let streaming
1201
- try {
1202
- streaming = this.#pool.execStream(
1203
- input.server,
1204
- {
1205
- command: input.command,
1206
- ...(input.cwd === undefined ? {} : { cwd: input.cwd }),
1207
- timeoutMs: input.timeoutMs,
1208
- maxOutputChars,
1209
- onChunk: (/** @type {'stdout'|'stderr'} */ channel, /** @type {string} */ text) => {
1210
- job.append(text, { channel })
1211
- },
1212
- onCancel: (/** @type {() => void} */ cancel) => {
1213
- cancelStream = cancel
1214
- },
1215
- },
1216
- input.exec?.signal,
1217
- )
1218
- } catch (error) {
1219
- return {
1220
- cancel: () => {},
1221
- done: Promise.resolve({ status: 'failed', detail: String(error) }),
1222
- }
1223
- }
1224
- const done = streaming.then(
1225
- (/** @type {any} */ result) => {
1226
- settled = result
1227
- if (result.truncated === true) job.append('\n…[TRUNCATED]')
1228
- /** @type {any} */
1229
- const outcome = result.cancelled === true
1230
- ? { status: 'killed', detail: 'cancelled by srv_job_kill' }
1231
- : {
1232
- status: 'completed',
1233
- detail: result.timedOut === true ? 'TIMEOUT (process group killed)' : `exit code: ${result.exitCode}`,
1234
- result: JSON.stringify({
1235
- exitCode: result.exitCode,
1236
- timedOut: result.timedOut,
1237
- truncated: result.truncated,
1238
- durationMs: result.durationMs,
1239
- }),
1240
- }
1241
- return outcome
1242
- },
1243
- (/** @type {any} */ error) => ({
1244
- status: 'failed',
1245
- detail: normalizePoolFailure(error).message,
1246
- }),
1247
- )
1248
- return {
1249
- cancel: () => {
1250
- if (cancelStream !== null) cancelStream()
1251
- },
1252
- done,
1253
- }
1254
- },
1255
- }
1256
- const id = jobs.start(spec)
1257
- return { id, outcome: () => settled }
1258
- }
1259
-
1260
- /**
1261
- * Write one audit line (§6.2). Audit failures are logged, never surfaced: the
1262
- * command's outcome must not change because a log file is unwritable.
1263
- * @param {Record<string, any>} entry
1264
- * @returns {Promise<void>}
1265
- */
1266
- async #auditExec(entry) {
1267
- const audit = this.#audit
1268
- if (audit === null || audit === undefined) return
1269
- try {
1270
- await audit.append(entry)
1271
- } catch (error) {
1272
- this.#log('error', 'dsh-server-connector: audit append failed: %s', String(error))
1273
- }
1274
- }
1275
-
1276
- /**
1277
- * @param {any} exec
1278
- * @returns {string|undefined}
1279
- */
1280
- #sessionIdOf(exec) {
1281
- const id = exec?.agent?.id ?? exec?.agent?.session?.id
1282
- return typeof id === 'string' ? id : undefined
1283
- }
1284
-
1285
- /**
1286
- * The reading/owning session of a job call, or undefined for an unowned job.
1287
- * @param {any} exec
1288
- * @returns {any}
1289
- */
1290
- #ownerOf(exec) {
1291
- return exec?.agent?.id
1292
- }
1293
-
1294
- /**
1295
- * @param {'info'|'warn'|'error'} level
1296
- * @param {string} message
1297
- * @param {...unknown} args
1298
- * @returns {void}
1299
- */
1300
- #log(level, message, ...args) {
1301
- const logger = this.#logger
1302
- if (logger === null || logger === undefined) return
1303
- const write = logger[level] ?? logger.info
1304
- if (typeof write === 'function') write.call(logger, message, ...args)
1305
- }
1306
- }
1307
-
1308
- /**
1309
- * Map a transport failure onto a §5.5 tool error, keeping the actionable next
1310
- * step the pool already composed.
1311
- * @param {unknown} error
1312
- * @returns {ToolError}
1313
- */
1314
- export function normalizePoolFailure(error) {
1315
- if (error instanceof ToolError) return error
1316
- const code = /** @type {{code?: string}} */ (error).code
1317
- const known = /** @type {ToolErrorCode[]} */ ([
1318
- 'CONNECT_FAILED',
1319
- 'HOST_KEY_UNKNOWN',
1320
- 'AUTH_FAILED',
1321
- 'TIMEOUT',
1322
- 'NO_CREDENTIAL',
1323
- 'EXIT_NONZERO',
1324
- 'INVALID_ARGUMENT',
1325
- ])
1326
- const message = error instanceof Error ? error.message : String(error)
1327
- if (typeof code === 'string' && known.includes(/** @type {ToolErrorCode} */ (code))) {
1328
- return new ToolError(/** @type {ToolErrorCode} */ (code), message)
1329
- }
1330
- return new ToolError('CONNECT_FAILED', message, 'Retry; if it persists, check that sshd is running on the target.')
1331
- }
1332
-
1333
- /**
1334
- * @param {unknown} value
1335
- * @returns {string}
1336
- */
1337
- function requireCommand(value) {
1338
- if (typeof value !== 'string' || value.trim() === '') {
1339
- throw new ToolError('INVALID_ARGUMENT', 'command must be a non-empty string.', 'Pass the shell command to run.')
1340
- }
1341
- return value
1342
- }
1343
-
1344
- /**
1345
- * @param {unknown} value
1346
- * @param {string} label
1347
- * @returns {number|undefined}
1348
- */
1349
- function positiveInteger(value, label) {
1350
- if (value === undefined || value === null) return undefined
1351
- if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) {
1352
- throw new ToolError('INVALID_ARGUMENT', `${label} must be a positive number of milliseconds.`)
1353
- }
1354
- return Math.round(value)
1355
- }
1356
-
1357
- export default ToolSurface
1
+ // dsh-server-connector — the agent-facing tool surface (design doc §5, §6).
2
+ //
3
+ // Six `srv_*` tools, the system-prompt section that advertises the configured
4
+ // servers (§5.3), and the three gates every execution goes through (§6.1):
5
+ //
6
+ // srv_exec(command)
7
+ // ├─① policy.check(command, server.policy, server.disabledRules)
8
+ // │ block → POLICY_BLOCKED, no connection, no execution
9
+ // │ confirm → ②
10
+ // │ allow → ③
11
+ // ├─② ctx.approval.request({agent, toolName: 'srv_exec', reason})
12
+ // │ allowed-once → ③
13
+ // │ rejected/cancelled/unavailable → POLICY_DENIED_BY_USER
14
+ // └─③ execute through the pool → audit → result
15
+ //
16
+ // This module is the only model-visible face of the plugin. It orchestrates the
17
+ // units below it and owns no storage of its own: the registry (§3.1), the pool
18
+ // (§7), the policy engine (§4) and the audit log (§3.4) are injected.
19
+ //
20
+ // Two deliberate decisions, both visible to the model and documented in the PR:
21
+ //
22
+ // 1. **Policy/approval/transport failures are thrown; a nonzero exit and a
23
+ // timeout are results.** §5.5 lists `EXIT_NONZERO` and `TIMEOUT` among the
24
+ // error types, but throwing would throw away the stdout/stderr the model
25
+ // needs to act on. Both are therefore reported as a typed `code` field
26
+ // (`EXIT_NONZERO` / `TIMEOUT`) on the normal result, whose rendered text
27
+ // leads with the code and a next step. Everything that *prevented* the
28
+ // command from running or from reporting (`POLICY_BLOCKED`,
29
+ // `POLICY_DENIED_BY_USER`, `CONNECT_FAILED`, `HOST_KEY_UNKNOWN`,
30
+ // `AUTH_FAILED`, `NO_CREDENTIAL`, `TIMEOUT` before any output, …) is thrown
31
+ // as a {@link ToolError} whose message starts with the §5.5 code.
32
+ //
33
+ // 2. **Long commands are promoted, not migrated.** `async: true` or a call that
34
+ // outlives `syncTimeoutMs` returns a `ctx.jobs` job id that keeps the very
35
+ // same SSH channel open. A foreground command therefore never runs twice and
36
+ // never loses output it already emitted. Without `ctx.jobs` (§9.4 optional
37
+ // service) execution degrades to a synchronous call bounded by `timeoutMs`.
38
+
39
+ import { defineTool } from '@deepseek-ai/dsh-tools'
40
+
41
+ import { readConfig } from './config.js'
42
+ import { RULES } from './policy.js'
43
+ import { check as checkPolicy } from './policy.js'
44
+
45
+ /** The six tool names of doc §5.1, in registration order. */
46
+ export const TOOL_NAMES = Object.freeze([
47
+ 'srv_list',
48
+ 'srv_connect',
49
+ 'srv_exec',
50
+ 'srv_job_output',
51
+ 'srv_job_kill',
52
+ 'srv_policy_check',
53
+ ])
54
+
55
+ /**
56
+ * Machine-readable error codes (§5.5). `INVALID_ARGUMENT` is the one addition:
57
+ * it marks a malformed tool argument, which the §5.5 table does not enumerate
58
+ * because it describes transport and policy failures.
59
+ * @typedef {'CONNECT_FAILED'|'HOST_KEY_UNKNOWN'|'AUTH_FAILED'|'POLICY_BLOCKED'|'POLICY_DENIED_BY_USER'|'TIMEOUT'|'EXIT_NONZERO'|'UNKNOWN_SERVER'|'AMBIGUOUS_SERVER'|'SERVER_DISABLED'|'NO_CREDENTIAL'|'INVALID_ARGUMENT'} ToolErrorCode
60
+ */
61
+
62
+ /**
63
+ * A tool failure the model can read. The message always starts with the code so
64
+ * the model can branch on it without parsing prose (§5.5: "每种都带一句可执行的
65
+ * 下一步提示").
66
+ */
67
+ export class ToolError extends Error {
68
+ /**
69
+ * @param {ToolErrorCode} code
70
+ * @param {string} detail - what happened, in one sentence.
71
+ * @param {string} [next] - the actionable next step.
72
+ */
73
+ constructor(code, detail, next) {
74
+ super(`${code}: ${detail}${next === undefined || next === '' ? '' : ` ${next}`}`)
75
+ this.name = 'ToolError'
76
+ /** @type {ToolErrorCode} */
77
+ this.code = code
78
+ }
79
+ }
80
+
81
+ /**
82
+ * The probe `srv_connect` runs after the handshake: platform, shell and sudo
83
+ * availability in one round trip (§5.1). Every line is `key=value`; an unknown
84
+ * key is ignored, a missing one reports `unknown`. The trailing `true` keeps the
85
+ * exit code 0 so a `grep`-style failure inside the probe is not mistaken for a
86
+ * failed connection.
87
+ * @type {string}
88
+ */
89
+ export const CONNECT_PROBE = [
90
+ '. /etc/os-release 2>/dev/null; printf "os=%s\\n" "${PRETTY_NAME:-${ID:-unknown}}"',
91
+ 'printf "kernel=%s\\n" "$(uname -srm 2>/dev/null || echo unknown)"',
92
+ 'printf "shell=%s\\n" "${SHELL:-/bin/sh}"',
93
+ 'if command -v sudo >/dev/null 2>&1; then',
94
+ ' if sudo -n true >/dev/null 2>&1; then echo "sudo=passwordless"; else echo "sudo=password (needs a password)"; fi',
95
+ 'else echo "sudo=absent"; fi',
96
+ 'true',
97
+ ].join('; ')
98
+
99
+ /**
100
+ * Parse the {@link CONNECT_PROBE} output into the fields the tool returns.
101
+ * @param {string} stdout
102
+ * @returns {{platform: string, kernel: string, shell: string, sudo: string}}
103
+ */
104
+ export function parseProbe(stdout) {
105
+ /** @type {Record<string, string>} */
106
+ const fields = {}
107
+ for (const line of stdout.split('\n')) {
108
+ const index = line.indexOf('=')
109
+ if (index <= 0) continue
110
+ const key = line.slice(0, index).trim()
111
+ const value = line.slice(index + 1).trim()
112
+ if (key !== '' && value !== '') fields[key] = value
113
+ }
114
+ return {
115
+ platform: fields.os ?? 'unknown',
116
+ kernel: fields.kernel ?? 'unknown',
117
+ shell: fields.shell ?? 'unknown',
118
+ sudo: fields.sudo ?? 'unknown',
119
+ }
120
+ }
121
+
122
+ /**
123
+ * Render the §5.3 system-prompt section for the configured servers.
124
+ *
125
+ * Returns an empty string when nothing is configured: the section then
126
+ * contributes nothing (§5.3: "仅在已配置服务器存在时出现"), and an empty section
127
+ * is how `ctx.systemPrompt.section` spells "nothing to say".
128
+ * @param {import('./registry.js').ServerRecord[]} servers
129
+ * @param {{maxListedServers?: number}} [options]
130
+ * @returns {string}
131
+ */
132
+ export function renderServerSection(servers, options = {}) {
133
+ if (servers.length === 0) return ''
134
+ const max = options.maxListedServers ?? 8
135
+ const listed = servers.slice(0, max)
136
+ const lines = ['可运维服务器(调用 srv_exec 时必须传 server):']
137
+ for (const server of listed) {
138
+ const workdir = server.defaultWorkDir === null ? '(无默认目录)' : server.defaultWorkDir
139
+ lines.push(
140
+ `- ${server.id} ${server.name} ${server.username}@${server.host}:${server.port} 默认目录 ${workdir}`,
141
+ )
142
+ }
143
+ const rest = servers.length - listed.length
144
+ if (rest > 0) lines.push(`…另有 ${rest} 台,调用 srv_list 查看全部`)
145
+
146
+ // Per-server overrides are what an operator actually configured; when every
147
+ // server inherits the built-in catalog, summarise the catalog instead of
148
+ // claiming an override that does not exist.
149
+ /** @type {Map<string, Set<string>>} */
150
+ const overrides = new Map()
151
+ for (const server of servers) {
152
+ for (const [ruleId, decision] of Object.entries(server.policy)) {
153
+ const key = `${ruleId}=${decision}`
154
+ if (!overrides.has(key)) overrides.set(key, new Set())
155
+ overrides.get(key)?.add(server.id)
156
+ }
157
+ }
158
+ if (overrides.size > 0) {
159
+ const summary = [...overrides.keys()].sort().join('、')
160
+ lines.push(`高危策略:${summary}(可用 srv_policy_check 预检)`)
161
+ } else {
162
+ const blocked = RULES.filter((rule) => rule.defaultDecision === 'block').length
163
+ const confirmed = RULES.length - blocked
164
+ lines.push(
165
+ `高危策略:内置 ${RULES.length} 条规则(${blocked} 条 block、${confirmed} 条 confirm),未做逐台覆盖(可用 srv_policy_check 预检)`,
166
+ )
167
+ }
168
+ // Custom rules are counted, not listed (doc §4.4.4): the model needs to know a
169
+ // server is stricter than the catalog, not what its private regexes say.
170
+ const customCount = servers.reduce((total, server) => total + (server.customRules ?? []).length, 0)
171
+ if (customCount > 0) lines.push(`另有 ${customCount} 条自定义规则(可用 srv_policy_check 预检具体命令)`)
172
+ return lines.join('\n')
173
+ }
174
+
175
+ /**
176
+ * Order of the §5.3 section in the assembled system prompt.
177
+ *
178
+ * Newer DSH releases allocate section orders centrally
179
+ * (`ctx.systemPrompt.getSectionOrder('MCP_SERVERS')`); the release candidates
180
+ * this package targets expose only plain numeric orders. Both are supported so
181
+ * the plugin does not pin one harness build: the fallback numbers are the ones
182
+ * `SECTION_ORDERS` assigns (`MCP_SERVERS = 3100`, "other tool guidance").
183
+ * @param {any} ctx
184
+ * @param {keyof typeof FALLBACK_SECTION_ORDERS} name
185
+ * @returns {number}
186
+ */
187
+ export function systemPromptOrder(ctx, name) {
188
+ const resolveOrder = ctx?.systemPrompt?.getSectionOrder
189
+ if (typeof resolveOrder === 'function') {
190
+ try {
191
+ const order = resolveOrder.call(ctx.systemPrompt, name)
192
+ if (typeof order === 'number' && Number.isFinite(order)) return order
193
+ } catch {
194
+ // Fall through to the literal order below.
195
+ }
196
+ }
197
+ return FALLBACK_SECTION_ORDERS[name]
198
+ }
199
+
200
+ /**
201
+ * The `SECTION_ORDERS` values this plugin uses, for harness builds without
202
+ * `getSectionOrder`. Keep in sync with `@deepseek-ai/dsh-system-prompt`.
203
+ * @type {{MCP_SERVERS: number}}
204
+ */
205
+ export const FALLBACK_SECTION_ORDERS = Object.freeze({
206
+ MCP_SERVERS: 3100,
207
+ })
208
+
209
+ /**
210
+ * Whether one approval outcome granted the action (§6.1: only `allowed-once`
211
+ * executes).
212
+ * @param {string} outcome
213
+ * @returns {boolean}
214
+ */
215
+ export function isGranted(outcome) {
216
+ return outcome === 'allowed-once'
217
+ }
218
+
219
+ /**
220
+ * The session's effective approval policy, read without prompting anyone.
221
+ *
222
+ * `ApprovalService` computes it as "the session override, else the deployment
223
+ * default". The public surface exposes neither directly (`overrideOf` is
224
+ * declared private, `config` is public), so both are probed defensively: a
225
+ * future shape change degrades to `'ask'`, which only affects the wording of a
226
+ * rejection message, never whether something runs.
227
+ * @param {any} approval - the `ctx.approval` service.
228
+ * @param {any} agent - the calling agent.
229
+ * @returns {'ask'|'never'}
230
+ */
231
+ export function approvalPolicyOf(approval, agent) {
232
+ const session = agent?.session
233
+ if (session !== undefined && typeof approval?.overrideOf === 'function') {
234
+ try {
235
+ const override = approval.overrideOf(session)
236
+ if (override === 'ask' || override === 'never') return override
237
+ } catch {
238
+ // Fall through to the configured default.
239
+ }
240
+ }
241
+ const configured = approval?.config?.policy
242
+ return configured === 'never' ? 'never' : 'ask'
243
+ }
244
+
245
+ /**
246
+ * Build the §6.1 approval reason: rule id + server name + the full command.
247
+ * @param {{ruleId: string|null, ruleLabel?: string, server: import('./registry.js').ServerRecord, command: string}} input
248
+ * @returns {string}
249
+ */
250
+ export function approvalReason(input) {
251
+ const rule = input.ruleId === null ? 'server policy' : `rule ${input.ruleId}${input.ruleLabel ? ` (${input.ruleLabel})` : ''}`
252
+ return `${rule} matched on server ${input.server.name} (${input.server.id}): ${input.command}`
253
+ }
254
+
255
+ /**
256
+ * Render a job-read delta the way the model reads it: stdout in order, then the
257
+ * stderr chunks in one `[stderr]` section, so a promoted call and the
258
+ * `srv_job_output` reads that follow have the same shape.
259
+ * @param {readonly {text: string, channel?: string}[]} chunks
260
+ * @returns {string}
261
+ */
262
+ export function renderChunks(chunks) {
263
+ const out = chunks.filter((chunk) => chunk.channel !== 'stderr').map((chunk) => chunk.text).join('')
264
+ const err = chunks.filter((chunk) => chunk.channel === 'stderr').map((chunk) => chunk.text).join('')
265
+ const separator = out.length > 0 && !out.endsWith('\n') ? '\n' : ''
266
+ return out + (err.length > 0 ? `${separator}[stderr]\n${err}` : '')
267
+ }
268
+
269
+ /**
270
+ * One line describing how a finished command ended. Kept separately from the
271
+ * output so a test (and the model) can locate it deterministically.
272
+ * @param {{exitCode: number|null, timedOut?: boolean, durationMs?: number}} result
273
+ * @returns {string}
274
+ */
275
+ export function statusLine(result) {
276
+ if (result.timedOut === true) {
277
+ return 'TIMEOUT: the remote watchdog killed the process group; raise timeoutMs or run it with async: true.'
278
+ }
279
+ if (result.exitCode === null || result.exitCode === undefined) return 'exit: unknown'
280
+ if (result.exitCode === 0) return 'exit 0'
281
+ return `exit ${result.exitCode} (EXIT_NONZERO)`
282
+ }
283
+
284
+ /**
285
+ * One `srv_exec` result, canonical shape (doc §5.4). `running` marks a promoted
286
+ * call (poll with `srv_job_output`); `code` carries the §5.5 failure code for a
287
+ * command that did run but did not succeed.
288
+ * @typedef {object} SrvExecValue
289
+ * @property {string} serverId
290
+ * @property {string} serverName
291
+ * @property {string} command
292
+ * @property {'allow'|'confirm'} decision
293
+ * @property {string} [policyRule]
294
+ * @property {string} [jobId]
295
+ * @property {boolean} [running]
296
+ * @property {number} [exitCode]
297
+ * @property {'EXIT_NONZERO'|'TIMEOUT'} [code]
298
+ * @property {string} [stdout]
299
+ * @property {string} [stderr]
300
+ * @property {boolean} [truncated]
301
+ * @property {number} [durationMs]
302
+ * @property {string} [note]
303
+ */
304
+
305
+ /**
306
+ * The model-visible tool surface. One instance per plugin process.
307
+ *
308
+ * `jobs` and `approval` are optional (§9.4): without `approval` every `confirm`
309
+ * decision fails closed as `POLICY_DENIED_BY_USER`; without `jobs` every call
310
+ * is synchronous.
311
+ */
312
+ export class ToolSurface {
313
+ /** @type {import('./registry.js').Registry} */
314
+ #registry
315
+ /** @type {import('./pool.js').Pool} */
316
+ #pool
317
+ /** @type {import('./audit.js').AuditLog|null} */
318
+ #audit
319
+ /** @type {any} */
320
+ #approval
321
+ /** @type {any} */
322
+ #jobs
323
+ /** @type {Partial<import('./index.js').PluginConfig>} */
324
+ #config
325
+ /** @type {{info?: Function, warn?: Function, error?: Function}|null} */
326
+ #logger
327
+ /** @type {import('./registry.js').ServerRecord[]} */
328
+ #servers = []
329
+ /** @type {boolean} */
330
+ #loaded = false
331
+
332
+ /**
333
+ * @param {{
334
+ * registry: import('./registry.js').Registry,
335
+ * pool: import('./pool.js').Pool,
336
+ * audit?: import('./audit.js').AuditLog|null,
337
+ * approval?: any,
338
+ * jobs?: any,
339
+ * config?: Partial<import('./index.js').PluginConfig>,
340
+ * logger?: {info?: Function, warn?: Function, error?: Function}|null,
341
+ * }} options
342
+ */
343
+ constructor(options) {
344
+ if (options?.registry === undefined || options.registry === null) {
345
+ throw new TypeError('ToolSurface requires a registry.')
346
+ }
347
+ if (options?.pool === undefined || options.pool === null) {
348
+ throw new TypeError('ToolSurface requires a pool.')
349
+ }
350
+ this.#registry = options.registry
351
+ this.#pool = options.pool
352
+ this.#audit = options.audit ?? null
353
+ this.#approval = options.approval ?? null
354
+ this.#jobs = options.jobs ?? null
355
+ // The row config is kept as handed over, never flattened: its fields are
356
+ // volatile cells the loader mutates in place when the user edits them, so a
357
+ // snapshot here would freeze the defaults for the life of the plugin.
358
+ this.#config = options.config ?? {}
359
+ this.#logger = options.logger ?? null
360
+ }
361
+
362
+ /**
363
+ * The current §10 values, read at call time.
364
+ *
365
+ * Volatile settings change without remounting this surface, so every read goes
366
+ * through here rather than through a constructor-time snapshot.
367
+ * @returns {import('./index.js').PluginConfig}
368
+ */
369
+ get settings() {
370
+ return readConfig(this.#config)
371
+ }
372
+
373
+ /** The cached server list used by the synchronous system-prompt provider. */
374
+ get servers() {
375
+ return this.#servers
376
+ }
377
+
378
+ /** Whether {@link refresh} has completed at least once. */
379
+ get loaded() {
380
+ return this.#loaded
381
+ }
382
+
383
+ /**
384
+ * Re-read `servers.json` into the cache used by the system-prompt provider
385
+ * (which must be synchronous, `ctx.systemPrompt.section` accepts no promise).
386
+ * Called at startup and after every tool call; phase 5's HTTP routes call it
387
+ * after a mutation so the prompt follows the panel.
388
+ * @returns {Promise<import('./registry.js').ServerRecord[]>}
389
+ */
390
+ async refresh() {
391
+ this.#servers = await this.#registry.list()
392
+ this.#loaded = true
393
+ return this.#servers
394
+ }
395
+
396
+ /**
397
+ * Register the six tools on `ctx.tools` (§5.1).
398
+ * @param {import('@deepseek-ai/cordis').Context} ctx
399
+ * @returns {void}
400
+ */
401
+ register(ctx) {
402
+ for (const definition of [
403
+ this.#listTool(),
404
+ this.#connectTool(),
405
+ this.#execTool(),
406
+ this.#jobOutputTool(),
407
+ this.#jobKillTool(),
408
+ this.#policyCheckTool(),
409
+ ]) {
410
+ ctx.tools.register(definition)
411
+ }
412
+ }
413
+
414
+ /**
415
+ * Register the §5.3 system-prompt section. The provider is synchronous, so it
416
+ * serves the cache; the cache is refreshed at startup and after every tool
417
+ * call.
418
+ * @param {import('@deepseek-ai/cordis').Context} ctx
419
+ * @returns {void}
420
+ */
421
+ registerSystemPrompt(ctx) {
422
+ ctx.effect(() =>
423
+ ctx.systemPrompt.section({
424
+ name: 'dsh-server-connector:servers',
425
+ // Next to the MCP server list: both describe systems reachable from this
426
+ // deployment, and neither is session-scoped.
427
+ order: systemPromptOrder(ctx, 'MCP_SERVERS'),
428
+ text: () =>
429
+ renderServerSection(
430
+ // Disabled servers are invisible here too (doc §5.1): the prompt is
431
+ // the model's map of what it may address, so listing a target that
432
+ // every call would reject only invites wasted attempts.
433
+ (this.#loaded ? this.#servers : []).filter((server) => server.enabled !== false),
434
+ { maxListedServers: this.settings.maxListedServers },
435
+ ),
436
+ }),
437
+ )
438
+ }
439
+
440
+ // ---------------------------------------------------------------- tools --
441
+
442
+ /** @returns {any} */
443
+ #listTool() {
444
+ const surface = this
445
+ return defineTool({
446
+ name: 'srv_list',
447
+ description:
448
+ 'List the servers configured on this machine: id, name, host, default working directory, and whether a connection is currently open. Call this first when unsure which server to address.',
449
+ parameters: {},
450
+ output: {
451
+ schema: {
452
+ type: 'array',
453
+ items: {
454
+ type: 'object',
455
+ additionalProperties: false,
456
+ properties: {
457
+ id: { type: 'string', required: true },
458
+ name: { type: 'string', required: true },
459
+ host: { type: 'string', required: true },
460
+ port: { type: 'number', required: true },
461
+ username: { type: 'string', required: true },
462
+ defaultWorkDir: { type: 'string' },
463
+ connected: { type: 'boolean', required: true },
464
+ hostKeyMode: { type: 'string', enum: ['accept-new', 'verify', 'off'], required: true },
465
+ },
466
+ },
467
+ },
468
+ render: (_args, servers) => [
469
+ {
470
+ type: 'text',
471
+ text:
472
+ servers.length === 0
473
+ ? 'No servers are configured yet. Add one in the "服务器" panel of this plugin.'
474
+ : servers
475
+ .map(
476
+ (server) =>
477
+ `${server.id} ${server.name} ${server.username}@${server.host}:${server.port}` +
478
+ ` ${server.connected ? 'connected' : 'not connected'}` +
479
+ ` ${server.defaultWorkDir === undefined ? '(no default dir)' : `dir ${server.defaultWorkDir}`}`,
480
+ )
481
+ .join('\n'),
482
+ },
483
+ ],
484
+ },
485
+ async execute() {
486
+ await surface.refresh()
487
+ const connected = new Set(surface.poolConnectedIds())
488
+ // A disabled server is invisible to the model (doc §5.1): it is not
489
+ // returned here, not listed in the system prompt, and any attempt to use
490
+ // it fails in #resolveServer. Showing it only to reject it would make the
491
+ // model retry a target it cannot reach.
492
+ return surface.servers.filter((server) => server.enabled !== false).map((server) => ({
493
+ id: server.id,
494
+ name: server.name,
495
+ host: server.host,
496
+ port: server.port,
497
+ username: server.username,
498
+ ...(server.defaultWorkDir === null ? {} : { defaultWorkDir: server.defaultWorkDir }),
499
+ connected: connected.has(server.id),
500
+ hostKeyMode: server.hostKeyMode,
501
+ }))
502
+ },
503
+ })
504
+ }
505
+
506
+ /** @returns {any} */
507
+ #connectTool() {
508
+ const surface = this
509
+ return defineTool({
510
+ name: 'srv_connect',
511
+ description:
512
+ 'Open (or reuse) the SSH connection for one server and report what the remote host is: platform, kernel, login shell and sudo availability. Safe to call repeatedly; srv_exec connects on its own.',
513
+ parameters: {
514
+ server: {
515
+ type: 'string',
516
+ required: true,
517
+ description: 'Server id, exact name, or an unambiguous prefix of either (see srv_list).',
518
+ },
519
+ },
520
+ output: {
521
+ schema: {
522
+ type: 'object',
523
+ additionalProperties: false,
524
+ properties: {
525
+ serverId: { type: 'string', required: true },
526
+ serverName: { type: 'string', required: true },
527
+ platform: { type: 'string', required: true },
528
+ kernel: { type: 'string', required: true },
529
+ shell: { type: 'string', required: true },
530
+ sudo: { type: 'string', required: true },
531
+ reconnected: { type: 'boolean', required: true },
532
+ durationMs: { type: 'number', required: true },
533
+ },
534
+ },
535
+ render: (_args, value) => [
536
+ {
537
+ type: 'text',
538
+ text: [
539
+ `${value.serverId} (${value.serverName}) connected in ${value.durationMs} ms`,
540
+ `platform: ${value.platform}`,
541
+ `kernel: ${value.kernel}`,
542
+ `shell: ${value.shell}`,
543
+ `sudo: ${value.sudo}`,
544
+ ].join('\n'),
545
+ },
546
+ ],
547
+ },
548
+ async execute(args, exec) {
549
+ await surface.refresh()
550
+ const server = await surface.#resolveServer(args.server)
551
+ const startedAt = Date.now()
552
+ const { reconnected } = await surface.#callPool('connect', server, exec)
553
+ const probe = await surface.#callPool(
554
+ 'exec',
555
+ server,
556
+ { command: CONNECT_PROBE, timeoutMs: Math.min(surface.settings.commandTimeoutMs, 15000) },
557
+ exec,
558
+ )
559
+ const parsed = parseProbe(String(probe.stdout ?? ''))
560
+ return {
561
+ serverId: server.id,
562
+ serverName: server.name,
563
+ ...parsed,
564
+ reconnected: reconnected !== false,
565
+ durationMs: Date.now() - startedAt,
566
+ }
567
+ },
568
+ })
569
+ }
570
+
571
+ /** @returns {any} */
572
+ #execTool() {
573
+ const surface = this
574
+ return defineTool({
575
+ name: 'srv_exec',
576
+ description:
577
+ 'Run a shell command on one configured server. High-risk commands are blocked or require the user to confirm first (see the server list in the system prompt, or pre-check with srv_policy_check). Output beyond the configured ceiling is cut and marked TRUNCATED; a command that outlives the sync budget returns a job id to poll with srv_job_output.',
578
+ parameters: {
579
+ server: {
580
+ type: 'string',
581
+ required: true,
582
+ description: 'Server id, exact name, or an unambiguous prefix of either.',
583
+ },
584
+ command: {
585
+ type: 'string',
586
+ required: true,
587
+ description: 'The shell command to run on the remote host.',
588
+ },
589
+ cwd: {
590
+ type: 'string',
591
+ description: "Working directory; defaults to the server's defaultWorkDir.",
592
+ },
593
+ timeoutMs: {
594
+ type: 'number',
595
+ description:
596
+ 'Wall-clock budget in milliseconds (default from the plugin config, 60000). On expiry the remote watchdog kills the process group.',
597
+ },
598
+ async: {
599
+ type: 'boolean',
600
+ description: 'Return a job id immediately instead of waiting for the first syncTimeoutMs window.',
601
+ },
602
+ },
603
+ output: {
604
+ schema: {
605
+ type: 'object',
606
+ additionalProperties: false,
607
+ properties: {
608
+ serverId: { type: 'string', required: true },
609
+ serverName: { type: 'string', required: true },
610
+ command: { type: 'string', required: true },
611
+ decision: { type: 'string', enum: ['allow', 'confirm'], required: true },
612
+ policyRule: { type: 'string' },
613
+ jobId: { type: 'string' },
614
+ running: { type: 'boolean' },
615
+ exitCode: { type: 'number' },
616
+ code: { type: 'string', enum: ['EXIT_NONZERO', 'TIMEOUT'] },
617
+ stdout: { type: 'string' },
618
+ stderr: { type: 'string' },
619
+ truncated: { type: 'boolean' },
620
+ durationMs: { type: 'number' },
621
+ note: { type: 'string' },
622
+ },
623
+ },
624
+ render: (_args, value) => {
625
+ if (value.running === true) {
626
+ return [
627
+ {
628
+ type: 'text',
629
+ text:
630
+ `started background job ${value.jobId} on ${value.serverId} (${value.serverName})\n` +
631
+ `command: ${value.command}\n` +
632
+ 'Read it with srv_job_output, stop it with srv_job_kill.',
633
+ },
634
+ ]
635
+ }
636
+ const header = [
637
+ `${value.serverId} (${value.serverName})`,
638
+ statusLine({ exitCode: value.exitCode ?? null, timedOut: value.code === 'TIMEOUT' }),
639
+ value.durationMs === undefined ? undefined : `${value.durationMs} ms`,
640
+ value.decision === 'confirm' ? 'approved by user' : undefined,
641
+ value.truncated === true ? 'TRUNCATED' : undefined,
642
+ ]
643
+ .filter((part) => part !== undefined)
644
+ .join(' ')
645
+ const body = [value.stdout ?? '', value.stderr === undefined ? '' : `[stderr]\n${value.stderr}`]
646
+ .filter((part) => part !== '')
647
+ .join(value.stdout !== undefined && !value.stdout.endsWith('\n') && value.stderr ? '\n' : '')
648
+ return [{ type: 'text', text: `${header}\n${body}${value.note === undefined ? '' : `\n[${value.note}]`}` }]
649
+ },
650
+ },
651
+ async execute(args, exec) {
652
+ return surface.#exec(args, exec)
653
+ },
654
+ presentCall: (args) => ({
655
+ card: 'terminal',
656
+ title: args.command,
657
+ description: `server ${args.server}`,
658
+ }),
659
+ })
660
+ }
661
+
662
+ /** @returns {any} */
663
+ #jobOutputTool() {
664
+ const surface = this
665
+ return defineTool({
666
+ name: 'srv_job_output',
667
+ description:
668
+ 'Read the output of a running or finished srv_exec job. Returns only what is new since the previous read (or since `offset`), plus the next byte offset to resume from. A job whose ring dropped bytes reports lossy: true with the spill file paths.',
669
+ parameters: {
670
+ jobId: { type: 'string', required: true, description: 'Job id returned by srv_exec.' },
671
+ offset: {
672
+ type: 'number',
673
+ description: 'Absolute byte offset to read from. Omit to continue from the previous read.',
674
+ },
675
+ },
676
+ output: {
677
+ schema: {
678
+ type: 'object',
679
+ additionalProperties: false,
680
+ properties: {
681
+ jobId: { type: 'string', required: true },
682
+ status: { type: 'string', required: true },
683
+ text: { type: 'string', required: true },
684
+ next: { type: 'number', required: true },
685
+ lossy: { type: 'boolean', required: true },
686
+ detail: { type: 'string' },
687
+ spillPaths: { type: 'array', items: { type: 'string' } },
688
+ result: { type: 'string' },
689
+ },
690
+ },
691
+ render: (_args, value) => [
692
+ {
693
+ type: 'text',
694
+ text: [
695
+ `${value.jobId} ${value.status}${value.detail === undefined ? '' : ` (${value.detail})`}`,
696
+ value.lossy ? `[some output was dropped; full output: ${(value.spillPaths ?? []).join(', ') || '(unavailable)'}]` : undefined,
697
+ value.text === '' ? '(no new output)' : value.text,
698
+ `next: ${value.next}`,
699
+ ]
700
+ .filter((part) => part !== undefined)
701
+ .join('\n'),
702
+ },
703
+ ],
704
+ },
705
+ async execute(args, exec) {
706
+ const jobs = surface.#jobs
707
+ if (jobs === null || jobs === undefined) {
708
+ throw new ToolError(
709
+ 'INVALID_ARGUMENT',
710
+ 'this deployment has no background-job service, so srv_exec never produces a job.',
711
+ 'Run the command without async: true (it then runs synchronously).',
712
+ )
713
+ }
714
+ const id = String(args.jobId)
715
+ // The owner is the calling session, so it must come from this call's
716
+ // `exec`. Passing `undefined` here made every read look unowned, and the
717
+ // registry — which fences owned jobs by session — then rejected it with
718
+ // "belongs to another session" even for the session that started it.
719
+ const caller = surface.#ownerOf(exec)
720
+ if (typeof args.offset === 'number') {
721
+ if (!Number.isFinite(args.offset) || args.offset < 0) {
722
+ throw new ToolError('INVALID_ARGUMENT', 'offset must be a non-negative byte offset.')
723
+ }
724
+ const read = jobs.readAt(id, Math.floor(args.offset), caller)
725
+ const job = jobs.get(id, caller)
726
+ return {
727
+ jobId: id,
728
+ status: job.status,
729
+ text: renderChunks(read.chunks),
730
+ next: read.next,
731
+ lossy: read.lossy === true,
732
+ ...(job.detail === undefined ? {} : { detail: job.detail }),
733
+ ...(job.output?.spillPaths === undefined ? {} : { spillPaths: job.output.spillPaths }),
734
+ }
735
+ }
736
+ const read = jobs.read(id, caller)
737
+ return {
738
+ jobId: id,
739
+ status: read.job.status,
740
+ text: renderChunks(read.chunks),
741
+ next: read.job.output.total,
742
+ lossy: read.lossy === true,
743
+ ...(read.job.detail === undefined ? {} : { detail: read.job.detail }),
744
+ ...(read.job.output.spillPaths === undefined ? {} : { spillPaths: read.job.output.spillPaths }),
745
+ ...(read.result === undefined ? {} : { result: read.result }),
746
+ }
747
+ },
748
+ })
749
+ }
750
+
751
+ /** @returns {any} */
752
+ #jobKillTool() {
753
+ const surface = this
754
+ return defineTool({
755
+ name: 'srv_job_kill',
756
+ description: 'Cancel a running srv_exec job. A finished job reports already-finished.',
757
+ parameters: {
758
+ jobId: { type: 'string', required: true, description: 'Job id returned by srv_exec.' },
759
+ },
760
+ output: {
761
+ schema: {
762
+ type: 'object',
763
+ additionalProperties: false,
764
+ properties: {
765
+ jobId: { type: 'string', required: true },
766
+ outcome: { type: 'string', enum: ['cancellation-requested', 'already-finished'], required: true },
767
+ status: { type: 'string', required: true },
768
+ },
769
+ },
770
+ render: (_args, value) => [
771
+ {
772
+ type: 'text',
773
+ text:
774
+ value.outcome === 'cancellation-requested'
775
+ ? `requested cancellation of job ${value.jobId} (status: ${value.status})`
776
+ : `job ${value.jobId} had already finished (status: ${value.status})`,
777
+ },
778
+ ],
779
+ },
780
+ async execute(args, exec) {
781
+ const jobs = surface.#jobs
782
+ if (jobs === null || jobs === undefined) {
783
+ throw new ToolError(
784
+ 'INVALID_ARGUMENT',
785
+ 'this deployment has no background-job service, so no srv_exec job can exist.',
786
+ 'srv_exec ran synchronously; nothing is left running.',
787
+ )
788
+ }
789
+ const id = String(args.jobId)
790
+ // Same owner fence as srv_job_output: the caller is this call's session.
791
+ const caller = surface.#ownerOf(exec)
792
+ const outcome = jobs.kill(id, caller)
793
+ const job = jobs.get(id, caller)
794
+ /** @type {'cancellation-requested'|'already-finished'} */
795
+ const cancellation = outcome === 'requested' ? 'cancellation-requested' : 'already-finished'
796
+ return { jobId: id, outcome: cancellation, status: job.status }
797
+ },
798
+ })
799
+ }
800
+
801
+ /** @returns {any} */
802
+ #policyCheckTool() {
803
+ const surface = this
804
+ return defineTool({
805
+ name: 'srv_policy_check',
806
+ description:
807
+ 'Decide how srv_exec would treat a command on one server — allow, confirm (the user is asked) or block (never runs) — without connecting or executing anything. Use it before running something the built-in rules might catch.',
808
+ parameters: {
809
+ server: { type: 'string', required: true, description: 'Server id, exact name, or an unambiguous prefix.' },
810
+ command: { type: 'string', required: true, description: 'The command to judge.' },
811
+ },
812
+ output: {
813
+ schema: {
814
+ type: 'object',
815
+ additionalProperties: false,
816
+ properties: {
817
+ serverId: { type: 'string', required: true },
818
+ serverName: { type: 'string', required: true },
819
+ command: { type: 'string', required: true },
820
+ decision: { type: 'string', enum: ['allow', 'confirm', 'block'], required: true },
821
+ ruleId: { type: 'string' },
822
+ ruleLabel: { type: 'string' },
823
+ matches: {
824
+ type: 'array',
825
+ items: {
826
+ type: 'object',
827
+ additionalProperties: false,
828
+ properties: {
829
+ ruleId: { type: 'string', required: true },
830
+ decision: { type: 'string', enum: ['allow', 'confirm', 'block'], required: true },
831
+ },
832
+ },
833
+ },
834
+ },
835
+ },
836
+ render: (_args, value) => [
837
+ {
838
+ type: 'text',
839
+ text:
840
+ `${value.decision.toUpperCase()} on ${value.serverId} (${value.serverName})` +
841
+ `${value.ruleId === undefined ? '' : ` — rule ${value.ruleId}${value.ruleLabel === undefined ? '' : ` (${value.ruleLabel})`}`}\n` +
842
+ `command: ${value.command}` +
843
+ (value.decision === 'block' ? '\nThis command will not run: the rule blocks it. Ask the user to change the server policy if it is really needed.' : ''),
844
+ },
845
+ ],
846
+ },
847
+ async execute(args) {
848
+ await surface.refresh()
849
+ const server = await surface.#resolveServer(args.server)
850
+ const command = requireCommand(args.command)
851
+ const verdict = checkPolicy(command, server.policy, server.disabledRules)
852
+ const matched = verdict.matchedRule
853
+ return {
854
+ serverId: server.id,
855
+ serverName: server.name,
856
+ command,
857
+ decision: verdict.decision,
858
+ ...(matched === null ? {} : { ruleId: matched.id, ruleLabel: matched.label }),
859
+ matches: verdict.matches.map((match) => ({ ruleId: match.id, decision: match.decision })),
860
+ }
861
+ },
862
+ })
863
+ }
864
+
865
+ // ------------------------------------------------------------- internals --
866
+
867
+ /**
868
+ * Resolve and cache-refresh one `server` argument (§5.2).
869
+ * @param {unknown} query
870
+ * @returns {Promise<import('./registry.js').ServerRecord>}
871
+ */
872
+ async #resolveServer(query) {
873
+ /** @type {import('./registry.js').ServerRecord} */
874
+ let server
875
+ try {
876
+ server = await this.#registry.resolve(String(query ?? ''))
877
+ } catch (error) {
878
+ const code = /** @type {{code?: string, message?: string}} */ (error).code
879
+ if (code === 'UNKNOWN_SERVER' || code === 'AMBIGUOUS_SERVER' || code === 'INVALID_SERVER') {
880
+ throw new ToolError(
881
+ /** @type {ToolErrorCode} */ (code),
882
+ String(/** @type {{message?: string}} */ (error).message ?? error),
883
+ code === 'UNKNOWN_SERVER' ? 'Call srv_list to see the configured servers.' : undefined,
884
+ )
885
+ }
886
+ throw error
887
+ }
888
+ // The enable switch (doc §5.1). Every srv_* tool resolves its target here,
889
+ // so one check covers srv_exec, srv_connect, srv_policy_check and the job
890
+ // tools alike. A disabled server is still addressable by id/name/prefix —
891
+ // that is how the panel names it — so the check belongs after resolution,
892
+ // not inside it.
893
+ if (server.enabled === false) {
894
+ throw new ToolError(
895
+ 'SERVER_DISABLED',
896
+ `server ${server.id} (${server.name}) is disabled.`,
897
+ 'Enable it in the "服务器" panel of this plugin, then retry.',
898
+ )
899
+ }
900
+ return server
901
+ }
902
+
903
+ /**
904
+ * Call one pool method and normalise its failures into §5.5 tool errors.
905
+ * @param {'connect'|'exec'|'execStream'|'disconnect'} method
906
+ * @param {...any} args
907
+ * @returns {Promise<any>}
908
+ */
909
+ async #callPool(method, ...args) {
910
+ try {
911
+ // The pool's methods have unrelated signatures; this dispatcher exists only
912
+ // so every transport failure is normalised in one place.
913
+ const call = /** @type {(...a: any[]) => Promise<any>} */ (/** @type {any} */ (this.#pool)[method]).bind(
914
+ this.#pool,
915
+ )
916
+ return await call(...args)
917
+ } catch (error) {
918
+ throw normalizePoolFailure(error)
919
+ }
920
+ }
921
+
922
+ /**
923
+ * Ids with a live connection, for `srv_list` (§5.1 "是否已连接").
924
+ * @returns {string[]}
925
+ */
926
+ poolConnectedIds() {
927
+ try {
928
+ return this.#pool.connectedServerIds()
929
+ } catch {
930
+ return []
931
+ }
932
+ }
933
+
934
+ /**
935
+ * The three gates of §6.1, then execution.
936
+ * @param {{server: string, command: string, cwd?: string, timeoutMs?: number, async?: boolean}} args
937
+ * @param {import('@deepseek-ai/dsh-tools').ToolRunContext} exec
938
+ * @returns {Promise<SrvExecValue>}
939
+ */
940
+ async #exec(args, exec) {
941
+ await this.refresh()
942
+ const server = await this.#resolveServer(args.server)
943
+ const command = requireCommand(args.command)
944
+ const settings = this.settings
945
+ const timeoutMs = positiveInteger(args.timeoutMs, 'timeoutMs') ?? settings.commandTimeoutMs
946
+ const cwd = typeof args.cwd === 'string' && args.cwd.trim() !== '' ? args.cwd : undefined
947
+ const auditBase = {
948
+ sessionId: this.#sessionIdOf(exec),
949
+ serverId: server.id,
950
+ serverName: server.name,
951
+ command,
952
+ }
953
+
954
+ // ① policy. A block never connects and never executes.
955
+ const verdict = checkPolicy(command, server.policy, server.disabledRules)
956
+ if (verdict.decision === 'block') {
957
+ await this.#auditExec({ ...auditBase, decision: 'block', policyRule: verdict.ruleId })
958
+ throw new ToolError(
959
+ 'POLICY_BLOCKED',
960
+ `command blocked by rule ${verdict.ruleId ?? 'unknown'}${
961
+ verdict.matchedRule === null ? '' : ` (${verdict.matchedRule.label})`
962
+ } on server ${server.name} (${server.id}): ${command}`,
963
+ 'Nothing was sent to the server. Ask the user to allow this rule for that server, or rephrase the command.',
964
+ )
965
+ }
966
+
967
+ // ② approval. Only 'allowed-once' proceeds; everything else fails closed.
968
+ /** @type {boolean|null} */
969
+ let approved = null
970
+ if (verdict.decision === 'confirm') {
971
+ const outcome = await this.#requestApproval({
972
+ exec,
973
+ server,
974
+ command,
975
+ ruleId: verdict.ruleId,
976
+ ruleLabel: verdict.matchedRule?.label,
977
+ })
978
+ if (!isGranted(outcome)) {
979
+ await this.#auditExec({ ...auditBase, decision: 'confirm', policyRule: verdict.ruleId, approved: false })
980
+ throw this.#denialError(outcome, exec, server, command)
981
+ }
982
+ approved = true
983
+ }
984
+
985
+ // ③ execute, then audit.
986
+ const jobs = this.#jobs
987
+ if (jobs === null || jobs === undefined) {
988
+ const result = await this.#runSync({ server, command, cwd, timeoutMs, exec, asyncRequested: args.async === true })
989
+ await this.#auditExec({
990
+ ...auditBase,
991
+ decision: verdict.decision,
992
+ policyRule: verdict.ruleId,
993
+ approved,
994
+ exitCode: result.exitCode,
995
+ durationMs: result.durationMs,
996
+ truncated: result.truncated,
997
+ })
998
+ return result
999
+ }
1000
+
1001
+ const started = this.#startJob({ server, command, cwd, timeoutMs, exec })
1002
+ const owner = this.#ownerOf(exec)
1003
+ if (args.async === true) {
1004
+ /** @type {SrvExecValue} */
1005
+ const startedValue = {
1006
+ serverId: server.id,
1007
+ serverName: server.name,
1008
+ command,
1009
+ // The block branch returned above, so the decision is allow or confirm.
1010
+ decision: /** @type {'allow'|'confirm'} */ (verdict.decision),
1011
+ ...(verdict.ruleId === null ? {} : { policyRule: verdict.ruleId }),
1012
+ jobId: started.id,
1013
+ running: true,
1014
+ }
1015
+ return startedValue
1016
+ }
1017
+
1018
+ let view
1019
+ try {
1020
+ view = await jobs.wait(started.id, settings.syncTimeoutMs, owner, exec.signal)
1021
+ } catch (error) {
1022
+ jobs.kill(started.id, owner, 'the srv_exec call was aborted')
1023
+ throw error
1024
+ }
1025
+ this.#log('info', 'dsh-server-connector: srv_exec on %s settled as %s', server.id, view.status)
1026
+
1027
+ if (view.status === 'running' || view.status === 'stopping') {
1028
+ return {
1029
+ serverId: server.id,
1030
+ serverName: server.name,
1031
+ command,
1032
+ decision: verdict.decision,
1033
+ ...(verdict.ruleId === null ? {} : { policyRule: verdict.ruleId }),
1034
+ jobId: started.id,
1035
+ running: true,
1036
+ note: `still running after ${settings.syncTimeoutMs} ms; use srv_job_output`,
1037
+ }
1038
+ }
1039
+
1040
+ // The call itself collected the terminal state and never handed the id out,
1041
+ // so the record leaves with the call (§12 / jobs start+wait+remove pattern).
1042
+ jobs.remove(started.id, owner)
1043
+ const settled = started.outcome()
1044
+ await this.#auditExec({
1045
+ ...auditBase,
1046
+ decision: verdict.decision,
1047
+ policyRule: verdict.ruleId,
1048
+ approved,
1049
+ exitCode: settled?.exitCode ?? null,
1050
+ durationMs: settled?.durationMs,
1051
+ truncated: settled?.truncated ?? false,
1052
+ })
1053
+ return this.#resultOf({ server, command, verdict, approved, result: settled })
1054
+ }
1055
+
1056
+ /**
1057
+ * Ask the user through the DSH approval service (§6.1) and report the closed
1058
+ * outcome. A missing service or a missing agent fails closed as
1059
+ * `'unavailable'` rather than running the command unasked.
1060
+ * @param {{exec: any, server: any, command: string, ruleId: string|null, ruleLabel?: string}} input
1061
+ * @returns {Promise<string>}
1062
+ */
1063
+ async #requestApproval(input) {
1064
+ const approval = this.#approval
1065
+ const agent = input.exec?.agent
1066
+ if (approval === null || approval === undefined || typeof approval.request !== 'function') {
1067
+ return 'unavailable'
1068
+ }
1069
+ if (agent === undefined) return 'unavailable'
1070
+ try {
1071
+ return await approval.request({
1072
+ agent,
1073
+ toolName: 'srv_exec',
1074
+ ...(input.exec.callId === undefined ? {} : { callId: input.exec.callId }),
1075
+ reason: approvalReason({
1076
+ ruleId: input.ruleId,
1077
+ ruleLabel: input.ruleLabel,
1078
+ server: input.server,
1079
+ command: input.command,
1080
+ }),
1081
+ ...(input.exec.signal === undefined ? {} : { signal: input.exec.signal }),
1082
+ })
1083
+ } catch (error) {
1084
+ this.#log('warn', 'dsh-server-connector: approval.request failed: %s', String(error))
1085
+ return 'unavailable'
1086
+ }
1087
+ }
1088
+
1089
+ /**
1090
+ * The §6.1 rejection error, wording the outcome and — when the session policy
1091
+ * is `never` — saying so explicitly, because that is the case the user can fix
1092
+ * without a prompt ever appearing.
1093
+ * @param {string} outcome
1094
+ * @param {any} exec
1095
+ * @param {import('./registry.js').ServerRecord} server
1096
+ * @param {string} command
1097
+ * @returns {ToolError}
1098
+ */
1099
+ #denialError(outcome, exec, server, command) {
1100
+ const context = `${server.name} (${server.id}): ${command}`
1101
+ if (outcome === 'rejected' && approvalPolicyOf(this.#approval, exec?.agent) === 'never') {
1102
+ return new ToolError(
1103
+ 'POLICY_DENIED_BY_USER',
1104
+ `当前会话审批策略为 never,高危命令无法确认,命令未执行 — ${context}`,
1105
+ 'The session approval policy is "never", so confirmation prompts are auto-rejected. Switch the session to the "ask" policy (or run the command manually on the server) and retry.',
1106
+ )
1107
+ }
1108
+ if (outcome === 'rejected') {
1109
+ return new ToolError(
1110
+ 'POLICY_DENIED_BY_USER',
1111
+ `the user rejected this command — ${context}`,
1112
+ 'Nothing was sent to the server. Ask the user what to do instead, or use srv_policy_check to find an allowed form.',
1113
+ )
1114
+ }
1115
+ if (outcome === 'cancelled') {
1116
+ return new ToolError(
1117
+ 'POLICY_DENIED_BY_USER',
1118
+ `the confirmation was cancelled before it was answered — ${context}`,
1119
+ 'Nothing was sent to the server. Retry when the user is ready to decide.',
1120
+ )
1121
+ }
1122
+ return new ToolError(
1123
+ 'POLICY_DENIED_BY_USER',
1124
+ `the confirmation could not be shown (no approval answerer is available) — ${context}`,
1125
+ 'Nothing was sent to the server. Ask the user to run the command or to enable an interactive approval surface.',
1126
+ )
1127
+ }
1128
+
1129
+ /**
1130
+ * Execute without `ctx.jobs` (§9.4: "无 jobs → 强制同步执行 + 硬超时").
1131
+ * @param {{server: any, command: string, cwd?: string, timeoutMs: number, exec: any, asyncRequested: boolean}} input
1132
+ * @returns {Promise<SrvExecValue>}
1133
+ */
1134
+ async #runSync(input) {
1135
+ const result = await this.#callPool(
1136
+ 'exec',
1137
+ input.server,
1138
+ {
1139
+ command: input.command,
1140
+ ...(input.cwd === undefined ? {} : { cwd: input.cwd }),
1141
+ timeoutMs: input.timeoutMs,
1142
+ },
1143
+ input.exec,
1144
+ )
1145
+ const verdict = checkPolicy(input.command, input.server.policy, input.server.disabledRules)
1146
+ return this.#resultOf({
1147
+ server: input.server,
1148
+ command: input.command,
1149
+ verdict,
1150
+ approved: null,
1151
+ result,
1152
+ note: input.asyncRequested ? 'this deployment has no background-job service, so the call ran synchronously' : undefined,
1153
+ })
1154
+ }
1155
+
1156
+ /**
1157
+ * Shape one finished execution into the canonical tool result.
1158
+ * @param {{server: any, command: string, verdict: any, approved: boolean|null, result: any, note?: string}} input
1159
+ * @returns {SrvExecValue}
1160
+ */
1161
+ #resultOf(input) {
1162
+ const result = input.result ?? {}
1163
+ const timedOut = result.timedOut === true
1164
+ const exitCode = typeof result.exitCode === 'number' ? result.exitCode : null
1165
+ /** @type {SrvExecValue} */
1166
+ const value = {
1167
+ serverId: input.server.id,
1168
+ serverName: input.server.name,
1169
+ command: input.command,
1170
+ decision: input.verdict.decision === 'block' ? 'allow' : input.verdict.decision,
1171
+ ...(input.verdict.ruleId === null ? {} : { policyRule: input.verdict.ruleId }),
1172
+ ...(exitCode === null ? {} : { exitCode }),
1173
+ stdout: String(result.stdout ?? ''),
1174
+ stderr: String(result.stderr ?? ''),
1175
+ truncated: result.truncated === true,
1176
+ durationMs: typeof result.durationMs === 'number' ? result.durationMs : 0,
1177
+ }
1178
+ if (timedOut) value.code = 'TIMEOUT'
1179
+ else if (exitCode !== null && exitCode !== 0) value.code = 'EXIT_NONZERO'
1180
+ if (input.note !== undefined) value.note = input.note
1181
+ return value
1182
+ }
1183
+
1184
+ /**
1185
+ * Register one `srv_exec` as a job and start the SSH channel inside it. The
1186
+ * job owns the stream from its first byte, so a promoted call keeps emitting
1187
+ * into the ring that `srv_job_output` reads (§5.4).
1188
+ * @param {{server: any, command: string, cwd?: string, timeoutMs: number, exec: any}} input
1189
+ * @returns {{id: string, outcome: () => any}}
1190
+ */
1191
+ #startJob(input) {
1192
+ const jobs = this.#jobs
1193
+ /** @type {any} */
1194
+ let settled = null
1195
+ /** @type {(() => void)|null} */
1196
+ let cancelStream = null
1197
+ const maxOutputChars = this.settings.maxOutputChars
1198
+ const label = `${input.server.id}: ${input.command}`
1199
+
1200
+ /** @type {any} */
1201
+ const spec = {
1202
+ kind: 'ssh',
1203
+ label,
1204
+ ...(this.#ownerOf(input.exec) === undefined ? {} : { owner: this.#ownerOf(input.exec) }),
1205
+ ...(input.exec?.callId === undefined
1206
+ ? {}
1207
+ : { toolCall: { callId: input.exec.callId, rootCallId: input.exec.rootCallId ?? input.exec.callId } }),
1208
+ run: (/** @type {any} */ job) => {
1209
+ /** @type {Promise<any>} */
1210
+ let streaming
1211
+ try {
1212
+ streaming = this.#pool.execStream(
1213
+ input.server,
1214
+ {
1215
+ command: input.command,
1216
+ ...(input.cwd === undefined ? {} : { cwd: input.cwd }),
1217
+ timeoutMs: input.timeoutMs,
1218
+ maxOutputChars,
1219
+ onChunk: (/** @type {'stdout'|'stderr'} */ channel, /** @type {string} */ text) => {
1220
+ job.append(text, { channel })
1221
+ },
1222
+ onCancel: (/** @type {() => void} */ cancel) => {
1223
+ cancelStream = cancel
1224
+ },
1225
+ },
1226
+ input.exec?.signal,
1227
+ )
1228
+ } catch (error) {
1229
+ return {
1230
+ cancel: () => {},
1231
+ done: Promise.resolve({ status: 'failed', detail: String(error) }),
1232
+ }
1233
+ }
1234
+ const done = streaming.then(
1235
+ (/** @type {any} */ result) => {
1236
+ settled = result
1237
+ if (result.truncated === true) job.append('\n…[TRUNCATED]')
1238
+ /** @type {any} */
1239
+ const outcome = result.cancelled === true
1240
+ ? { status: 'killed', detail: 'cancelled by srv_job_kill' }
1241
+ : {
1242
+ status: 'completed',
1243
+ detail: result.timedOut === true ? 'TIMEOUT (process group killed)' : `exit code: ${result.exitCode}`,
1244
+ result: JSON.stringify({
1245
+ exitCode: result.exitCode,
1246
+ timedOut: result.timedOut,
1247
+ truncated: result.truncated,
1248
+ durationMs: result.durationMs,
1249
+ }),
1250
+ }
1251
+ return outcome
1252
+ },
1253
+ (/** @type {any} */ error) => ({
1254
+ status: 'failed',
1255
+ detail: normalizePoolFailure(error).message,
1256
+ }),
1257
+ )
1258
+ return {
1259
+ cancel: () => {
1260
+ if (cancelStream !== null) cancelStream()
1261
+ },
1262
+ done,
1263
+ }
1264
+ },
1265
+ }
1266
+ const id = jobs.start(spec)
1267
+ return { id, outcome: () => settled }
1268
+ }
1269
+
1270
+ /**
1271
+ * Write one audit line (§6.2). Audit failures are logged, never surfaced: the
1272
+ * command's outcome must not change because a log file is unwritable.
1273
+ * @param {Record<string, any>} entry
1274
+ * @returns {Promise<void>}
1275
+ */
1276
+ async #auditExec(entry) {
1277
+ const audit = this.#audit
1278
+ if (audit === null || audit === undefined) return
1279
+ try {
1280
+ await audit.append(entry)
1281
+ } catch (error) {
1282
+ this.#log('error', 'dsh-server-connector: audit append failed: %s', String(error))
1283
+ }
1284
+ }
1285
+
1286
+ /**
1287
+ * @param {any} exec
1288
+ * @returns {string|undefined}
1289
+ */
1290
+ #sessionIdOf(exec) {
1291
+ const id = exec?.agent?.id ?? exec?.agent?.session?.id
1292
+ return typeof id === 'string' ? id : undefined
1293
+ }
1294
+
1295
+ /**
1296
+ * The reading/owning session of a job call, or undefined for an unowned job.
1297
+ * @param {any} exec
1298
+ * @returns {any}
1299
+ */
1300
+ #ownerOf(exec) {
1301
+ return exec?.agent?.id
1302
+ }
1303
+
1304
+ /**
1305
+ * @param {'info'|'warn'|'error'} level
1306
+ * @param {string} message
1307
+ * @param {...unknown} args
1308
+ * @returns {void}
1309
+ */
1310
+ #log(level, message, ...args) {
1311
+ const logger = this.#logger
1312
+ if (logger === null || logger === undefined) return
1313
+ const write = logger[level] ?? logger.info
1314
+ if (typeof write === 'function') write.call(logger, message, ...args)
1315
+ }
1316
+ }
1317
+
1318
+ /**
1319
+ * Map a transport failure onto a §5.5 tool error, keeping the actionable next
1320
+ * step the pool already composed.
1321
+ * @param {unknown} error
1322
+ * @returns {ToolError}
1323
+ */
1324
+ export function normalizePoolFailure(error) {
1325
+ if (error instanceof ToolError) return error
1326
+ const code = /** @type {{code?: string}} */ (error).code
1327
+ const known = /** @type {ToolErrorCode[]} */ ([
1328
+ 'CONNECT_FAILED',
1329
+ 'HOST_KEY_UNKNOWN',
1330
+ 'AUTH_FAILED',
1331
+ 'TIMEOUT',
1332
+ 'NO_CREDENTIAL',
1333
+ 'EXIT_NONZERO',
1334
+ 'INVALID_ARGUMENT',
1335
+ ])
1336
+ const message = error instanceof Error ? error.message : String(error)
1337
+ if (typeof code === 'string' && known.includes(/** @type {ToolErrorCode} */ (code))) {
1338
+ return new ToolError(/** @type {ToolErrorCode} */ (code), message)
1339
+ }
1340
+ return new ToolError('CONNECT_FAILED', message, 'Retry; if it persists, check that sshd is running on the target.')
1341
+ }
1342
+
1343
+ /**
1344
+ * @param {unknown} value
1345
+ * @returns {string}
1346
+ */
1347
+ function requireCommand(value) {
1348
+ if (typeof value !== 'string' || value.trim() === '') {
1349
+ throw new ToolError('INVALID_ARGUMENT', 'command must be a non-empty string.', 'Pass the shell command to run.')
1350
+ }
1351
+ return value
1352
+ }
1353
+
1354
+ /**
1355
+ * @param {unknown} value
1356
+ * @param {string} label
1357
+ * @returns {number|undefined}
1358
+ */
1359
+ function positiveInteger(value, label) {
1360
+ if (value === undefined || value === null) return undefined
1361
+ if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) {
1362
+ throw new ToolError('INVALID_ARGUMENT', `${label} must be a positive number of milliseconds.`)
1363
+ }
1364
+ return Math.round(value)
1365
+ }
1366
+
1367
+ export default ToolSurface