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.
Files changed (64) hide show
  1. package/LICENSE +73 -0
  2. package/README.md +112 -0
  3. package/README.zh.md +119 -0
  4. package/RELEASING.md +75 -0
  5. package/cordis.patch.yml +8 -0
  6. package/lib/client.js +2076 -0
  7. package/lib/index.js +2458 -0
  8. package/lib/invariant.js +6 -0
  9. package/lib/types/client/api.d.ts +59 -0
  10. package/lib/types/client/index.d.ts +38 -0
  11. package/lib/types/client/locales.d.ts +231 -0
  12. package/lib/types/client/panel/ConsoleTab.d.ts +11 -0
  13. package/lib/types/client/panel/HostFormDialog.d.ts +12 -0
  14. package/lib/types/client/panel/HostsTab.d.ts +9 -0
  15. package/lib/types/client/panel/ProcessesTab.d.ts +7 -0
  16. package/lib/types/client/panel/ServicesTab.d.ts +7 -0
  17. package/lib/types/client/panel/TransferTab.d.ts +7 -0
  18. package/lib/types/client/panel/WinrmPanel.d.ts +12 -0
  19. package/lib/types/client/panel/WinrmPanelIcon.d.ts +8 -0
  20. package/lib/types/client/panel/helpers.d.ts +16 -0
  21. package/lib/types/engine/client.d.ts +49 -0
  22. package/lib/types/engine/console.d.ts +23 -0
  23. package/lib/types/engine.d.ts +58 -0
  24. package/lib/types/index.d.ts +44 -0
  25. package/lib/types/invariant.d.ts +3 -0
  26. package/lib/types/loopback.d.ts +24 -0
  27. package/lib/types/mount-once.d.ts +24 -0
  28. package/lib/types/powershell.d.ts +73 -0
  29. package/lib/types/protocol.d.ts +176 -0
  30. package/lib/types/routes.d.ts +26 -0
  31. package/lib/types/smb.d.ts +31 -0
  32. package/lib/types/store.d.ts +44 -0
  33. package/lib/types/tools.d.ts +20 -0
  34. package/package.json +111 -0
  35. package/scripts/enable-winrm.ps1 +0 -0
  36. package/scripts/postbuild.mjs +40 -0
  37. package/scripts/validate-manifest.mjs +18 -0
  38. package/src/client/api.ts +345 -0
  39. package/src/client/css-modules.d.ts +5 -0
  40. package/src/client/index.ts +82 -0
  41. package/src/client/locales.ts +243 -0
  42. package/src/client/panel/ConsoleTab.tsx +142 -0
  43. package/src/client/panel/HostFormDialog.tsx +142 -0
  44. package/src/client/panel/HostsTab.tsx +153 -0
  45. package/src/client/panel/ProcessesTab.tsx +119 -0
  46. package/src/client/panel/ServicesTab.tsx +123 -0
  47. package/src/client/panel/TransferTab.tsx +161 -0
  48. package/src/client/panel/WinrmPanel.tsx +85 -0
  49. package/src/client/panel/WinrmPanelIcon.tsx +27 -0
  50. package/src/client/panel/helpers.ts +33 -0
  51. package/src/client/panel/panel.module.css +1000 -0
  52. package/src/engine/client.ts +291 -0
  53. package/src/engine/console.ts +62 -0
  54. package/src/engine.ts +331 -0
  55. package/src/index.ts +142 -0
  56. package/src/invariant.ts +4 -0
  57. package/src/loopback.ts +63 -0
  58. package/src/mount-once.ts +48 -0
  59. package/src/powershell.ts +208 -0
  60. package/src/protocol.ts +178 -0
  61. package/src/routes.ts +490 -0
  62. package/src/smb.ts +129 -0
  63. package/src/store.ts +232 -0
  64. package/src/tools.ts +387 -0
