/** * dsh-ssh — host half. Mounts the SSH engine (persistent ssh2 connection * pool, exec / PTY shell / SFTP / tunnels / cluster), the /api/dsh-ssh route * family plus the terminal WebSocket upgrade, the agent tools (ssh_list, * ssh_exec, ssh_upload, ssh_download, ssh_tunnel, ssh_cluster), and a * system-prompt announcement. The browser half (./client) renders the host * manager and web terminal. Everything rides official NPM SDK packages — * no dsh source changes. */ import type { Context } from '@deepseek-ai/cordis' import type { SettingsNamespace } from '@deepseek-ai/dsh-settings' import z from 'schemastery' import type {} from '@deepseek-ai/dsh-host-webserver' import type {} from '@deepseek-ai/dsh-system-prompt' import type {} from '@deepseek-ai/dsh-tools' import { SshEngine } from './engine.ts' import { makeRoutes } from './routes.ts' import { HostStore } from './store.ts' import { sshClusterTool, sshDownloadTool, sshExecTool, sshListTool, sshTunnelTool, sshUploadTool } from './tools.ts' import { mountOnce } from './mount-once.ts' /** Stable cordis plugin name. */ export const name = 'ssh' /** Services required before the SSH surfaces can mount. */ export const inject = ['webServer', 'tools', 'systemPrompt'] /** * Settings namespace of the SSH capability — the section the web settings * surface edits. Spelled here rather than imported: the browser half spells * the same value and must not depend on a Host package. */ export const SSH_SETTINGS_NAMESPACE = 'dsh-ssh' as SettingsNamespace /** Plugin config, validated by the same-named schemastery schema. */ export interface Config { /** * When true (default), a system-prompt section announces the SSH plugin to * every agent (tools + host store). Set false to keep it silent. */ announceToAgent?: boolean /** Master switch for the plugin (routes, tools, prompt section). */ enabled?: boolean /** * xterm `fontFamily` for the web terminal (issue #577). Empty (default) * defers to the CSS chain: `--dsh-ssh-terminal-font`, then the official * `--ds-font-family-code` token, then the built-in monospace stack. Set a * Nerd Font stack here to render powerline/Nerd glyphs. */ terminalFontFamily?: string } export const Config: z = z.object({ announceToAgent: z.boolean().default(false), enabled: z.boolean().default(true), terminalFontFamily: z.string().default(''), }) /** Schema default, re-read for hand-built test contexts (the loader applies them normally). */ const DEFAULT_ANNOUNCE = false /** Order of the announcement section within the tool-guidance band. */ const SECTION_ORDER = 150 /** Model-facing announcement: plugin presence, capabilities, and limits. */ export const SSH_GUIDANCE = '本机已安装 dsh-ssh 插件(DSH 远程 SSH 运维):侧边栏「SSH」入口;在 dsh-web 插件全家桶仓库(packages/dsh-ssh)统一维护。能力:主机配置存 $DSH_HOME/dsh-ssh.json(默认 ~/.dsh)(可从 ~/.ssh/config 导入);持久连接池复用长连接(空闲 30 分钟自动断开);ssh_list 列出主机、ssh_exec 执行远程命令、ssh_upload/ssh_download 传输文件、ssh_tunnel 本地端口转发(访问远程数据库/内网服务)、ssh_cluster 按 aliases/environment/tags 至少一种非空 selector 筛选后集群并发执行;支持密钥/密码/ssh-agent 认证、passphrase 密钥、ProxyJump 跳板机(别名或 [user@]host[:port] 地址)与 OpenSSH 语义的 ProxyCommand(跳过堡垒机客户端场景,只能由用户在 GUI 配置,agent 不可写);Web 终端走 WebSocket。限制:主机操作由用户在 GUI 中配置后 agent 方可使用;密码以明文存在用户主目录私有文件(权限 0600);命令输出原样返回、可能含敏感信息;断线重连可能重放非幂等命令;传输/执行消耗真实远程资源,先确认再操作。路径区分:本机(dsh host)上的文件与命令一律用本地工具(read / write / edit / bash),ssh_* 工具只针对远程主机上的路径。用户提到「SSH / 远程服务器 / 服务器操作 / 跳板机 / 隧道 / 部署 / 上传下载」时即指本插件,请据此协作。' /** * Mount the SSH engine, routes, tools, and announcement. * @param ctx - host plugin context carrying webServer/tools/systemPrompt. * @param config - resolved plugin config (schema defaults applied by the loader). */ export const apply = mountOnce('@linxin666/dsh-ssh', applyImpl) function applyImpl(ctx: Context, config?: Config): void { // The live source the surfaces read: the settings section once the web // settings surface is served, the composition entry otherwise. let current: () => Config = () => config ?? {} let isSettingsBound = false const store = new HostStore() const engine = new SshEngine(store) ctx.effect(() => () => { engine.dispose() }, 'dsh-ssh: engine') const resolve = (): Config => { const value = current() let enabled = value.enabled ?? true // When dsh-ssh was seeded with enabled: false (e.g. from an aggregate profile line), // but the user has never explicitly configured settings (not bound yet) // AND already has active host records in dsh-ssh.json, keep the plugin enabled so // existing users are not broken upon upgrade (#1250). if (enabled === false && !isSettingsBound && store.list().length > 0) { enabled = true } return { announceToAgent: value.announceToAgent ?? DEFAULT_ANNOUNCE, enabled, } } // The /api/dsh-ssh route family + terminal upgrade. const { routes, upgrade } = makeRoutes({ store, engine }) let disposeRoutes: (() => void) | undefined // Agent tools + their prompt sections. const tools = [ sshListTool(engine), sshExecTool(engine), sshUploadTool(engine), sshDownloadTool(engine), sshTunnelTool(engine), sshClusterTool(engine), ] let disposeTools: (() => void) | undefined // System-prompt announcement. let disposeSection: (() => void) | undefined // Register (or drop) every surface to match the current source. Each group // is kept under one disposer: re-registering first tears the old one down // so duplicate-name registrations never throw. const sync = (): void => { const value = resolve() if (disposeSection !== undefined) { disposeSection() disposeSection = undefined } if (disposeRoutes !== undefined) { disposeRoutes() disposeRoutes = undefined } if (disposeTools !== undefined) { disposeTools() disposeTools = undefined } if (!value.enabled) return if (value.announceToAgent) { disposeSection = ctx.systemPrompt.section({ name: 'plugin:dsh-ssh', order: SECTION_ORDER, text: SSH_GUIDANCE, }) } disposeRoutes = ctx.effect( () => { const disposers = routes.map(route => ctx.webServer.register(route)) const upgradeDisposer = ctx.webServer.registerUpgrade(upgrade) return () => { for (const dispose of disposers) dispose() upgradeDisposer() } }, 'dsh-ssh: routes', ) disposeTools = ctx.effect( () => { const disposers = tools.map(tool => ctx.tools.register(tool)) return () => { for (const dispose of disposers) dispose() } }, 'dsh-ssh: tools', ) } ctx.inject(['settings'], (settingsCtx) => { try { if (typeof settingsCtx.settings?.installSection === 'function') { settingsCtx.settings.installSection(ctx, SSH_SETTINGS_NAMESPACE, Config, config ?? {}, { setSource: (source) => { isSettingsBound = true current = source sync() }, onChange: sync, }) } else if (typeof settingsCtx.settings?.register === 'function') { const scope = settingsCtx.settings.register(SSH_SETTINGS_NAMESPACE, Config, { base: config ?? {} }) isSettingsBound = true current = () => scope?.get?.() ?? (config ?? {}) scope?.watch?.(() => { sync() }) } } catch { // Defensive fallback against settings registration differences } }) // Initial registration from the composition entry (covers deployments with // no settings service, whose installSection never fires its hooks). sync() }