dsh-winrm 0.1.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/LICENSE +73 -0
- package/README.md +112 -0
- package/README.zh.md +119 -0
- package/RELEASING.md +75 -0
- package/cordis.patch.yml +8 -0
- package/lib/client.js +2076 -0
- package/lib/index.js +2458 -0
- package/lib/invariant.js +6 -0
- package/lib/types/client/api.d.ts +59 -0
- package/lib/types/client/index.d.ts +38 -0
- package/lib/types/client/locales.d.ts +231 -0
- package/lib/types/client/panel/ConsoleTab.d.ts +11 -0
- package/lib/types/client/panel/HostFormDialog.d.ts +12 -0
- package/lib/types/client/panel/HostsTab.d.ts +9 -0
- package/lib/types/client/panel/ProcessesTab.d.ts +7 -0
- package/lib/types/client/panel/ServicesTab.d.ts +7 -0
- package/lib/types/client/panel/TransferTab.d.ts +7 -0
- package/lib/types/client/panel/WinrmPanel.d.ts +12 -0
- package/lib/types/client/panel/WinrmPanelIcon.d.ts +8 -0
- package/lib/types/client/panel/helpers.d.ts +16 -0
- package/lib/types/engine/client.d.ts +49 -0
- package/lib/types/engine/console.d.ts +23 -0
- package/lib/types/engine.d.ts +58 -0
- package/lib/types/index.d.ts +44 -0
- package/lib/types/invariant.d.ts +3 -0
- package/lib/types/loopback.d.ts +24 -0
- package/lib/types/mount-once.d.ts +24 -0
- package/lib/types/powershell.d.ts +73 -0
- package/lib/types/protocol.d.ts +176 -0
- package/lib/types/routes.d.ts +26 -0
- package/lib/types/smb.d.ts +31 -0
- package/lib/types/store.d.ts +44 -0
- package/lib/types/tools.d.ts +20 -0
- package/package.json +111 -0
- package/scripts/enable-winrm.ps1 +0 -0
- package/scripts/postbuild.mjs +40 -0
- package/scripts/validate-manifest.mjs +18 -0
- package/src/client/api.ts +345 -0
- package/src/client/css-modules.d.ts +5 -0
- package/src/client/index.ts +82 -0
- package/src/client/locales.ts +243 -0
- package/src/client/panel/ConsoleTab.tsx +142 -0
- package/src/client/panel/HostFormDialog.tsx +142 -0
- package/src/client/panel/HostsTab.tsx +153 -0
- package/src/client/panel/ProcessesTab.tsx +119 -0
- package/src/client/panel/ServicesTab.tsx +123 -0
- package/src/client/panel/TransferTab.tsx +161 -0
- package/src/client/panel/WinrmPanel.tsx +85 -0
- package/src/client/panel/WinrmPanelIcon.tsx +27 -0
- package/src/client/panel/helpers.ts +33 -0
- package/src/client/panel/panel.module.css +1000 -0
- package/src/engine/client.ts +291 -0
- package/src/engine/console.ts +62 -0
- package/src/engine.ts +331 -0
- package/src/index.ts +142 -0
- package/src/invariant.ts +4 -0
- package/src/loopback.ts +63 -0
- package/src/mount-once.ts +48 -0
- package/src/powershell.ts +208 -0
- package/src/protocol.ts +178 -0
- package/src/routes.ts +490 -0
- package/src/smb.ts +129 -0
- package/src/store.ts +232 -0
- package/src/tools.ts +387 -0
package/src/store.ts
ADDED
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Host config store: one JSON file (~/.dsh/dsh-winrm.json) holding every
|
|
3
|
+
* Windows host entry, written atomically (tmp + rename). Secrets (passwords)
|
|
4
|
+
* live in this user-owned file in plaintext — the same trust model as
|
|
5
|
+
* dsh-ssh; document it, never log it.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, statSync, writeFileSync } from 'node:fs'
|
|
9
|
+
import { homedir } from 'node:os'
|
|
10
|
+
import { dirname, join, resolve } from 'node:path'
|
|
11
|
+
import type { WinHostEntry, WinHostPayload, WinHostSummary } from './protocol.ts'
|
|
12
|
+
|
|
13
|
+
/** File format version. */
|
|
14
|
+
const FORMAT_VERSION = 1
|
|
15
|
+
|
|
16
|
+
/** Store file location: <home>/.dsh/dsh-winrm.json. */
|
|
17
|
+
export function storePath(): string {
|
|
18
|
+
return join(homedir(), '.dsh', 'dsh-winrm.json')
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
interface StoreFile {
|
|
22
|
+
version: number
|
|
23
|
+
hosts: WinHostEntry[]
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Validate the wire shape of a host payload; returns a message or undefined. */
|
|
27
|
+
export function validateHostPayload(payload: unknown): string | undefined {
|
|
28
|
+
if (typeof payload !== 'object' || payload === null) return 'body must be a JSON object'
|
|
29
|
+
const p = payload as Record<string, unknown>
|
|
30
|
+
if (typeof p.host !== 'string' || p.host.trim() === '') return 'host is required'
|
|
31
|
+
if (typeof p.user !== 'string' || p.user.trim() === '') return 'user is required'
|
|
32
|
+
const auth = p.auth as Record<string, unknown> | undefined
|
|
33
|
+
if (auth !== undefined) {
|
|
34
|
+
if (typeof auth !== 'object' || auth === null) return 'auth must be an object'
|
|
35
|
+
if (auth.kind !== 'password') return 'auth.kind must be password'
|
|
36
|
+
if (auth.password !== undefined && typeof auth.password !== 'string') {
|
|
37
|
+
return 'auth.password must be a string when provided'
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
if (p.port !== undefined && (typeof p.port !== 'number' || !Number.isInteger(p.port) || p.port < 1 || p.port > 65535)) {
|
|
41
|
+
return 'port must be an integer in 1..65535'
|
|
42
|
+
}
|
|
43
|
+
if (p.transport !== undefined && p.transport !== 'http' && p.transport !== 'https') {
|
|
44
|
+
return 'transport must be http or https'
|
|
45
|
+
}
|
|
46
|
+
if (p.rejectUnauthorized !== undefined && typeof p.rejectUnauthorized !== 'boolean') {
|
|
47
|
+
return 'rejectUnauthorized must be a boolean'
|
|
48
|
+
}
|
|
49
|
+
if (p.tags !== undefined && (!Array.isArray(p.tags) || p.tags.some(x => typeof x !== 'string'))) {
|
|
50
|
+
return 'tags must be an array of strings'
|
|
51
|
+
}
|
|
52
|
+
return undefined
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Alias grammar: letters/digits plus dots, hyphens, underscores (IP/domain aliases included). */
|
|
56
|
+
const ALIAS_RE = /^[a-zA-Z0-9][a-zA-Z0-9._-]*$/
|
|
57
|
+
|
|
58
|
+
/** Validate an alias for creation. */
|
|
59
|
+
export function validateAlias(alias: string): string | undefined {
|
|
60
|
+
if (!ALIAS_RE.test(alias)) return 'alias must be letters, digits, dots, hyphens or underscores'
|
|
61
|
+
return undefined
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* The host store. Pure file I/O — no cordis dependency, unit-testable.
|
|
66
|
+
*/
|
|
67
|
+
export class HostStore {
|
|
68
|
+
/** The JSON file path. */
|
|
69
|
+
readonly path: string
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* @param path - store file path (defaults to the standard location).
|
|
73
|
+
*/
|
|
74
|
+
constructor(path?: string) {
|
|
75
|
+
this.path = resolve(path ?? storePath())
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Load all entries (empty store when the file is absent). */
|
|
79
|
+
list(): WinHostEntry[] {
|
|
80
|
+
return this.load().hosts
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Find one entry by alias. */
|
|
84
|
+
find(alias: string): WinHostEntry | undefined {
|
|
85
|
+
return this.list().find(entry => entry.alias === alias)
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Secret-free projection for the browser and agent surfaces. */
|
|
89
|
+
summarize(entry: WinHostEntry): WinHostSummary {
|
|
90
|
+
return {
|
|
91
|
+
alias: entry.alias,
|
|
92
|
+
host: entry.host,
|
|
93
|
+
port: entry.port,
|
|
94
|
+
user: entry.user,
|
|
95
|
+
auth: entry.auth.kind,
|
|
96
|
+
transport: entry.transport,
|
|
97
|
+
...(entry.rejectUnauthorized !== undefined ? { rejectUnauthorized: entry.rejectUnauthorized } : {}),
|
|
98
|
+
...(entry.description !== undefined ? { description: entry.description } : {}),
|
|
99
|
+
...(entry.environment !== undefined ? { environment: entry.environment } : {}),
|
|
100
|
+
tags: [...entry.tags],
|
|
101
|
+
...(entry.location !== undefined ? { location: entry.location } : {}),
|
|
102
|
+
createdAt: entry.createdAt,
|
|
103
|
+
updatedAt: entry.updatedAt,
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Create one entry. Throws on alias collision or invalid payload. */
|
|
108
|
+
create(payload: WinHostPayload): WinHostEntry {
|
|
109
|
+
const alias = payload.alias?.trim()
|
|
110
|
+
if (!alias) throw new Error('alias is required')
|
|
111
|
+
const aliasError = validateAlias(alias)
|
|
112
|
+
if (aliasError !== undefined) throw new Error(aliasError)
|
|
113
|
+
const bodyError = validateHostPayload(payload)
|
|
114
|
+
if (bodyError !== undefined) throw new Error(bodyError)
|
|
115
|
+
if (payload.auth === undefined) throw new Error('auth is required')
|
|
116
|
+
const file = this.load()
|
|
117
|
+
if (file.hosts.some(entry => entry.alias === alias)) throw new Error(`alias '${alias}' already exists`)
|
|
118
|
+
const now = Date.now()
|
|
119
|
+
const entry: WinHostEntry = {
|
|
120
|
+
alias,
|
|
121
|
+
host: payload.host.trim(),
|
|
122
|
+
port: payload.port ?? 5985,
|
|
123
|
+
user: payload.user.trim(),
|
|
124
|
+
auth: { kind: 'password', password: payload.auth.password },
|
|
125
|
+
transport: payload.transport ?? 'http',
|
|
126
|
+
...(payload.rejectUnauthorized !== undefined ? { rejectUnauthorized: payload.rejectUnauthorized } : {}),
|
|
127
|
+
description: payload.description?.trim() || undefined,
|
|
128
|
+
environment: payload.environment?.trim() || undefined,
|
|
129
|
+
tags: [...(payload.tags ?? [])].map(tag => tag.trim()).filter(tag => tag !== ''),
|
|
130
|
+
location: payload.location?.trim() || undefined,
|
|
131
|
+
createdAt: now,
|
|
132
|
+
updatedAt: now,
|
|
133
|
+
}
|
|
134
|
+
file.hosts.push(entry)
|
|
135
|
+
this.save(file)
|
|
136
|
+
return entry
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** Update the fields present in `patch`; unknown aliases throw. */
|
|
140
|
+
update(alias: string, patch: Partial<WinHostPayload>): WinHostEntry {
|
|
141
|
+
const file = this.load()
|
|
142
|
+
const entry = file.hosts.find(candidate => candidate.alias === alias)
|
|
143
|
+
if (entry === undefined) throw new Error(`alias '${alias}' not found`)
|
|
144
|
+
if (patch.host !== undefined && (typeof patch.host !== 'string' || patch.host.trim() === '')) {
|
|
145
|
+
throw new Error('host is required')
|
|
146
|
+
}
|
|
147
|
+
if (patch.user !== undefined && (typeof patch.user !== 'string' || patch.user.trim() === '')) {
|
|
148
|
+
throw new Error('user is required')
|
|
149
|
+
}
|
|
150
|
+
if (patch.port !== undefined && (typeof patch.port !== 'number' || !Number.isInteger(patch.port) || patch.port < 1 || patch.port > 65535)) {
|
|
151
|
+
throw new Error('port must be an integer in 1..65535')
|
|
152
|
+
}
|
|
153
|
+
if (patch.transport !== undefined && patch.transport !== 'http' && patch.transport !== 'https') {
|
|
154
|
+
throw new Error('transport must be http or https')
|
|
155
|
+
}
|
|
156
|
+
if (patch.tags !== undefined && (!Array.isArray(patch.tags) || patch.tags.some(x => typeof x !== 'string'))) {
|
|
157
|
+
throw new Error('tags must be an array of strings')
|
|
158
|
+
}
|
|
159
|
+
if (patch.host !== undefined) entry.host = patch.host.trim()
|
|
160
|
+
if (patch.port !== undefined) entry.port = patch.port
|
|
161
|
+
if (patch.user !== undefined) entry.user = patch.user.trim()
|
|
162
|
+
if (patch.auth !== undefined) {
|
|
163
|
+
const auth = patch.auth
|
|
164
|
+
if (auth.kind !== 'password') throw new Error('auth.kind must be password')
|
|
165
|
+
entry.auth = { kind: 'password', password: auth.password }
|
|
166
|
+
}
|
|
167
|
+
if (patch.transport !== undefined) entry.transport = patch.transport
|
|
168
|
+
if (patch.rejectUnauthorized !== undefined) entry.rejectUnauthorized = patch.rejectUnauthorized
|
|
169
|
+
if (patch.description !== undefined) entry.description = patch.description.trim() || undefined
|
|
170
|
+
if (patch.environment !== undefined) entry.environment = patch.environment.trim() || undefined
|
|
171
|
+
if (patch.tags !== undefined) entry.tags = [...patch.tags].map(tag => tag.trim()).filter(tag => tag !== '')
|
|
172
|
+
if (patch.location !== undefined) entry.location = patch.location.trim() || undefined
|
|
173
|
+
entry.updatedAt = Date.now()
|
|
174
|
+
this.save(file)
|
|
175
|
+
return entry
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** Remove one entry. */
|
|
179
|
+
delete(alias: string): void {
|
|
180
|
+
const file = this.load()
|
|
181
|
+
const index = file.hosts.findIndex(candidate => candidate.alias === alias)
|
|
182
|
+
if (index < 0) throw new Error(`alias '${alias}' not found`)
|
|
183
|
+
file.hosts.splice(index, 1)
|
|
184
|
+
this.save(file)
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Last parsed store keyed by file identity. list/find ride every acquire
|
|
189
|
+
* and GUI refresh; re-reading and re-parsing the whole file each call is
|
|
190
|
+
* wasted work when the file has not changed. Any save invalidates.
|
|
191
|
+
*/
|
|
192
|
+
private cache: { mtimeMs: number; size: number; file: StoreFile } | undefined
|
|
193
|
+
|
|
194
|
+
private load(): StoreFile {
|
|
195
|
+
let stats: { mtimeMs: number; size: number }
|
|
196
|
+
try {
|
|
197
|
+
stats = statSync(this.path)
|
|
198
|
+
} catch {
|
|
199
|
+
this.cache = undefined
|
|
200
|
+
return { version: FORMAT_VERSION, hosts: [] }
|
|
201
|
+
}
|
|
202
|
+
if (this.cache !== undefined && this.cache.mtimeMs === stats.mtimeMs && this.cache.size === stats.size) {
|
|
203
|
+
return this.cache.file
|
|
204
|
+
}
|
|
205
|
+
try {
|
|
206
|
+
const parsed = JSON.parse(readFileSync(this.path, 'utf8')) as StoreFile
|
|
207
|
+
if (typeof parsed !== 'object' || parsed === null || !Array.isArray(parsed.hosts)) {
|
|
208
|
+
throw new Error('store file shape invalid')
|
|
209
|
+
}
|
|
210
|
+
this.cache = { mtimeMs: stats.mtimeMs, size: stats.size, file: parsed }
|
|
211
|
+
return parsed
|
|
212
|
+
} catch {
|
|
213
|
+
// A corrupt store must not brick the plugin — rename it aside for
|
|
214
|
+
// manual recovery (the plugin then starts from an empty list).
|
|
215
|
+
this.cache = undefined
|
|
216
|
+
try {
|
|
217
|
+
renameSync(this.path, `${this.path}.corrupt-${Date.now()}`)
|
|
218
|
+
} catch { /* best effort */ }
|
|
219
|
+
return { version: FORMAT_VERSION, hosts: [] }
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
private save(file: StoreFile): void {
|
|
224
|
+
const dir = dirname(this.path)
|
|
225
|
+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true, mode: 0o700 })
|
|
226
|
+
const tmp = this.path + '.tmp'
|
|
227
|
+
// Secrets live in this file: keep it readable by the owner only.
|
|
228
|
+
writeFileSync(tmp, JSON.stringify(file, null, 2) + '\n', { encoding: 'utf8', mode: 0o600 })
|
|
229
|
+
renameSync(tmp, this.path)
|
|
230
|
+
this.cache = undefined
|
|
231
|
+
}
|
|
232
|
+
}
|
package/src/tools.ts
ADDED
|
@@ -0,0 +1,387 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Agent tools: the DSH-native counterpart of the GUI — every tool talks to
|
|
3
|
+
* the same engine the web UI uses, so a host configured in the GUI is
|
|
4
|
+
* immediately operable by any agent, and vice versa.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { defineTool } from '@deepseek-ai/dsh-tools'
|
|
8
|
+
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
|
|
9
|
+
import type { WinRmEngine } from './engine.ts'
|
|
10
|
+
import type { ClusterResult, ExecResult, WinHostSummary } from './protocol.ts'
|
|
11
|
+
|
|
12
|
+
/** One text content block (the only render shape these tools emit). */
|
|
13
|
+
function text(value: string): ContentBlock[] {
|
|
14
|
+
return [{ type: 'text', text: value }]
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Host table render shared by list surfaces. */
|
|
18
|
+
function renderHosts(hosts: WinHostSummary[]): string {
|
|
19
|
+
if (hosts.length === 0) return 'no hosts configured'
|
|
20
|
+
const rows = hosts.map(host => [
|
|
21
|
+
host.alias,
|
|
22
|
+
host.host,
|
|
23
|
+
String(host.port),
|
|
24
|
+
host.user,
|
|
25
|
+
host.transport,
|
|
26
|
+
host.environment ?? '-',
|
|
27
|
+
(host.tags.length > 0 ? host.tags.join(',') : '-'),
|
|
28
|
+
host.description ?? '',
|
|
29
|
+
].join(' | '))
|
|
30
|
+
return ['alias | host | port | user | transport | environment | tags | description', '--- | --- | --- | --- | --- | --- | --- | ---', ...rows].join('\n')
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Render one exec result (mirrors the bash-tool exit-code convention). */
|
|
34
|
+
function renderExec(result: ExecResult): string {
|
|
35
|
+
const marker = result.timedOut
|
|
36
|
+
? '[timed out]'
|
|
37
|
+
: `[exit code: ${result.exitCode ?? 'null'}]`
|
|
38
|
+
const parts = [marker]
|
|
39
|
+
if (result.stdout !== '') parts.push('stdout:\n' + result.stdout)
|
|
40
|
+
if (result.stderr !== '') parts.push('stderr:\n' + result.stderr)
|
|
41
|
+
if (result.error !== undefined) parts.push('error: ' + result.error)
|
|
42
|
+
parts.push(`duration: ${result.durationMs} ms`)
|
|
43
|
+
return parts.join('\n')
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Render cluster outcomes compactly. */
|
|
47
|
+
function renderCluster(results: ClusterResult[]): string {
|
|
48
|
+
if (results.length === 0) return 'no hosts matched'
|
|
49
|
+
return results.map(result => {
|
|
50
|
+
const status = result.ok ? 'ok' : result.timedOut === true ? 'timed out' : 'failed'
|
|
51
|
+
const tail = result.error !== undefined ? ' (' + result.error + ')' : ''
|
|
52
|
+
return `${result.alias}: ${status} [exit code: ${result.exitCode ?? 'null'}]${tail}`
|
|
53
|
+
}).join('\n')
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** The host-list tool. */
|
|
57
|
+
export function winrmListTool(engine: WinRmEngine) {
|
|
58
|
+
return defineTool({
|
|
59
|
+
name: 'winrm_list',
|
|
60
|
+
description: 'List configured Windows hosts (alias, host, port, user, transport, environment, tags, description). Use winrm_exec etc. with the alias. ' +
|
|
61
|
+
'Triggers: Windows server, WinRM, PowerShell remote, check server/status, deploy to Windows.',
|
|
62
|
+
parameters: {
|
|
63
|
+
query: { type: 'string', description: 'Optional fuzzy match against alias, description, host, and tags.' },
|
|
64
|
+
},
|
|
65
|
+
output: {
|
|
66
|
+
schema: {
|
|
67
|
+
type: 'object',
|
|
68
|
+
additionalProperties: false,
|
|
69
|
+
properties: {
|
|
70
|
+
hosts: {
|
|
71
|
+
type: 'array',
|
|
72
|
+
required: true,
|
|
73
|
+
items: {
|
|
74
|
+
type: 'object',
|
|
75
|
+
additionalProperties: false,
|
|
76
|
+
properties: {
|
|
77
|
+
alias: { type: 'string', required: true },
|
|
78
|
+
host: { type: 'string', required: true },
|
|
79
|
+
port: { type: 'integer', required: true },
|
|
80
|
+
user: { type: 'string', required: true },
|
|
81
|
+
auth: { type: 'string', enum: ['password'], required: true },
|
|
82
|
+
transport: { type: 'string', enum: ['http', 'https'], required: true },
|
|
83
|
+
description: { type: 'string' },
|
|
84
|
+
environment: { type: 'string' },
|
|
85
|
+
tags: { type: 'array', items: { type: 'string' }, required: true },
|
|
86
|
+
location: { type: 'string' },
|
|
87
|
+
createdAt: { type: 'integer', required: true },
|
|
88
|
+
updatedAt: { type: 'integer', required: true },
|
|
89
|
+
},
|
|
90
|
+
},
|
|
91
|
+
},
|
|
92
|
+
},
|
|
93
|
+
},
|
|
94
|
+
render: (_args, value: { hosts?: WinHostSummary[] }) => text(renderHosts(value.hosts ?? [])),
|
|
95
|
+
},
|
|
96
|
+
async execute(args) {
|
|
97
|
+
return { hosts: engine.list(args.query) }
|
|
98
|
+
},
|
|
99
|
+
})
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** The command-execution tool (PowerShell). */
|
|
103
|
+
export function winrmExecTool(engine: WinRmEngine) {
|
|
104
|
+
return defineTool({
|
|
105
|
+
name: 'winrm_exec',
|
|
106
|
+
description: 'Execute a PowerShell command on a configured Windows host by alias. The command runs under PowerShell with UTF-8 output; prefer combining independent read-only queries into one command. ' +
|
|
107
|
+
'Triggers: run command on Windows server, PowerShell remote, check server/status, service control, view logs, any remote Windows operation.',
|
|
108
|
+
parameters: {
|
|
109
|
+
alias: { type: 'string', required: true, description: 'Host alias from winrm_list.' },
|
|
110
|
+
command: { type: 'string', required: true, description: 'The PowerShell command to run remotely (script body, no powershell.exe wrapper needed).' },
|
|
111
|
+
timeoutMs: { type: 'integer', description: 'Timeout in milliseconds (default 60000).' },
|
|
112
|
+
},
|
|
113
|
+
output: {
|
|
114
|
+
schema: {
|
|
115
|
+
type: 'object',
|
|
116
|
+
additionalProperties: false,
|
|
117
|
+
properties: {
|
|
118
|
+
success: { type: 'boolean', required: true },
|
|
119
|
+
exitCode: { oneOf: [{ type: 'integer' }, { type: 'null' }], required: true },
|
|
120
|
+
timedOut: { type: 'boolean', required: true },
|
|
121
|
+
stdout: { type: 'string', required: true },
|
|
122
|
+
stderr: { type: 'string', required: true },
|
|
123
|
+
durationMs: { type: 'integer', required: true },
|
|
124
|
+
error: { type: 'string' },
|
|
125
|
+
},
|
|
126
|
+
},
|
|
127
|
+
render: (_args, value: ExecResult) => text(renderExec(value)),
|
|
128
|
+
},
|
|
129
|
+
async execute(args) {
|
|
130
|
+
return engine.exec(args.alias, args.command, args.timeoutMs)
|
|
131
|
+
},
|
|
132
|
+
})
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** The service-management tool. */
|
|
136
|
+
export function winrmServiceTool(engine: WinRmEngine) {
|
|
137
|
+
return defineTool({
|
|
138
|
+
name: 'winrm_service',
|
|
139
|
+
description: 'Manage Windows services on a configured host: list all services, or start/stop/restart one and change its startup type. ' +
|
|
140
|
+
'Triggers: service control, start/stop/restart Windows service, check service status, set service startup type.',
|
|
141
|
+
parameters: {
|
|
142
|
+
alias: { type: 'string', required: true, description: 'Host alias from winrm_list.' },
|
|
143
|
+
name: { type: 'string', description: 'Service name (required for actions; omitted lists all services).' },
|
|
144
|
+
action: { type: 'string', enum: ['list', 'start', 'stop', 'restart', 'set-auto', 'set-manual', 'set-disabled'], description: 'list (default) / start / stop / restart / set-auto / set-manual / set-disabled.' },
|
|
145
|
+
},
|
|
146
|
+
output: {
|
|
147
|
+
schema: {
|
|
148
|
+
type: 'object',
|
|
149
|
+
additionalProperties: false,
|
|
150
|
+
properties: {
|
|
151
|
+
services: {
|
|
152
|
+
type: 'array',
|
|
153
|
+
items: {
|
|
154
|
+
type: 'object',
|
|
155
|
+
additionalProperties: false,
|
|
156
|
+
properties: {
|
|
157
|
+
name: { type: 'string', required: true },
|
|
158
|
+
displayName: { type: 'string', required: true },
|
|
159
|
+
status: { type: 'string', required: true },
|
|
160
|
+
startMode: { type: 'string', required: true },
|
|
161
|
+
startName: { type: 'string' },
|
|
162
|
+
},
|
|
163
|
+
},
|
|
164
|
+
},
|
|
165
|
+
service: {
|
|
166
|
+
type: 'object',
|
|
167
|
+
additionalProperties: false,
|
|
168
|
+
properties: {
|
|
169
|
+
name: { type: 'string', required: true },
|
|
170
|
+
displayName: { type: 'string', required: true },
|
|
171
|
+
status: { type: 'string', required: true },
|
|
172
|
+
startMode: { type: 'string', required: true },
|
|
173
|
+
startName: { type: 'string' },
|
|
174
|
+
},
|
|
175
|
+
},
|
|
176
|
+
error: { type: 'string' },
|
|
177
|
+
},
|
|
178
|
+
},
|
|
179
|
+
render: (_args, value: { services?: Array<{ name: string; displayName: string; status: string; startMode: string }>; service?: { name: string; displayName: string; status: string; startMode: string }; error?: string }) => {
|
|
180
|
+
if (value.error !== undefined) return text('service error: ' + value.error)
|
|
181
|
+
if (value.services !== undefined) {
|
|
182
|
+
if (value.services.length === 0) return text('no services')
|
|
183
|
+
return text(['name | displayName | status | startMode', '--- | --- | --- | ---', ...value.services.map(s => `${s.name} | ${s.displayName} | ${s.status} | ${s.startMode}`)].join('\n'))
|
|
184
|
+
}
|
|
185
|
+
if (value.service !== undefined) {
|
|
186
|
+
const s = value.service
|
|
187
|
+
return text(`service ${s.name}: ${s.status} (startMode ${s.startMode}, displayName ${s.displayName})`)
|
|
188
|
+
}
|
|
189
|
+
return text('no result')
|
|
190
|
+
},
|
|
191
|
+
},
|
|
192
|
+
async execute(args) {
|
|
193
|
+
try {
|
|
194
|
+
if (args.name === undefined || args.action === undefined || args.action === 'list') {
|
|
195
|
+
return { services: await engine.listServices(args.alias) }
|
|
196
|
+
}
|
|
197
|
+
const service = await engine.serviceAction(args.alias, args.name, args.action)
|
|
198
|
+
return { service }
|
|
199
|
+
} catch (error) {
|
|
200
|
+
return { error: error instanceof Error ? error.message : String(error) }
|
|
201
|
+
}
|
|
202
|
+
},
|
|
203
|
+
})
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/** The process-management tool. */
|
|
207
|
+
export function winrmProcessTool(engine: WinRmEngine) {
|
|
208
|
+
return defineTool({
|
|
209
|
+
name: 'winrm_process',
|
|
210
|
+
description: 'Manage processes on a configured Windows host: list all processes, or kill one by id. ' +
|
|
211
|
+
'Triggers: process list, kill process, check what is running on a Windows server.',
|
|
212
|
+
parameters: {
|
|
213
|
+
alias: { type: 'string', required: true, description: 'Host alias from winrm_list.' },
|
|
214
|
+
id: { type: 'integer', description: 'Process id (required for kill; omitted lists processes).' },
|
|
215
|
+
action: { type: 'string', enum: ['list', 'kill'], description: 'list (default) / kill.' },
|
|
216
|
+
},
|
|
217
|
+
output: {
|
|
218
|
+
schema: {
|
|
219
|
+
type: 'object',
|
|
220
|
+
additionalProperties: false,
|
|
221
|
+
properties: {
|
|
222
|
+
processes: {
|
|
223
|
+
type: 'array',
|
|
224
|
+
items: {
|
|
225
|
+
type: 'object',
|
|
226
|
+
additionalProperties: false,
|
|
227
|
+
properties: {
|
|
228
|
+
id: { type: 'integer', required: true },
|
|
229
|
+
name: { type: 'string', required: true },
|
|
230
|
+
cpu: { type: 'number' },
|
|
231
|
+
memMB: { type: 'number' },
|
|
232
|
+
startTime: { type: 'string' },
|
|
233
|
+
path: { type: 'string' },
|
|
234
|
+
},
|
|
235
|
+
},
|
|
236
|
+
},
|
|
237
|
+
killed: { type: 'integer' },
|
|
238
|
+
error: { type: 'string' },
|
|
239
|
+
},
|
|
240
|
+
},
|
|
241
|
+
render: (_args, value: { processes?: Array<{ id: number; name: string; cpu?: number; memMB?: number; startTime?: string }>; killed?: number; error?: string }) => {
|
|
242
|
+
if (value.error !== undefined) return text('process error: ' + value.error)
|
|
243
|
+
if (value.processes !== undefined) {
|
|
244
|
+
if (value.processes.length === 0) return text('no processes')
|
|
245
|
+
return text(['id | name | cpu | memMB | startTime', '--- | --- | --- | --- | ---', ...value.processes.map(p => `${p.id} | ${p.name} | ${p.cpu ?? ''} | ${p.memMB ?? ''} | ${p.startTime ?? ''}`)].join('\n'))
|
|
246
|
+
}
|
|
247
|
+
return text(`killed process ${value.killed ?? 0}`)
|
|
248
|
+
},
|
|
249
|
+
},
|
|
250
|
+
async execute(args) {
|
|
251
|
+
try {
|
|
252
|
+
if (args.action === 'kill' && args.id !== undefined) {
|
|
253
|
+
await engine.killProcess(args.alias, args.id)
|
|
254
|
+
return { killed: args.id }
|
|
255
|
+
}
|
|
256
|
+
return { processes: await engine.listProcesses(args.alias) }
|
|
257
|
+
} catch (error) {
|
|
258
|
+
return { error: error instanceof Error ? error.message : String(error) }
|
|
259
|
+
}
|
|
260
|
+
},
|
|
261
|
+
})
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/** The upload tool. */
|
|
265
|
+
export function winrmUploadTool(engine: WinRmEngine) {
|
|
266
|
+
return defineTool({
|
|
267
|
+
name: 'winrm_upload',
|
|
268
|
+
description: 'Upload a local file to a configured Windows host (SMB admin share preferred, base64 WinRM chunk fallback). The local path is on THIS machine (the dsh host). ' +
|
|
269
|
+
'Triggers: upload file to Windows server, deploy artifact, copy config to Windows server.',
|
|
270
|
+
parameters: {
|
|
271
|
+
alias: { type: 'string', required: true, description: 'Host alias from winrm_list.' },
|
|
272
|
+
localPath: { type: 'string', required: true, description: 'Absolute local file path on this machine.' },
|
|
273
|
+
remotePath: { type: 'string', required: true, description: 'Destination path on the remote host, e.g. C:\\temp\\file.txt (parent dirs are created).' },
|
|
274
|
+
channel: { type: 'string', enum: ['auto', 'smb', 'winrm'], description: 'Transfer channel: smb (admin share), winrm (base64 chunks), or auto (default, SMB first with WinRM fallback).' },
|
|
275
|
+
},
|
|
276
|
+
output: {
|
|
277
|
+
schema: {
|
|
278
|
+
type: 'object',
|
|
279
|
+
additionalProperties: false,
|
|
280
|
+
properties: {
|
|
281
|
+
ok: { type: 'boolean', required: true },
|
|
282
|
+
transferredBytes: { type: 'integer' },
|
|
283
|
+
channel: { type: 'string', enum: ['smb', 'winrm'] },
|
|
284
|
+
error: { type: 'string' },
|
|
285
|
+
},
|
|
286
|
+
},
|
|
287
|
+
render: (_args, value: { ok: boolean; transferredBytes?: number; channel?: string; error?: string }) => text(value.ok
|
|
288
|
+
? `uploaded ${value.transferredBytes ?? 0} bytes via ${value.channel ?? 'unknown'}`
|
|
289
|
+
: `upload failed: ${value.error ?? 'unknown error'}`),
|
|
290
|
+
},
|
|
291
|
+
async execute(args) {
|
|
292
|
+
try {
|
|
293
|
+
const outcome = await engine.upload(args.alias, args.localPath, args.remotePath, undefined, args.channel ?? 'auto')
|
|
294
|
+
const usedChannel: 'smb' | 'winrm' = outcome.channel === 'smb' ? 'smb' : 'winrm'
|
|
295
|
+
return { ok: true, transferredBytes: outcome.bytes, channel: usedChannel }
|
|
296
|
+
} catch (error) {
|
|
297
|
+
return { ok: false, error: error instanceof Error ? error.message : String(error) }
|
|
298
|
+
}
|
|
299
|
+
},
|
|
300
|
+
})
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/** The download tool. */
|
|
304
|
+
export function winrmDownloadTool(engine: WinRmEngine) {
|
|
305
|
+
return defineTool({
|
|
306
|
+
name: 'winrm_download',
|
|
307
|
+
description: 'Download a remote FILE from a configured Windows host to a local path on this machine (SMB admin share preferred, base64 WinRM chunk fallback). Directory download is not supported — download files individually. ' +
|
|
308
|
+
'Triggers: download file from Windows server, fetch remote log/artifact.',
|
|
309
|
+
parameters: {
|
|
310
|
+
alias: { type: 'string', required: true, description: 'Host alias from winrm_list.' },
|
|
311
|
+
remotePath: { type: 'string', required: true, description: 'Remote file path, e.g. C:\\temp\\file.txt.' },
|
|
312
|
+
localPath: { type: 'string', required: true, description: 'Absolute destination path on this machine.' },
|
|
313
|
+
channel: { type: 'string', enum: ['auto', 'smb', 'winrm'], description: 'Transfer channel: smb (admin share), winrm (base64 chunks), or auto (default, SMB first with WinRM fallback).' },
|
|
314
|
+
},
|
|
315
|
+
output: {
|
|
316
|
+
schema: {
|
|
317
|
+
type: 'object',
|
|
318
|
+
additionalProperties: false,
|
|
319
|
+
properties: {
|
|
320
|
+
ok: { type: 'boolean', required: true },
|
|
321
|
+
bytes: { type: 'integer' },
|
|
322
|
+
channel: { type: 'string', enum: ['smb', 'winrm'] },
|
|
323
|
+
error: { type: 'string' },
|
|
324
|
+
},
|
|
325
|
+
},
|
|
326
|
+
render: (_args, value: { ok: boolean; bytes?: number; channel?: string; error?: string }) => text(value.ok
|
|
327
|
+
? `downloaded ${value.bytes ?? 0} bytes via ${value.channel ?? 'unknown'}`
|
|
328
|
+
: `download failed: ${value.error ?? 'unknown error'}`),
|
|
329
|
+
},
|
|
330
|
+
async execute(args) {
|
|
331
|
+
try {
|
|
332
|
+
const outcome = await engine.download(args.alias, args.remotePath, args.localPath, undefined, args.channel ?? 'auto')
|
|
333
|
+
const usedChannel: 'smb' | 'winrm' = outcome.channel === 'smb' ? 'smb' : 'winrm'
|
|
334
|
+
return { ok: true, bytes: outcome.bytes, channel: usedChannel }
|
|
335
|
+
} catch (error) {
|
|
336
|
+
return { ok: false, error: error instanceof Error ? error.message : String(error) }
|
|
337
|
+
}
|
|
338
|
+
},
|
|
339
|
+
})
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/** The cluster tool. */
|
|
343
|
+
export function winrmClusterTool(engine: WinRmEngine) {
|
|
344
|
+
return defineTool({
|
|
345
|
+
name: 'winrm_cluster',
|
|
346
|
+
description: 'Run one PowerShell command concurrently across many Windows hosts (all hosts, or filtered by aliases / environment / tags). ' +
|
|
347
|
+
'Triggers: run on all Windows servers, batch operation, production servers, cluster command.',
|
|
348
|
+
parameters: {
|
|
349
|
+
command: { type: 'string', required: true, description: 'The PowerShell command to run on every matched host.' },
|
|
350
|
+
aliases: { type: 'array', items: { type: 'string' }, description: 'Explicit alias list; when absent every configured host matches.' },
|
|
351
|
+
environment: { type: 'string', description: 'Only hosts with this environment label.' },
|
|
352
|
+
tags: { type: 'array', items: { type: 'string' }, description: 'Only hosts carrying ALL these tags.' },
|
|
353
|
+
timeoutMs: { type: 'integer', description: 'Per-host timeout in milliseconds.' },
|
|
354
|
+
maxWorkers: { type: 'integer', description: 'Concurrency cap (default 8).' },
|
|
355
|
+
},
|
|
356
|
+
output: {
|
|
357
|
+
schema: {
|
|
358
|
+
type: 'object',
|
|
359
|
+
additionalProperties: false,
|
|
360
|
+
properties: {
|
|
361
|
+
results: {
|
|
362
|
+
type: 'array',
|
|
363
|
+
required: true,
|
|
364
|
+
items: {
|
|
365
|
+
type: 'object',
|
|
366
|
+
additionalProperties: false,
|
|
367
|
+
properties: {
|
|
368
|
+
alias: { type: 'string', required: true },
|
|
369
|
+
ok: { type: 'boolean', required: true },
|
|
370
|
+
exitCode: { oneOf: [{ type: 'integer' }, { type: 'null' }] },
|
|
371
|
+
timedOut: { type: 'boolean' },
|
|
372
|
+
stdout: { type: 'string' },
|
|
373
|
+
stderr: { type: 'string' },
|
|
374
|
+
durationMs: { type: 'integer' },
|
|
375
|
+
error: { type: 'string' },
|
|
376
|
+
},
|
|
377
|
+
},
|
|
378
|
+
},
|
|
379
|
+
},
|
|
380
|
+
},
|
|
381
|
+
render: (_args, value: { results?: ClusterResult[] }) => text(renderCluster(value.results ?? [])),
|
|
382
|
+
},
|
|
383
|
+
async execute(args) {
|
|
384
|
+
return { results: await engine.cluster(args) }
|
|
385
|
+
},
|
|
386
|
+
})
|
|
387
|
+
}
|