package/src/index.ts ADDED
@@ -0,0 +1,142 @@
1
+ /**
2
+ * dsh-winrm — host half. Mounts the WinRM engine (per-call PowerShell
3
+ * execution with a UTF-8 envelope, streaming console, service/process
4
+ * management, base64-chunked file transfer, cluster), the /api/dsh-winrm
5
+ * route family plus the console WebSocket upgrade, the agent tools
6
+ * (winrm_list, winrm_exec, winrm_service, winrm_process, winrm_upload,
7
+ * winrm_download, winrm_cluster), and a system-prompt announcement. The
8
+ * browser half (./client) renders the host manager and operations panel.
9
+ * Everything rides official NPM SDK packages — no dsh source changes.
10
+ *
11
+ * Config is read live through `Volatile` references (`.get()`), so the
12
+ * Settings page can flip `enabled` / `announceToAgent` without remounting;
13
+ * `loader/volatile-update` re-runs the registration sync when they change.
14
+ */
15
+
16
+ import type { Context, Volatile } from '@deepseek-ai/cordis'
17
+ import z from '@deepseek-ai/schemastery'
18
+ // Type-only: the loader's Events merge (`loader/volatile-update`).
19
+ import type {} from '@deepseek-ai/cordis-plugin-loader'
20
+ import type {} from '@deepseek-ai/dsh-host-webserver'
21
+ import type {} from '@deepseek-ai/dsh-system-prompt'
22
+ import type {} from '@deepseek-ai/dsh-tools'
23
+ import { WinRmEngine } from './engine.ts'
24
+ import { makeRoutes } from './routes.ts'
25
+ import { HostStore } from './store.ts'
26
+ import {
27
+ winrmClusterTool,
28
+ winrmDownloadTool,
29
+ winrmExecTool,
30
+ winrmListTool,
31
+ winrmProcessTool,
32
+ winrmServiceTool,
33
+ winrmUploadTool,
34
+ } from './tools.ts'
35
+ import { mountOnce } from './mount-once.ts'
36
+
37
+ /** Stable cordis plugin name. */
38
+ export const name = 'winrm'
39
+
40
+ /** Services required before the WinRM surfaces can mount. */
41
+ export const inject = ['webServer', 'tools', 'systemPrompt']
42
+
43
+ /** Plugin config, validated by the same-named schemastery schema. Live fields are `Volatile`. */
44
+ export interface Config {
45
+ /** When true (default), a system-prompt section announces the plugin to every agent. */
46
+ announceToAgent?: Volatile<boolean>
47
+ /** Master switch for the plugin (routes, tools, prompt section). */
48
+ enabled?: Volatile<boolean>
49
+ }
50
+
51
+ export const Config = z.object({
52
+ announceToAgent: z.boolean().default(true).volatile(),
53
+ enabled: z.boolean().default(true).volatile(),
54
+ })
55
+
56
+ /** Schema default, re-read for hand-built test contexts. */
57
+ const DEFAULT_ANNOUNCE = true
58
+
59
+ /** Schema default for the master switch. */
60
+ const DEFAULT_ENABLED = true
61
+
62
+ /** Order of the announcement section within the tool-guidance band. */
63
+ const SECTION_ORDER = 151
64
+
65
+ /** Model-facing announcement: plugin presence, capabilities, and limits. */
66
+ export const WINRM_GUIDANCE = '本机已安装 dsh-winrm 插件(DSH 远程 Windows 运维,WinRM/PowerShell Remoting):侧边栏「Windows」入口;仿照 dsh-ssh 插件开发。能力:主机配置存 ~/.dsh/dsh-winrm.json(GUI 配置后 agent 方可使用);winrm_list 列出主机、winrm_exec 执行 PowerShell 命令(UTF-8 信封,中文不乱码)、winrm_service 服务管理(start/stop/restart/启动类型)、winrm_process 进程管理(list/kill)、winrm_upload/winrm_download 文件传输(base64 分块,无 SMB 依赖)、winrm_cluster 集群并发执行;Web 控制台走 WebSocket。认证:通过原生 Node.js WinRM 客户端(winrm-client)的 NTLM/Negotiate 与 Basic 实现,兼容本地账户与域账户;传输支持 http(5985)/https(5986)。前提:目标机需启用 WinRM(scripts/enable-winrm.ps1),本机无需 Python —— WinRM 走纯 Node 依赖。HTTP 仅建议受信内网使用;密码以明文存在用户主目录私有文件(权限 0600);命令输出可能含敏感信息;传输/执行消耗真实远程资源,先确认再操作。用户提到「Windows 服务器 / WinRM / PowerShell 远程 / 服务管理 / 进程管理」时即指本插件,请据此协作。'
67
+
68
+ /**
69
+ * Mount the WinRM engine, routes, tools, and announcement.
70
+ * @param ctx - host plugin context carrying webServer/tools/systemPrompt.
71
+ * @param config - resolved plugin config (volatile fields read via `.get()`).
72
+ */
73
+ export const apply = mountOnce('dsh-winrm', applyImpl)
74
+
75
+ function applyImpl(ctx: Context, config?: Config): void {
76
+ const resolve = (): { announceToAgent: boolean; enabled: boolean } => ({
77
+ announceToAgent: config?.announceToAgent?.get() ?? DEFAULT_ANNOUNCE,
78
+ enabled: config?.enabled?.get() ?? DEFAULT_ENABLED,
79
+ })
80
+
81
+ const store = new HostStore()
82
+ const engine = new WinRmEngine(store)
83
+
84
+ const { routes, upgrade } = makeRoutes({ store, engine })
85
+ const tools = [
86
+ winrmListTool(engine),
87
+ winrmExecTool(engine),
88
+ winrmServiceTool(engine),
89
+ winrmProcessTool(engine),
90
+ winrmUploadTool(engine),
91
+ winrmDownloadTool(engine),
92
+ winrmClusterTool(engine),
93
+ ]
94
+
95
+ let disposeSection: (() => void) | undefined
96
+ let disposeRoutes: (() => void) | undefined
97
+ let disposeTools: (() => void) | undefined
98
+
99
+ const registerRoutes = (): (() => void) => {
100
+ const disposers = routes.map(route => ctx.webServer.register(route))
101
+ const upgradeDisposer = ctx.webServer.registerUpgrade(upgrade)
102
+ return () => {
103
+ for (const dispose of disposers) dispose()
104
+ upgradeDisposer()
105
+ }
106
+ }
107
+ const registerTools = (): (() => void) => {
108
+ const disposers = tools.map(tool => ctx.tools.register(tool))
109
+ return () => { for (const dispose of disposers) dispose() }
110
+ }
111
+
112
+ const sync = (): void => {
113
+ disposeSection?.(); disposeSection = undefined
114
+ disposeRoutes?.(); disposeRoutes = undefined
115
+ disposeTools?.(); disposeTools = undefined
116
+ const value = resolve()
117
+ if (!value.enabled) return
118
+ if (value.announceToAgent) {
119
+ disposeSection = ctx.systemPrompt.section({
120
+ name: 'plugin:dsh-winrm',
121
+ order: SECTION_ORDER,
122
+ text: WINRM_GUIDANCE,
123
+ })
124
+ }
125
+ disposeRoutes = registerRoutes()
126
+ disposeTools = registerTools()
127
+ }
128
+
129
+ // Volatile config edits land on the running references, then notify the
130
+ // owning fiber here — re-sync the registrations against the new values.
131
+ ctx.on('loader/volatile-update', sync)
132
+
133
+ // Single teardown: dispose whatever the latest sync left registered.
134
+ ctx.effect(() => () => {
135
+ disposeSection?.()
136
+ disposeRoutes?.()
137
+ disposeTools?.()
138
+ engine.dispose()
139
+ }, 'dsh-winrm: teardown')
140
+
141
+ sync()
142
+ }
@@ -0,0 +1,4 @@
1
+ /** Invariant companion plugin (no assertions — nothing to check at runtime). */
2
+
3
+ /** Provides no assertions: the winrm plugin owns no cross-package runtime invariants. */
4
+ export function apply(): void {}
@@ -0,0 +1,63 @@
1
+ // Generated by scripts/sync-shared.mjs from shared/host/loopback.ts. Do not edit this copy; edit the shared source and run "node scripts/sync-shared.mjs".
2
+ /**
3
+ * Loopback trust fence shared by the host route families: socket address,
4
+ * Host header, and browser same-origin markers. Packages receive this file as
5
+ * a generated copy via scripts/sync-shared.mjs; edit the shared source and
6
+ * re-run the sync instead of editing a copy.
7
+ *
8
+ * Semantics: RFC 5735 IPv4 127/8, ::1, IPv4-mapped ::ffff:127/8 (matching the
9
+ * remote-web-ui gate), localhost hostnames, plus the browser same-origin
10
+ * markers (sec-fetch-site and Origin) for the request-level fence.
11
+ * @module dsh-web-ui-shared/host/loopback
12
+ */
13
+
14
+ import type { IncomingMessage } from 'node:http'
15
+
16
+ /** IPv4 127/8 predicate (four decimal octets, first == 127). */
17
+ export function isIPv4Loopback(v4: string): boolean {
18
+ const parts = v4.split('.')
19
+ return parts.length === 4
20
+ && parts[0] === '127'
21
+ && parts.every(part => /^\d{1,3}$/.test(part) && Number(part) <= 255)
22
+ }
23
+
24
+ /** Whether a socket remote address names the loopback range (127/8, ::1, IPv4-mapped). */
25
+ export function isLoopbackAddress(address: string | undefined): boolean {
26
+ if (address === undefined) return false
27
+ const normalized = address.toLowerCase()
28
+ if (normalized === '::1') return true
29
+ if (normalized.startsWith('::ffff:')) return isIPv4Loopback(normalized.slice('::ffff:'.length))
30
+ return isIPv4Loopback(normalized)
31
+ }
32
+
33
+ /** Whether a normalized URL hostname names the loopback authority (localhost, [::1], 127/8). */
34
+ export function isLoopbackHostname(hostname: string): boolean {
35
+ if (hostname === 'localhost' || hostname === '[::1]') return true
36
+ return isIPv4Loopback(hostname)
37
+ }
38
+
39
+ /**
40
+ * Request-level trust fence: a loopback socket address AND a loopback Host
41
+ * header, plus browser same-origin markers. The socket address is
42
+ * authoritative; X-Forwarded-For is never trusted.
43
+ */
44
+ export function isLoopbackRequest(request: IncomingMessage): boolean {
45
+ if (!isLoopbackAddress(request.socket.remoteAddress)) return false
46
+ const host = request.headers.host
47
+ if (typeof host !== 'string') return false
48
+ let hostUrl: URL
49
+ try {
50
+ hostUrl = new URL('http://' + host)
51
+ } catch {
52
+ return false
53
+ }
54
+ if (!isLoopbackHostname(hostUrl.hostname)) return false
55
+ if (request.headers['sec-fetch-site'] === 'cross-site') return false
56
+ const origin = request.headers.origin
57
+ if (origin === undefined) return true
58
+ try {
59
+ return new URL(origin).host === hostUrl.host
60
+ } catch {
61
+ return false
62
+ }
63
+ }
@@ -0,0 +1,48 @@
1
+ // Generated by scripts/sync-shared.mjs from shared/host/mount-once.ts. Do not edit this copy; edit the shared source and run "node scripts/sync-shared.mjs".
2
+ /**
3
+ * Host single-instance guard shared by the plugin family. The family bundle
4
+ * (dsh-web-ui-all / dsh-skins) namespaces every child row id (web-ui-*), so
5
+ * the loader accepts a standalone install of the same package side by side;
6
+ * without this guard the second instance would still re-register the same
7
+ * webserver routes, tools, settings namespaces, and system-prompt sections
8
+ * and fail the boot. mountOnce makes the second host apply a no-op for the
9
+ * lifetime of the first instance (the browser half is already deduped by
10
+ * package name in the client module host).
11
+ *
12
+ * The registry rides a global symbol so two module instances of the same
13
+ * package (npm copy vs repository link) still share one verdict. cordis
14
+ * `ctx.effect` runs its callback immediately and treats the callback's
15
+ * return value as the fiber disposer, so the unmarker is returned, not run.
16
+ */
17
+
18
+ const MOUNTED = Symbol.for('dsh-web-ui.mounted-plugins')
19
+
20
+ interface MountRegistry {
21
+ [MOUNTED]?: Set<string>
22
+ }
23
+
24
+ function mountedSet(): Set<string> {
25
+ const registry = globalThis as MountRegistry
26
+ return (registry[MOUNTED] ??= new Set())
27
+ }
28
+
29
+ /**
30
+ * Wrap a cordis plugin apply so the package runs at most once per process.
31
+ * The first mount registers normally and unmarks when its fiber disposes;
32
+ * any later mount of the same package name is a no-op.
33
+ * @param packageName - npm package identity shared by every install source.
34
+ * @param fn - the original plugin apply.
35
+ * @returns an apply of the same shape.
36
+ */
37
+ export function mountOnce<T extends (...args: any[]) => unknown>(packageName: string, fn: T): T {
38
+ return ((...args: unknown[]) => {
39
+ const mounted = mountedSet()
40
+ if (mounted.has(packageName)) return
41
+ mounted.add(packageName)
42
+ const ctx = args[0] as { effect?: (effect: () => unknown) => unknown } | undefined
43
+ ctx?.effect?.(() => () => {
44
+ mounted.delete(packageName)
45
+ })
46
+ return fn(...args)
47
+ }) as T
48
+ }
@@ -0,0 +1,208 @@
1
+ /**
2
+ * PowerShell snippet builders. Every payload rides the UTF-8 base64
3
+ * envelope: the WinRM host prints only ASCII (marker + base64 of UTF-8 text)
4
+ * so Chinese / any codepage output survives the transport losslessly. One-shot
5
+ * commands execute directly in the PowerShell session created by WinRM.
6
+ */
7
+
8
+ /** Marker line prefix carrying the exit code: '__DSH_WINRM__<code>'. */
9
+ export const ENVELOPE_MARKER = '__DSH_WINRM__'
10
+
11
+ /** PS single-quoted literal (doubles embedded quotes). */
12
+ export function sq(value: string): string {
13
+ return "'" + value.replace(/'/g, "''") + "'"
14
+ }
15
+
16
+ /** Base64 of a UTF-16LE PowerShell script, for -EncodedCommand. */
17
+ export function encodeCommand(script: string): string {
18
+ return Buffer.from(script, 'utf16le').toString('base64')
19
+ }
20
+
21
+ /**
22
+ * The UTF-8 WinRM session script: runs `userScript` inside a script block,
23
+ * merges stderr, prints the exit-code marker and base64 of UTF-8 output.
24
+ */
25
+ export function wrapEnvelope(userScript: string): string {
26
+ return [
27
+ "$ErrorActionPreference = 'Continue'",
28
+ "$__out = & { " + userScript + " } 2>&1 | Out-String -Width 4096",
29
+ "$__code = 0",
30
+ "if ($null -ne $LASTEXITCODE) { $__code = $LASTEXITCODE }",
31
+ "[Console]::OutputEncoding = [System.Text.Encoding]::UTF8",
32
+ "'__DSH_WINRM__' + [string]$__code",
33
+ "[System.Convert]::ToBase64String([System.Text.Encoding]::UTF8.GetBytes([string]$__out))",
34
+ '',
35
+ ].join('\r\n')
36
+ }
37
+
38
+ /** One-shot script body for the PowerShell session already created by WinRM. */
39
+ export function winrmPowerShellScript(userScript: string): string {
40
+ return wrapEnvelope(userScript)
41
+ }
42
+
43
+ /** The full powershell.exe command line (ASCII-safe) for a script body. */
44
+ export function powershellCommandLine(script: string): string {
45
+ return 'powershell.exe -NoProfile -NoLogo -ExecutionPolicy Bypass -EncodedCommand ' + encodeCommand(script)
46
+ }
47
+
48
+ /** One-shot non-interactive PowerShell command line (envelope-wrapped). */
49
+ export function execCommandLine(userScript: string): string {
50
+ return powershellCommandLine(wrapEnvelope(userScript))
51
+ }
52
+
53
+ /** Interactive bootstrap for the streaming console (keeps the shell alive). */
54
+ export function consoleCommandLine(): string {
55
+ const bootstrap = [
56
+ "$ErrorActionPreference = 'Continue'",
57
+ "[Console]::OutputEncoding = [System.Text.Encoding]::UTF8",
58
+ "'__DSH_WINRM_CONSOLE_READY__'",
59
+ '',
60
+ ].join('\r\n')
61
+ // -NoExit keeps powershell.exe alive reading stdin (interactive mode).
62
+ return 'powershell.exe -NoProfile -NoLogo -ExecutionPolicy Bypass -EncodedCommand '
63
+ + encodeCommand(bootstrap) + ' -NoExit'
64
+ }
65
+
66
+ // ------------------------------------------------------------ snippets
67
+
68
+ /** JSON: full service table (CIM). */
69
+ export function psListServices(): string {
70
+ return [
71
+ '$__s = Get-CimInstance Win32_Service | Select-Object Name, DisplayName, State, StartMode, StartName | ForEach-Object {',
72
+ ' [PSCustomObject]@{ name=$_.Name; displayName=$_.DisplayName; status=[string]$_.State; startMode=[string]$_.StartMode; startName=[string]$_.StartName }',
73
+ '}',
74
+ 'ConvertTo-Json -InputObject $__s -Compress -Depth 3',
75
+ '',
76
+ ].join('\r\n')
77
+ }
78
+
79
+ /** JSON: one service after an action. */
80
+ export function psServiceAction(name: string, action: 'start' | 'stop' | 'restart' | 'set-auto' | 'set-manual' | 'set-disabled'): string {
81
+ const statements: string[] = []
82
+ if (action === 'start') statements.push('Start-Service -Name ' + sq(name) + ' -ErrorAction Stop')
83
+ if (action === 'stop') statements.push('Stop-Service -Name ' + sq(name) + ' -Force -ErrorAction Stop')
84
+ if (action === 'restart') statements.push('Restart-Service -Name ' + sq(name) + ' -Force -ErrorAction Stop')
85
+ if (action === 'set-auto') statements.push('Set-Service -Name ' + sq(name) + ' -StartupType Automatic -ErrorAction Stop')
86
+ if (action === 'set-manual') statements.push('Set-Service -Name ' + sq(name) + ' -StartupType Manual -ErrorAction Stop')
87
+ if (action === 'set-disabled') statements.push('Set-Service -Name ' + sq(name) + ' -StartupType Disabled -ErrorAction Stop')
88
+ statements.push('$__s = Get-CimInstance Win32_Service -Filter ' + sq('Name=' + name.replace(/'/g, "''")) + ' | Select-Object Name, DisplayName, State, StartMode, StartName')
89
+ statements.push('ConvertTo-Json -InputObject $__s -Compress -Depth 3')
90
+ return statements.join('\r\n')
91
+ }
92
+
93
+ /** JSON: process table. */
94
+ export function psListProcesses(): string {
95
+ return [
96
+ '$__p = Get-Process | Sort-Object Id | Select-Object Id, ProcessName, CPU, WS, StartTime, Path | ForEach-Object {',
97
+ ' [PSCustomObject]@{ id=$_.Id; name=$_.ProcessName; cpu=if($null -eq $_.CPU){$null}else{[math]::Round([double]$_.CPU,1)}; memMB=[math]::Round($_.WS/1MB,1); startTime=if($_.StartTime){$_.StartTime.ToString(\'yyyy-MM-dd HH:mm:ss\')}else{$null}; path=[string]$_.Path }',
98
+ '}',
99
+ 'ConvertTo-Json -InputObject $__p -Compress -Depth 3',
100
+ '',
101
+ ].join('\r\n')
102
+ }
103
+
104
+ /** Kill one process by id. */
105
+ export function psKillProcess(id: number): string {
106
+ return 'Stop-Process -Id ' + String(id) + ' -Force -ErrorAction Stop; "killed " + ' + String(id)
107
+ }
108
+
109
+ /** JSON: directory listing. */
110
+ export function psListDir(dir: string): string {
111
+ return [
112
+ '$__d = ' + sq(dir),
113
+ '$__items = Get-ChildItem -LiteralPath $__d -Force -ErrorAction Stop | Select-Object Name, PSIsContainer, Length, LastWriteTime | ForEach-Object {',
114
+ ' [PSCustomObject]@{ name=[string]$_.Name; type=if($_.PSIsContainer){\'dir\'}elseif(-not $_.PSIsContainer -and $_.Length -ge 0){\'file\'}else{\'other\'}; size=if($_.PSIsContainer){0}else{[long]$_.Length}; mtimeMs=[long](([DateTimeOffset]$_.LastWriteTime).ToUnixTimeMilliseconds()) }',
115
+ '}',
116
+ 'ConvertTo-Json -InputObject $__items -Compress -Depth 3',
117
+ '',
118
+ ].join('\r\n')
119
+ }
120
+
121
+ /** Plain: remote file size in bytes (0 when missing). */
122
+ export function psFileSize(p: string): string {
123
+ return [
124
+ '$__f = Get-Item -LiteralPath ' + sq(p) + ' -ErrorAction SilentlyContinue',
125
+ 'if ($null -eq $__f) { "0" } elseif ($__f.PSIsContainer) { "0" } else { [string]$__f.Length }',
126
+ '',
127
+ ].join('\r\n')
128
+ }
129
+
130
+ /** Plain: base64 of up to `count` bytes at `offset` (download chunk). */
131
+ export function psReadChunk(p: string, offset: number, count: number): string {
132
+ return [
133
+ '$__p = ' + sq(p),
134
+ '$__fs = [System.IO.File]::OpenRead($__p)',
135
+ 'try {',
136
+ ' $__fs.Position = [long]' + String(offset),
137
+ ' $__buf = New-Object byte[] ' + String(count),
138
+ ' $__n = $__fs.Read($__buf, 0, $__buf.Length)',
139
+ ' [System.Convert]::ToBase64String($__buf, 0, $__n)',
140
+ '} finally { $__fs.Dispose() }',
141
+ '',
142
+ ].join('\r\n')
143
+ }
144
+
145
+ /**
146
+ * Plain: append `b64` bytes to `p` (Create on first chunk, Append after),
147
+ * prints the number of bytes written.
148
+ */
149
+ export function psWriteChunk(p: string, b64: string, append: boolean): string {
150
+ return [
151
+ '$__p = ' + sq(p),
152
+ '$__d = [System.IO.Path]::GetDirectoryName($__p)',
153
+ 'if (-not [string]::IsNullOrEmpty($__d)) { [System.IO.Directory]::CreateDirectory($__d) | Out-Null }',
154
+ '$__b = [System.Convert]::FromBase64String(' + sq(b64) + ')',
155
+ '$__mode = ' + (append ? '[System.IO.FileMode]::Append' : '[System.IO.FileMode]::Create'),
156
+ '$__fs = New-Object System.IO.FileStream($__p, $__mode, [System.IO.FileAccess]::Write, [System.IO.FileShare]::Read)',
157
+ 'try { $__fs.Write($__b, 0, $__b.Length) } finally { $__fs.Dispose() }',
158
+ '[string]$__b.Length',
159
+ '',
160
+ ].join('\r\n')
161
+ }
162
+
163
+ /**
164
+ * Parse the envelope output: { exitCode, text } or null when absent.
165
+ *
166
+ * The base64 body is optional and the separating newline may be missing: a
167
+ * command that printed nothing emits the marker alone (an empty string has
168
+ * no base64 body), and a transport that trims each response drops the
169
+ * trailing newline. The exit code is signed because `$LASTEXITCODE` can be
170
+ * negative.
171
+ */
172
+ export function parseEnvelope(output: string): { exitCode: number; text: string } | null {
173
+ const match = /^__DSH_WINRM__(-?\d+)(?:\r?\n([\s\S]*))?$/.exec(output)
174
+ if (match === null) return null
175
+ const exitCode = Number.parseInt(match[1], 10)
176
+ const payload = (match[2] ?? '').trim()
177
+ try {
178
+ const text = Buffer.from(payload, 'base64').toString('utf8')
179
+ return { exitCode, text }
180
+ } catch {
181
+ return { exitCode, text: payload }
182
+ }
183
+ }
184
+
185
+ /** The `#< CLIXML` stream header PowerShell writes once before its serialized records. */
186
+ const CLIXML_HEADER = /^#< CLIXML\r?$/gm
187
+
188
+ /** One serialized record block (`<Objs …>…</Objs>`) holding host progress/verbose records. */
189
+ const CLIXML_RECORDS = /<Objs\b[^>]*>[\s\S]*?<\/Objs>\r?\n?/g
190
+
191
+ /**
192
+ * Remove PowerShell's CLIXML host records from the WinRS stderr stream.
193
+ *
194
+ * PowerShell marks its stderr stream with a `#< CLIXML` header line and
195
+ * serializes the remote host's own progress/verbose records into `<Objs>`
196
+ * blocks. Both are removed here. Anything else on the stream is genuine
197
+ * process-level stderr and is preserved verbatim — text written between the
198
+ * header and a record block (for example `[Console]::Error`) is real output,
199
+ * not part of the CLIXML payload.
200
+ *
201
+ * The script's own error output is already merged into stdout by the
202
+ * envelope, so this only removes transport-level noise.
203
+ * @param stderr - raw stderr text captured from the WinRS stream.
204
+ * @returns the stderr text without CLIXML host records.
205
+ */
206
+ export function stripClixml(stderr: string): string {
207
+ return stderr.replace(CLIXML_HEADER, '').replace(CLIXML_RECORDS, '').trim()
208
+ }
@@ -0,0 +1,178 @@
1
+ /**
2
+ * Wire contract between the host half (routes.ts) and the browser half
3
+ * (client/api.ts). Pure types only — imported by both halves, bundled into
4
+ * each, no runtime identity to share.
5
+ */
6
+
7
+ /** WinRM transport flavors. */
8
+ export type WinTransport = 'http' | 'https'
9
+
10
+ /** One stored host entry (the ~/.dsh/dsh-winrm.json store shape). */
11
+ export interface WinHostEntry {
12
+ /** Stable, user-chosen identifier used by every operation. */
13
+ alias: string
14
+ /** Hostname or IP of the target Windows machine. */
15
+ host: string
16
+ /** WinRM port (5985 HTTP, 5986 HTTPS). */
17
+ port: number
18
+ /** Login user. The transport auto-selects NTLM/Negotiate or Basic for local and domain accounts. */
19
+ user: string
20
+ /** Authentication (WinRM password; transport handles NTLM/Negotiate/Basic in-process). */
21
+ auth: {
22
+ kind: 'password'
23
+ password?: string
24
+ }
25
+ /** http (5985) or https (5986). */
26
+ transport: WinTransport
27
+ /** Whether self-signed HTTPS certificates are accepted. */
28
+ rejectUnauthorized?: boolean
29
+ /** Free-form note. */
30
+ description?: string
31
+ /** Deployment environment label (development / production / ...). */
32
+ environment?: string
33
+ /** Free-form tags. */
34
+ tags: string[]
35
+ /** Physical location note. */
36
+ location?: string
37
+ createdAt: number
38
+ updatedAt: number
39
+ }
40
+
41
+ /** Public (secret-free) projection of an entry, safe for the browser/agent. */
42
+ export interface WinHostSummary {
43
+ alias: string
44
+ host: string
45
+ port: number
46
+ user: string
47
+ auth: 'password'
48
+ transport: WinTransport
49
+ rejectUnauthorized?: boolean
50
+ description?: string
51
+ environment?: string
52
+ tags: string[]
53
+ location?: string
54
+ createdAt: number
55
+ updatedAt: number
56
+ }
57
+
58
+ /** Result of one PowerShell command execution. */
59
+ export interface ExecResult {
60
+ success: boolean
61
+ /** Remote exit code, or null when the channel died without one. */
62
+ exitCode: number | null
63
+ timedOut: boolean
64
+ stdout: string
65
+ stderr: string
66
+ /** Wall-clock duration of the round trip in ms. */
67
+ durationMs: number
68
+ /** Connection error message when the command never ran. */
69
+ error?: string
70
+ }
71
+
72
+ /** One Windows service entry. */
73
+ export interface ServiceInfo {
74
+ name: string
75
+ displayName: string
76
+ status: string
77
+ startMode: string
78
+ startName?: string
79
+ }
80
+
81
+ /** One Windows process entry. */
82
+ export interface ProcessInfo {
83
+ id: number
84
+ name: string
85
+ cpu?: number
86
+ memMB?: number
87
+ startTime?: string
88
+ path?: string
89
+ }
90
+
91
+ /** One directory listing entry (remote file browser). */
92
+ export interface RemoteDirEntry {
93
+ name: string
94
+ type: 'dir' | 'file' | 'other'
95
+ size: number
96
+ mtimeMs: number
97
+ }
98
+
99
+ /** One server entry in a cluster run. */
100
+ export interface ClusterResult {
101
+ alias: string
102
+ ok: boolean
103
+ exitCode?: number | null
104
+ timedOut?: boolean
105
+ stdout?: string
106
+ stderr?: string
107
+ durationMs?: number
108
+ error?: string
109
+ }
110
+
111
+ /** Transfer progress frame (upload/download). */
112
+ export interface TransferProgress {
113
+ phase: 'connecting' | 'transferring' | 'done' | 'error'
114
+ file: string
115
+ transferred: number
116
+ total: number
117
+ percent: number
118
+ speedBps?: number
119
+ error?: string
120
+ }
121
+
122
+ /** Test-connection outcome. */
123
+ export interface TestResult {
124
+ ok: boolean
125
+ latencyMs?: number
126
+ error?: string
127
+ }
128
+
129
+ /** Host edit payload (create/update); 'alias' comes from the URL for updates. */
130
+ export interface WinHostPayload {
131
+ alias?: string
132
+ host: string
133
+ port?: number
134
+ user: string
135
+ /** Authentication. Required on create; on update an omitted auth keeps the stored password. */
136
+ auth?: WinHostEntry['auth']
137
+ transport?: WinTransport
138
+ rejectUnauthorized?: boolean
139
+ description?: string
140
+ environment?: string
141
+ tags?: string[]
142
+ location?: string
143
+ }
144
+
145
+ /** JSON error body used by every route. */
146
+ export interface ApiErrorBody {
147
+ error: string
148
+ }
149
+
150
+ /** WebSocket console protocol frames (host -> client and client -> host). */
151
+ export type ConsoleServerFrame =
152
+ | { type: 'ready'; alias: string }
153
+ | { type: 'output'; data: string }
154
+ | { type: 'exit'; code: number | null; error?: string }
155
+
156
+ export type ConsoleClientFrame =
157
+ | { type: 'input'; data: string }
158
+
159
+ /** Route paths the client calls (shared literals). */
160
+ export const WINRM_API_BASE = '/api/dsh-winrm' as const
161
+
162
+ export const WINRM_API = {
163
+ hosts: WINRM_API_BASE + '/hosts',
164
+ test: WINRM_API_BASE + '/test',
165
+ exec: WINRM_API_BASE + '/exec',
166
+ cluster: WINRM_API_BASE + '/cluster',
167
+ services: WINRM_API_BASE + '/services',
168
+ processes: WINRM_API_BASE + '/processes',
169
+ ls: WINRM_API_BASE + '/ls',
170
+ upload: WINRM_API_BASE + '/upload',
171
+ download: WINRM_API_BASE + '/download',
172
+ console: WINRM_API_BASE + '/console',
173
+ } as const
174
+
175
+ /** NDJSON transfer stream line shapes (upload). */
176
+ export type TransferStreamLine =
177
+ | { type: 'progress'; progress: TransferProgress }
178
+ | { type: 'result'; ok: boolean; transferredBytes?: number; error?: string }