@geekbeer/minion 3.10.0 → 3.11.5
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/docs/environment-setup.md +80 -0
- package/package.json +1 -1
- package/win/bin/wsl-session-entry.js +89 -0
- package/win/minion-cli.ps1 +68 -0
- package/win/routes/chat.js +103 -2
- package/win/routes/terminal.js +121 -3
- package/win/server.js +22 -0
- package/win/terminal-server.js +62 -0
- package/win/wsl-session-server.js +503 -0
|
@@ -162,6 +162,86 @@ URL ベースの MCP サーバーは `url` フィールドで指定する(`com
|
|
|
162
162
|
|
|
163
163
|
---
|
|
164
164
|
|
|
165
|
+
## WSL セッション(Windows ミニオン限定)
|
|
166
|
+
|
|
167
|
+
Windows ミニオンで WSL(Windows Subsystem for Linux)内の Docker やリポジトリ操作を行うための機能。
|
|
168
|
+
|
|
169
|
+
### 概要
|
|
170
|
+
|
|
171
|
+
minion-agent は LocalSystem (Session 0) で動作するため、ユーザー単位の WSL ディストリビューションに直接アクセスできない。WSL セッションサーバーがユーザーセッションで別プロセスとして動作し、minion-agent がリクエストをプロキシする。
|
|
172
|
+
|
|
173
|
+
### 前提条件
|
|
174
|
+
|
|
175
|
+
- WSL 2 がインストール済み(`wsl --install` で導入可能)
|
|
176
|
+
- Linux ディストリビューション(Ubuntu 等)がセットアップ済み
|
|
177
|
+
- Docker Desktop for Windows がインストール済み(WSL 2 バックエンド有効)
|
|
178
|
+
- ターゲットユーザーがログイン中(schtasks ONLOGON でサーバーが起動するため)
|
|
179
|
+
|
|
180
|
+
### セットアップ
|
|
181
|
+
|
|
182
|
+
`minion-cli setup` を実行すると、WSL が検出された場合に自動で登録される。手動で確認・起動する場合:
|
|
183
|
+
|
|
184
|
+
```powershell
|
|
185
|
+
# WSL セッションサーバーの状態確認
|
|
186
|
+
schtasks /Query /TN "MinionWSL" /FO LIST
|
|
187
|
+
|
|
188
|
+
# 手動起動(テスト用)
|
|
189
|
+
schtasks /Run /TN "MinionWSL"
|
|
190
|
+
|
|
191
|
+
# ヘルスチェック
|
|
192
|
+
curl http://localhost:7682/api/wsl/health
|
|
193
|
+
```
|
|
194
|
+
|
|
195
|
+
### 使い方
|
|
196
|
+
|
|
197
|
+
#### ターミナルセッション
|
|
198
|
+
|
|
199
|
+
```bash
|
|
200
|
+
# WSL ターミナルセッション作成
|
|
201
|
+
curl -X POST http://localhost:8080/api/terminal/create \
|
|
202
|
+
-H "Authorization: Bearer $API_TOKEN" \
|
|
203
|
+
-H "Content-Type: application/json" \
|
|
204
|
+
-d '{"name": "wsl-dev", "type": "wsl"}'
|
|
205
|
+
|
|
206
|
+
# コマンド送信
|
|
207
|
+
curl -X POST http://localhost:8080/api/terminal/send \
|
|
208
|
+
-H "Authorization: Bearer $API_TOKEN" \
|
|
209
|
+
-H "Content-Type: application/json" \
|
|
210
|
+
-d '{"session": "wsl-dev", "input": "docker compose up -d", "enter": true}'
|
|
211
|
+
|
|
212
|
+
# WSL セッションサーバーの稼働状態確認
|
|
213
|
+
curl http://localhost:8080/api/terminal/wsl/status \
|
|
214
|
+
-H "Authorization: Bearer $API_TOKEN"
|
|
215
|
+
```
|
|
216
|
+
|
|
217
|
+
#### チャット(WSL モード)
|
|
218
|
+
|
|
219
|
+
チャット API に `wsl_mode: true` を追加すると、Claude Code CLI がユーザーセッションで実行され、`wsl` コマンドを直接使用できる。
|
|
220
|
+
|
|
221
|
+
```bash
|
|
222
|
+
curl -X POST http://localhost:8080/api/chat \
|
|
223
|
+
-H "Authorization: Bearer $API_TOKEN" \
|
|
224
|
+
-H "Content-Type: application/json" \
|
|
225
|
+
-d '{"message": "WSL内でリポジトリをクローンしてDockerを起動して", "wsl_mode": true}'
|
|
226
|
+
```
|
|
227
|
+
|
|
228
|
+
### ポート構成
|
|
229
|
+
|
|
230
|
+
| ポート | サービス | 備考 |
|
|
231
|
+
|--------|---------|------|
|
|
232
|
+
| 7682 | WSL session server (HTTP API) | localhost のみ |
|
|
233
|
+
| 7683 | WSL session server (WebSocket) | localhost のみ、ttyd プロトコル |
|
|
234
|
+
|
|
235
|
+
### トラブルシューティング
|
|
236
|
+
|
|
237
|
+
| 問題 | 原因 | 対処 |
|
|
238
|
+
|------|------|------|
|
|
239
|
+
| WSL session server is not running | ユーザーが未ログイン | RDP/コンソールでログイン |
|
|
240
|
+
| WSL not detected during setup | WSL 未インストール | `wsl --install` を実行後 `minion-cli setup` を再実行 |
|
|
241
|
+
| Connection refused on port 7682 | サーバー異常終了 | `schtasks /Run /TN "MinionWSL"` で再起動 |
|
|
242
|
+
|
|
243
|
+
---
|
|
244
|
+
|
|
165
245
|
## パッケージのインストール
|
|
166
246
|
|
|
167
247
|
スキルが `requires.packages` で宣言したパッケージは、対応するパッケージマネージャーの list コマンドで検出される。
|
package/package.json
CHANGED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* WSL Session Server Entry Point
|
|
4
|
+
*
|
|
5
|
+
* Designed to be launched via schtasks ONLOGON so it runs in the
|
|
6
|
+
* target user's interactive session (not LocalSystem/Session 0).
|
|
7
|
+
*
|
|
8
|
+
* Reads configuration from .minion/.env, sets up environment,
|
|
9
|
+
* then starts the WSL session server.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
const fs = require('fs')
|
|
13
|
+
const path = require('path')
|
|
14
|
+
|
|
15
|
+
// ---------------------------------------------------------------------------
|
|
16
|
+
// Resolve HOME_DIR from .minion/.env or environment
|
|
17
|
+
// ---------------------------------------------------------------------------
|
|
18
|
+
|
|
19
|
+
function loadEnvFile(envPath) {
|
|
20
|
+
if (!fs.existsSync(envPath)) return {}
|
|
21
|
+
const env = {}
|
|
22
|
+
for (const line of fs.readFileSync(envPath, 'utf-8').split('\n')) {
|
|
23
|
+
const trimmed = line.trim()
|
|
24
|
+
if (!trimmed || trimmed.startsWith('#')) continue
|
|
25
|
+
const eqIdx = trimmed.indexOf('=')
|
|
26
|
+
if (eqIdx < 0) continue
|
|
27
|
+
const key = trimmed.slice(0, eqIdx).trim()
|
|
28
|
+
let value = trimmed.slice(eqIdx + 1).trim()
|
|
29
|
+
// Strip surrounding quotes
|
|
30
|
+
if ((value.startsWith('"') && value.endsWith('"')) ||
|
|
31
|
+
(value.startsWith("'") && value.endsWith("'"))) {
|
|
32
|
+
value = value.slice(1, -1)
|
|
33
|
+
}
|
|
34
|
+
env[key] = value
|
|
35
|
+
}
|
|
36
|
+
return env
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Determine HOME_DIR: prefer .minion/.env, fallback to env vars
|
|
40
|
+
const homeDir = process.env.HOME || process.env.USERPROFILE || ''
|
|
41
|
+
const dataDir = path.join(homeDir, '.minion')
|
|
42
|
+
const envFile = path.join(dataDir, '.env')
|
|
43
|
+
const envVars = loadEnvFile(envFile)
|
|
44
|
+
|
|
45
|
+
// Apply env vars that aren't already set
|
|
46
|
+
for (const [key, value] of Object.entries(envVars)) {
|
|
47
|
+
if (!(key in process.env)) process.env[key] = value
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Ensure HOME/USERPROFILE are set
|
|
51
|
+
if (!process.env.HOME) process.env.HOME = homeDir
|
|
52
|
+
if (!process.env.USERPROFILE) process.env.USERPROFILE = homeDir
|
|
53
|
+
|
|
54
|
+
// ---------------------------------------------------------------------------
|
|
55
|
+
// Extend PATH (same logic as server.js)
|
|
56
|
+
// ---------------------------------------------------------------------------
|
|
57
|
+
|
|
58
|
+
try {
|
|
59
|
+
const { buildExtendedPath } = require('../../core/lib/platform')
|
|
60
|
+
process.env.PATH = buildExtendedPath(homeDir)
|
|
61
|
+
} catch (err) {
|
|
62
|
+
console.warn('[WSL-Entry] Could not extend PATH:', err.message)
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Load minion secrets into process.env
|
|
66
|
+
try {
|
|
67
|
+
const variableStore = require('../../core/stores/variable-store')
|
|
68
|
+
const minionEnv = variableStore.buildEnv()
|
|
69
|
+
for (const [key, value] of Object.entries(minionEnv)) {
|
|
70
|
+
if (!(key in process.env)) process.env[key] = value
|
|
71
|
+
}
|
|
72
|
+
console.log(`[WSL-Entry] Loaded ${Object.keys(minionEnv).length} minion secrets`)
|
|
73
|
+
} catch (err) {
|
|
74
|
+
console.warn('[WSL-Entry] Could not load minion secrets:', err.message)
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// ---------------------------------------------------------------------------
|
|
78
|
+
// Start the server
|
|
79
|
+
// ---------------------------------------------------------------------------
|
|
80
|
+
|
|
81
|
+
console.log(`[WSL-Entry] HOME_DIR: ${homeDir}`)
|
|
82
|
+
console.log(`[WSL-Entry] PID: ${process.pid}`)
|
|
83
|
+
|
|
84
|
+
const { startServer } = require('../wsl-session-server')
|
|
85
|
+
|
|
86
|
+
startServer().catch((err) => {
|
|
87
|
+
console.error('[WSL-Entry] Failed to start WSL session server:', err)
|
|
88
|
+
process.exit(1)
|
|
89
|
+
})
|
package/win/minion-cli.ps1
CHANGED
|
@@ -800,6 +800,48 @@ function Invoke-Setup {
|
|
|
800
800
|
Write-Detail "TightVNC registered as logon task (user session, not service)"
|
|
801
801
|
}
|
|
802
802
|
|
|
803
|
+
# Step 9b: Register WSL session server (runs in user session for WSL access)
|
|
804
|
+
$wslServerJs = Join-Path $minionPkgDir 'win\bin\wsl-session-entry.js'
|
|
805
|
+
if (Test-Path $wslServerJs) {
|
|
806
|
+
# Generate shared auth token for minion-agent <-> wsl-session-server communication
|
|
807
|
+
$wslTokenFile = Join-Path $DataDir '.wsl-session-token'
|
|
808
|
+
if (-not (Test-Path $wslTokenFile)) {
|
|
809
|
+
$wslToken = [System.Guid]::NewGuid().ToString('N')
|
|
810
|
+
[System.IO.File]::WriteAllText($wslTokenFile, $wslToken)
|
|
811
|
+
Write-Detail "WSL session token generated"
|
|
812
|
+
} else {
|
|
813
|
+
Write-Detail "WSL session token already exists"
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
$wslAvailable = $false
|
|
817
|
+
if (Get-Command wsl.exe -ErrorAction SilentlyContinue) {
|
|
818
|
+
$wslAvailable = $true
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
if ($wslAvailable) {
|
|
822
|
+
# Grant target user read+execute access to the package directory
|
|
823
|
+
# so the schtask (running as target user) can access the entry script
|
|
824
|
+
if ($targetUserName -ne $env:USERNAME) {
|
|
825
|
+
try {
|
|
826
|
+
icacls $minionPkgDir /grant "${targetUserName}:(OI)(CI)RX" /T /Q 2>$null | Out-Null
|
|
827
|
+
Write-Detail "Granted $targetUserName read access to package directory"
|
|
828
|
+
} catch {
|
|
829
|
+
Write-Warn "Could not grant package directory access to $targetUserName"
|
|
830
|
+
}
|
|
831
|
+
}
|
|
832
|
+
Remove-ScheduledTaskSilent "MinionWSL"
|
|
833
|
+
$wslTR = "'$nodePath' '$wslServerJs'"
|
|
834
|
+
schtasks /Create /TN "MinionWSL" /TR $wslTR /SC ONLOGON /RU $targetUserName /RL LIMITED /F | Out-Null
|
|
835
|
+
Write-Detail "WSL session server registered as logon task (user: $targetUserName)"
|
|
836
|
+
# Start immediately (ONLOGON only triggers on next login)
|
|
837
|
+
schtasks /Run /TN "MinionWSL" 2>$null | Out-Null
|
|
838
|
+
Write-Detail "WSL session server started"
|
|
839
|
+
} else {
|
|
840
|
+
Write-Warn "WSL not detected. WSL session server not registered."
|
|
841
|
+
Write-Detail "Install WSL and re-run setup to enable WSL sessions."
|
|
842
|
+
}
|
|
843
|
+
}
|
|
844
|
+
|
|
803
845
|
# Step 10: Setup websockify (runs as LocalSystem, paired with VNC)
|
|
804
846
|
Write-Step 9 $totalSteps "Setting up websockify..."
|
|
805
847
|
[array]$wsCmd = Get-WebsockifyCommand
|
|
@@ -949,6 +991,7 @@ function Invoke-Setup {
|
|
|
949
991
|
$fwRules = @(
|
|
950
992
|
@{ Name = 'Minion Agent'; Port = 8080 },
|
|
951
993
|
@{ Name = 'Minion Terminal'; Port = 7681 },
|
|
994
|
+
@{ Name = 'Minion WSL Terminal'; Port = 7683 },
|
|
952
995
|
@{ Name = 'Minion VNC'; Port = 6080 }
|
|
953
996
|
)
|
|
954
997
|
foreach ($rule in $fwRules) {
|
|
@@ -1096,6 +1139,10 @@ function Invoke-Uninstall {
|
|
|
1096
1139
|
}
|
|
1097
1140
|
}
|
|
1098
1141
|
|
|
1142
|
+
# Remove WSL session server logon task
|
|
1143
|
+
Remove-ScheduledTaskSilent "MinionWSL"
|
|
1144
|
+
Write-Detail "WSL session server logon task removed"
|
|
1145
|
+
|
|
1099
1146
|
# Remove VNC logon task and legacy NSSM service
|
|
1100
1147
|
Remove-ScheduledTaskSilent "MinionVNC"
|
|
1101
1148
|
Invoke-Nssm stop minion-vnc
|
|
@@ -1436,6 +1483,27 @@ function Invoke-Configure {
|
|
|
1436
1483
|
Write-Detail "minion-websockify started"
|
|
1437
1484
|
}
|
|
1438
1485
|
Start-MinionService
|
|
1486
|
+
# Start WSL session server if token exists (task was registered during setup)
|
|
1487
|
+
$wslTokenFile = Join-Path $DataDir '.wsl-session-token'
|
|
1488
|
+
if (Test-Path $wslTokenFile) {
|
|
1489
|
+
$wslAlreadyRunning = $false
|
|
1490
|
+
try {
|
|
1491
|
+
Invoke-RestMethod -Uri "http://127.0.0.1:7682/api/wsl/health" -TimeoutSec 2 -UseBasicParsing | Out-Null
|
|
1492
|
+
$wslAlreadyRunning = $true
|
|
1493
|
+
} catch {}
|
|
1494
|
+
if ($wslAlreadyRunning) {
|
|
1495
|
+
Write-Detail "WSL session server already running"
|
|
1496
|
+
} else {
|
|
1497
|
+
try {
|
|
1498
|
+
$wslServerJs = Join-Path $CliDir 'win\bin\wsl-session-entry.js'
|
|
1499
|
+
$wslNodePath = (Get-Command node).Source
|
|
1500
|
+
Start-Process -FilePath $wslNodePath -ArgumentList "`"$wslServerJs`"" -WindowStyle Hidden
|
|
1501
|
+
Write-Detail "WSL session server started"
|
|
1502
|
+
} catch {
|
|
1503
|
+
Write-Detail "WSL session server not started: $($_.Exception.Message)"
|
|
1504
|
+
}
|
|
1505
|
+
}
|
|
1506
|
+
}
|
|
1439
1507
|
|
|
1440
1508
|
# Health check
|
|
1441
1509
|
Write-Step $totalSteps $totalSteps "Health check..."
|
package/win/routes/chat.js
CHANGED
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
const { spawn } = require('child_process')
|
|
16
16
|
const fs = require('fs')
|
|
17
17
|
const path = require('path')
|
|
18
|
+
const http = require('http')
|
|
18
19
|
const { verifyToken } = require('../../core/lib/auth')
|
|
19
20
|
const { config } = require('../../core/config')
|
|
20
21
|
const chatStore = require('../../core/stores/chat-store')
|
|
@@ -22,6 +23,91 @@ const { DATA_DIR } = require('../../core/lib/platform')
|
|
|
22
23
|
const { runEndOfDay } = require('../../core/lib/end-of-day')
|
|
23
24
|
|
|
24
25
|
let activeChatChild = null
|
|
26
|
+
let wslModeActive = false
|
|
27
|
+
|
|
28
|
+
// ---------------------------------------------------------------------------
|
|
29
|
+
// WSL session server proxy helpers
|
|
30
|
+
// ---------------------------------------------------------------------------
|
|
31
|
+
|
|
32
|
+
const WSL_PORT = parseInt(process.env.WSL_SESSION_PORT, 10) || 7682
|
|
33
|
+
|
|
34
|
+
function getWslToken() {
|
|
35
|
+
try {
|
|
36
|
+
const tokenPath = path.join(config.HOME_DIR, '.minion', '.wsl-session-token')
|
|
37
|
+
return fs.readFileSync(tokenPath, 'utf-8').trim()
|
|
38
|
+
} catch { return '' }
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Proxy a chat request to the WSL session server's /api/wsl/chat endpoint.
|
|
43
|
+
* Pipes the SSE stream from the WSL server back to the client.
|
|
44
|
+
*/
|
|
45
|
+
function proxyWslChat(res, prompt, sessionId) {
|
|
46
|
+
return new Promise((resolve, reject) => {
|
|
47
|
+
const token = getWslToken()
|
|
48
|
+
const body = JSON.stringify({ prompt, session_id: sessionId })
|
|
49
|
+
const req = http.request({
|
|
50
|
+
hostname: '127.0.0.1',
|
|
51
|
+
port: WSL_PORT,
|
|
52
|
+
path: '/api/wsl/chat',
|
|
53
|
+
method: 'POST',
|
|
54
|
+
headers: {
|
|
55
|
+
'Content-Type': 'application/json',
|
|
56
|
+
'Authorization': `Bearer ${token}`,
|
|
57
|
+
'Content-Length': Buffer.byteLength(body),
|
|
58
|
+
},
|
|
59
|
+
}, (upstream) => {
|
|
60
|
+
if (upstream.statusCode !== 200) {
|
|
61
|
+
const errorEvent = JSON.stringify({ type: 'error', error: `WSL session server returned ${upstream.statusCode}` })
|
|
62
|
+
res.write(`data: ${errorEvent}\n\n`)
|
|
63
|
+
resolve()
|
|
64
|
+
return
|
|
65
|
+
}
|
|
66
|
+
upstream.on('data', (chunk) => { res.write(chunk) })
|
|
67
|
+
upstream.on('end', () => { resolve() })
|
|
68
|
+
upstream.on('error', (err) => {
|
|
69
|
+
res.write(`data: ${JSON.stringify({ type: 'error', error: err.message })}\n\n`)
|
|
70
|
+
resolve()
|
|
71
|
+
})
|
|
72
|
+
})
|
|
73
|
+
req.on('error', (err) => {
|
|
74
|
+
const errorMsg = err.code === 'ECONNREFUSED'
|
|
75
|
+
? 'WSL session server is not running. The target user must be logged in for WSL sessions.'
|
|
76
|
+
: `WSL proxy error: ${err.message}`
|
|
77
|
+
res.write(`data: ${JSON.stringify({ type: 'error', error: errorMsg })}\n\n`)
|
|
78
|
+
resolve()
|
|
79
|
+
})
|
|
80
|
+
req.write(body)
|
|
81
|
+
req.end()
|
|
82
|
+
|
|
83
|
+
// If client disconnects, abort upstream request
|
|
84
|
+
res.on('close', () => { req.destroy() })
|
|
85
|
+
})
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function proxyWslChatAbort() {
|
|
89
|
+
return new Promise((resolve) => {
|
|
90
|
+
const token = getWslToken()
|
|
91
|
+
const req = http.request({
|
|
92
|
+
hostname: '127.0.0.1',
|
|
93
|
+
port: WSL_PORT,
|
|
94
|
+
path: '/api/wsl/chat/abort',
|
|
95
|
+
method: 'POST',
|
|
96
|
+
headers: {
|
|
97
|
+
'Content-Type': 'application/json',
|
|
98
|
+
'Authorization': `Bearer ${token}`,
|
|
99
|
+
},
|
|
100
|
+
}, (res) => {
|
|
101
|
+
let body = ''
|
|
102
|
+
res.on('data', (c) => { body += c })
|
|
103
|
+
res.on('end', () => {
|
|
104
|
+
try { resolve(JSON.parse(body)) } catch { resolve({ success: false }) }
|
|
105
|
+
})
|
|
106
|
+
})
|
|
107
|
+
req.on('error', () => { resolve({ success: false, error: 'WSL session server unreachable' }) })
|
|
108
|
+
req.end('{}')
|
|
109
|
+
})
|
|
110
|
+
}
|
|
25
111
|
|
|
26
112
|
async function chatRoutes(fastify) {
|
|
27
113
|
fastify.post('/api/chat', async (request, reply) => {
|
|
@@ -30,7 +116,7 @@ async function chatRoutes(fastify) {
|
|
|
30
116
|
return { success: false, error: 'Unauthorized' }
|
|
31
117
|
}
|
|
32
118
|
|
|
33
|
-
const { message, session_id, context } = request.body || {}
|
|
119
|
+
const { message, session_id, context, wsl_mode } = request.body || {}
|
|
34
120
|
if (!message || typeof message !== 'string') {
|
|
35
121
|
reply.code(400)
|
|
36
122
|
return { success: false, error: 'message is required' }
|
|
@@ -52,8 +138,16 @@ async function chatRoutes(fastify) {
|
|
|
52
138
|
reply.raw.flushHeaders()
|
|
53
139
|
|
|
54
140
|
try {
|
|
55
|
-
|
|
141
|
+
if (wsl_mode) {
|
|
142
|
+
wslModeActive = true
|
|
143
|
+
console.log('[Chat] WSL mode enabled — proxying to WSL session server')
|
|
144
|
+
await proxyWslChat(reply.raw, prompt, currentSessionId)
|
|
145
|
+
wslModeActive = false
|
|
146
|
+
} else {
|
|
147
|
+
await streamLlmResponse(reply.raw, prompt, currentSessionId)
|
|
148
|
+
}
|
|
56
149
|
} catch (err) {
|
|
150
|
+
wslModeActive = false
|
|
57
151
|
console.error('[Chat] stream error:', err.message)
|
|
58
152
|
const errorEvent = JSON.stringify({ type: 'error', error: err.message })
|
|
59
153
|
reply.raw.write(`data: ${errorEvent}\n\n`)
|
|
@@ -122,6 +216,13 @@ async function chatRoutes(fastify) {
|
|
|
122
216
|
reply.code(401)
|
|
123
217
|
return { success: false, error: 'Unauthorized' }
|
|
124
218
|
}
|
|
219
|
+
|
|
220
|
+
// If WSL mode chat is active, proxy abort to WSL session server
|
|
221
|
+
if (wslModeActive) {
|
|
222
|
+
console.log('[Chat] Aborting WSL mode chat — proxying to WSL session server')
|
|
223
|
+
return proxyWslChatAbort()
|
|
224
|
+
}
|
|
225
|
+
|
|
125
226
|
if (!activeChatChild) {
|
|
126
227
|
return { success: false, error: 'No active chat process' }
|
|
127
228
|
}
|
package/win/routes/terminal.js
CHANGED
|
@@ -8,11 +8,65 @@
|
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
10
|
const path = require('path')
|
|
11
|
+
const fs = require('fs')
|
|
12
|
+
const http = require('http')
|
|
11
13
|
const { verifyToken } = require('../../core/lib/auth')
|
|
12
14
|
const { config } = require('../../core/config')
|
|
13
15
|
const { activeSessions } = require('../workflow-runner')
|
|
14
16
|
|
|
15
17
|
const homeDir = config.HOME_DIR
|
|
18
|
+
const WSL_PORT = parseInt(process.env.WSL_SESSION_PORT, 10) || 7682
|
|
19
|
+
|
|
20
|
+
// ---------------------------------------------------------------------------
|
|
21
|
+
// WSL session server proxy helpers
|
|
22
|
+
// ---------------------------------------------------------------------------
|
|
23
|
+
|
|
24
|
+
function getWslToken() {
|
|
25
|
+
try {
|
|
26
|
+
const tokenPath = path.join(config.HOME_DIR, '.minion', '.wsl-session-token')
|
|
27
|
+
return fs.readFileSync(tokenPath, 'utf-8').trim()
|
|
28
|
+
} catch { return '' }
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Proxy an HTTP request to the WSL session server.
|
|
33
|
+
* Returns parsed JSON response or null on failure.
|
|
34
|
+
*/
|
|
35
|
+
function proxyToWsl(method, urlPath, body) {
|
|
36
|
+
return new Promise((resolve) => {
|
|
37
|
+
const token = getWslToken()
|
|
38
|
+
const bodyStr = body ? JSON.stringify(body) : ''
|
|
39
|
+
const headers = {
|
|
40
|
+
'Authorization': `Bearer ${token}`,
|
|
41
|
+
}
|
|
42
|
+
if (body) {
|
|
43
|
+
headers['Content-Type'] = 'application/json'
|
|
44
|
+
headers['Content-Length'] = Buffer.byteLength(bodyStr)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const req = http.request({
|
|
48
|
+
hostname: '127.0.0.1',
|
|
49
|
+
port: WSL_PORT,
|
|
50
|
+
path: urlPath,
|
|
51
|
+
method,
|
|
52
|
+
headers,
|
|
53
|
+
}, (res) => {
|
|
54
|
+
let data = ''
|
|
55
|
+
res.on('data', (c) => { data += c })
|
|
56
|
+
res.on('end', () => {
|
|
57
|
+
try { resolve(JSON.parse(data)) } catch { resolve(null) }
|
|
58
|
+
})
|
|
59
|
+
})
|
|
60
|
+
req.on('error', () => { resolve(null) })
|
|
61
|
+
if (body) req.write(bodyStr)
|
|
62
|
+
req.end()
|
|
63
|
+
})
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Check if a session name belongs to WSL (wsl- prefix). */
|
|
67
|
+
function isWslSession(name) {
|
|
68
|
+
return name && name.startsWith('wsl-')
|
|
69
|
+
}
|
|
16
70
|
|
|
17
71
|
/**
|
|
18
72
|
* Load node-pty dynamically.
|
|
@@ -100,7 +154,7 @@ function createPtySession(sessionName, command) {
|
|
|
100
154
|
* @param {import('fastify').FastifyInstance} fastify
|
|
101
155
|
*/
|
|
102
156
|
async function terminalRoutes(fastify) {
|
|
103
|
-
// List sessions
|
|
157
|
+
// List sessions (merges local CMD sessions + WSL sessions from wsl-session-server)
|
|
104
158
|
fastify.get('/api/terminal/sessions', async (request, reply) => {
|
|
105
159
|
if (!verifyToken(request)) {
|
|
106
160
|
reply.code(401)
|
|
@@ -111,6 +165,7 @@ async function terminalRoutes(fastify) {
|
|
|
111
165
|
for (const [name, session] of activeSessions) {
|
|
112
166
|
sessions.push({
|
|
113
167
|
name,
|
|
168
|
+
type: 'cmd',
|
|
114
169
|
attached: false,
|
|
115
170
|
completed: session.completed,
|
|
116
171
|
exit_code: session.exitCode,
|
|
@@ -118,6 +173,14 @@ async function terminalRoutes(fastify) {
|
|
|
118
173
|
})
|
|
119
174
|
}
|
|
120
175
|
|
|
176
|
+
// Merge WSL sessions (best-effort, skip on failure)
|
|
177
|
+
const wslResult = await proxyToWsl('GET', '/api/wsl/sessions')
|
|
178
|
+
if (wslResult && wslResult.success && wslResult.sessions) {
|
|
179
|
+
for (const s of wslResult.sessions) {
|
|
180
|
+
sessions.push({ ...s, type: 'wsl' })
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
121
184
|
console.log(`[Terminal] Found ${sessions.length} session(s): ${sessions.map(s => s.name).join(', ') || '(none)'}`)
|
|
122
185
|
return { success: true, sessions }
|
|
123
186
|
})
|
|
@@ -139,7 +202,15 @@ async function terminalRoutes(fastify) {
|
|
|
139
202
|
return { success: false, error: 'Invalid session name' }
|
|
140
203
|
}
|
|
141
204
|
|
|
205
|
+
// Proxy to WSL server if session is a WSL session not found locally
|
|
142
206
|
const session = activeSessions.get(sessionName)
|
|
207
|
+
if (!session && isWslSession(sessionName)) {
|
|
208
|
+
const result = await proxyToWsl('GET', `/api/wsl/capture?session=${encodeURIComponent(sessionName)}&lines=${lines}`)
|
|
209
|
+
if (!result) { reply.code(503); return { success: false, error: 'WSL session server unreachable' } }
|
|
210
|
+
reply.code(result.success ? 200 : 404)
|
|
211
|
+
return result
|
|
212
|
+
}
|
|
213
|
+
|
|
143
214
|
if (!session) {
|
|
144
215
|
reply.code(404)
|
|
145
216
|
return { success: false, error: `Session '${sessionName}' not found` }
|
|
@@ -179,7 +250,15 @@ async function terminalRoutes(fastify) {
|
|
|
179
250
|
return { success: false, error: 'input or special key is required' }
|
|
180
251
|
}
|
|
181
252
|
|
|
253
|
+
// Proxy to WSL server if session is a WSL session not found locally
|
|
182
254
|
const session = activeSessions.get(sessionName)
|
|
255
|
+
if (!session && isWslSession(sessionName)) {
|
|
256
|
+
const result = await proxyToWsl('POST', '/api/wsl/send', { session: sessionName, input, enter, special })
|
|
257
|
+
if (!result) { reply.code(503); return { success: false, error: 'WSL session server unreachable' } }
|
|
258
|
+
reply.code(result.success ? 200 : 400)
|
|
259
|
+
return result
|
|
260
|
+
}
|
|
261
|
+
|
|
183
262
|
if (!session || !session.pty) {
|
|
184
263
|
reply.code(404)
|
|
185
264
|
return { success: false, error: `Session '${sessionName}' not found` }
|
|
@@ -224,14 +303,29 @@ async function terminalRoutes(fastify) {
|
|
|
224
303
|
}
|
|
225
304
|
})
|
|
226
305
|
|
|
227
|
-
// Create a new session
|
|
306
|
+
// Create a new session (supports type: 'wsl' for WSL sessions)
|
|
228
307
|
fastify.post('/api/terminal/create', async (request, reply) => {
|
|
229
308
|
if (!verifyToken(request)) {
|
|
230
309
|
reply.code(401)
|
|
231
310
|
return { success: false, error: 'Unauthorized' }
|
|
232
311
|
}
|
|
233
312
|
|
|
234
|
-
const { name, command } = request.body || {}
|
|
313
|
+
const { name, command, type } = request.body || {}
|
|
314
|
+
|
|
315
|
+
// WSL session: proxy to wsl-session-server
|
|
316
|
+
if (type === 'wsl') {
|
|
317
|
+
const sessionName = name || `wsl-session-${Date.now()}`
|
|
318
|
+
const wslName = sessionName.startsWith('wsl-') ? sessionName : `wsl-${sessionName}`
|
|
319
|
+
console.log(`[Terminal] Creating WSL session '${wslName}' — proxying to WSL server`)
|
|
320
|
+
const result = await proxyToWsl('POST', '/api/wsl/create', { name: wslName, command })
|
|
321
|
+
if (!result) {
|
|
322
|
+
reply.code(503)
|
|
323
|
+
return { success: false, error: 'WSL session server is not running. The target user must be logged in for WSL sessions.' }
|
|
324
|
+
}
|
|
325
|
+
reply.code(result.success ? 200 : 500)
|
|
326
|
+
return result
|
|
327
|
+
}
|
|
328
|
+
|
|
235
329
|
const sessionName = name || `session-${Date.now()}`
|
|
236
330
|
|
|
237
331
|
if (!/^[\w-]+$/.test(sessionName)) {
|
|
@@ -272,7 +366,15 @@ async function terminalRoutes(fastify) {
|
|
|
272
366
|
return { success: false, error: 'Invalid session name' }
|
|
273
367
|
}
|
|
274
368
|
|
|
369
|
+
// Proxy to WSL server if session is a WSL session not found locally
|
|
275
370
|
const session = activeSessions.get(sessionName)
|
|
371
|
+
if (!session && isWslSession(sessionName)) {
|
|
372
|
+
const result = await proxyToWsl('POST', '/api/wsl/kill', { session: sessionName })
|
|
373
|
+
if (!result) { reply.code(503); return { success: false, error: 'WSL session server unreachable' } }
|
|
374
|
+
reply.code(result.success ? 200 : 404)
|
|
375
|
+
return result
|
|
376
|
+
}
|
|
377
|
+
|
|
276
378
|
if (!session) {
|
|
277
379
|
reply.code(404)
|
|
278
380
|
return { success: false, error: `Session '${sessionName}' not found` }
|
|
@@ -315,6 +417,22 @@ async function terminalRoutes(fastify) {
|
|
|
315
417
|
active_sessions: sessions,
|
|
316
418
|
}
|
|
317
419
|
})
|
|
420
|
+
|
|
421
|
+
// WSL session server status
|
|
422
|
+
fastify.get('/api/terminal/wsl/status', async (request, reply) => {
|
|
423
|
+
if (!verifyToken(request)) {
|
|
424
|
+
reply.code(401)
|
|
425
|
+
return { success: false, error: 'Unauthorized' }
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
const health = await proxyToWsl('GET', '/api/wsl/health')
|
|
429
|
+
return {
|
|
430
|
+
success: true,
|
|
431
|
+
available: !!getWslToken(), // token exists = WSL was configured during setup
|
|
432
|
+
running: !!(health && health.success),
|
|
433
|
+
server_pid: health?.pid || null,
|
|
434
|
+
}
|
|
435
|
+
})
|
|
318
436
|
}
|
|
319
437
|
|
|
320
438
|
function cleanupSessions() {
|
package/win/server.js
CHANGED
|
@@ -284,6 +284,28 @@ async function start() {
|
|
|
284
284
|
console.error('[Server] Terminal WebSocket connections will not work')
|
|
285
285
|
}
|
|
286
286
|
|
|
287
|
+
// Check WSL session server availability (best-effort)
|
|
288
|
+
try {
|
|
289
|
+
const http = require('http')
|
|
290
|
+
const wslPort = parseInt(process.env.WSL_SESSION_PORT, 10) || 7682
|
|
291
|
+
const wslCheck = await new Promise((resolve) => {
|
|
292
|
+
const req = http.get(`http://127.0.0.1:${wslPort}/api/wsl/health`, { timeout: 2000 }, (res) => {
|
|
293
|
+
let body = ''
|
|
294
|
+
res.on('data', (c) => { body += c })
|
|
295
|
+
res.on('end', () => { try { resolve(JSON.parse(body)) } catch { resolve(null) } })
|
|
296
|
+
})
|
|
297
|
+
req.on('error', () => resolve(null))
|
|
298
|
+
req.on('timeout', () => { req.destroy(); resolve(null) })
|
|
299
|
+
})
|
|
300
|
+
if (wslCheck && wslCheck.success) {
|
|
301
|
+
console.log(`[Server] WSL session server is running (PID: ${wslCheck.pid}, port: ${wslPort})`)
|
|
302
|
+
} else {
|
|
303
|
+
console.log('[Server] WSL session server is not running (user may not be logged in)')
|
|
304
|
+
}
|
|
305
|
+
} catch {
|
|
306
|
+
console.log('[Server] WSL session server check skipped')
|
|
307
|
+
}
|
|
308
|
+
|
|
287
309
|
// Load cached workflows
|
|
288
310
|
try {
|
|
289
311
|
const cachedWorkflows = await workflowStore.load()
|
package/win/terminal-server.js
CHANGED
|
@@ -12,16 +12,24 @@
|
|
|
12
12
|
* Listens on port 7681, URL: /ws/{sessionName}
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
|
+
const fs = require('fs')
|
|
15
16
|
const http = require('http')
|
|
16
17
|
const path = require('path')
|
|
17
18
|
const { config } = require('../core/config')
|
|
18
19
|
const { activeSessions } = require('./workflow-runner')
|
|
19
20
|
|
|
20
21
|
const PROXY_PORT = 7681
|
|
22
|
+
const WSL_WS_PORT = (parseInt(process.env.WSL_SESSION_PORT, 10) || 7682) + 1 // 7683
|
|
21
23
|
|
|
22
24
|
let wss = null
|
|
23
25
|
let httpServer = null
|
|
24
26
|
|
|
27
|
+
function getWslToken() {
|
|
28
|
+
try {
|
|
29
|
+
return fs.readFileSync(path.join(config.HOME_DIR, '.minion', '.wsl-session-token'), 'utf-8').trim()
|
|
30
|
+
} catch { return '' }
|
|
31
|
+
}
|
|
32
|
+
|
|
25
33
|
// ttyd message types (ASCII character codes, matching ttyd wire protocol)
|
|
26
34
|
const MSG_INPUT = 0x30 // '0'
|
|
27
35
|
const MSG_RESIZE = 0x31 // '1'
|
|
@@ -151,6 +159,12 @@ function startTerminalServer() {
|
|
|
151
159
|
return
|
|
152
160
|
}
|
|
153
161
|
|
|
162
|
+
// WSL sessions: proxy to wsl-session-server's WebSocket
|
|
163
|
+
if (sessionName.startsWith('wsl-')) {
|
|
164
|
+
proxyToWslWebSocket(ws, sessionName, WebSocket)
|
|
165
|
+
return
|
|
166
|
+
}
|
|
167
|
+
|
|
154
168
|
let session
|
|
155
169
|
try {
|
|
156
170
|
session = ensureSession(sessionName)
|
|
@@ -225,6 +239,54 @@ function startTerminalServer() {
|
|
|
225
239
|
})
|
|
226
240
|
}
|
|
227
241
|
|
|
242
|
+
/**
|
|
243
|
+
* Proxy a WebSocket connection to the WSL session server.
|
|
244
|
+
* Bidirectional pipe: HQ client <-> minion-agent:7681 <-> wsl-session-server:7683
|
|
245
|
+
*/
|
|
246
|
+
function proxyToWslWebSocket(clientWs, sessionName, WebSocket) {
|
|
247
|
+
const token = getWslToken()
|
|
248
|
+
const upstreamUrl = `ws://127.0.0.1:${WSL_WS_PORT}/ws/${sessionName}?token=${encodeURIComponent(token)}`
|
|
249
|
+
|
|
250
|
+
console.log(`[TerminalServer] Proxying WSL session '${sessionName}' to ${upstreamUrl}`)
|
|
251
|
+
|
|
252
|
+
const upstream = new WebSocket(upstreamUrl)
|
|
253
|
+
|
|
254
|
+
upstream.on('open', () => {
|
|
255
|
+
console.log(`[TerminalServer] WSL proxy connected for '${sessionName}'`)
|
|
256
|
+
})
|
|
257
|
+
|
|
258
|
+
// Forward data from WSL server to HQ client
|
|
259
|
+
upstream.on('message', (data) => {
|
|
260
|
+
try { if (clientWs.readyState === 1) clientWs.send(data) } catch {}
|
|
261
|
+
})
|
|
262
|
+
|
|
263
|
+
// Forward data from HQ client to WSL server
|
|
264
|
+
clientWs.on('message', (data) => {
|
|
265
|
+
try { if (upstream.readyState === 1) upstream.send(data) } catch {}
|
|
266
|
+
})
|
|
267
|
+
|
|
268
|
+
// Close handling
|
|
269
|
+
upstream.on('close', () => {
|
|
270
|
+
console.log(`[TerminalServer] WSL proxy upstream closed for '${sessionName}'`)
|
|
271
|
+
try { clientWs.close() } catch {}
|
|
272
|
+
})
|
|
273
|
+
|
|
274
|
+
clientWs.on('close', () => {
|
|
275
|
+
console.log(`[TerminalServer] WSL proxy client disconnected for '${sessionName}'`)
|
|
276
|
+
try { upstream.close() } catch {}
|
|
277
|
+
})
|
|
278
|
+
|
|
279
|
+
upstream.on('error', (err) => {
|
|
280
|
+
console.error(`[TerminalServer] WSL proxy error for '${sessionName}': ${err.message}`)
|
|
281
|
+
try { clientWs.close(1011, 'WSL session server unreachable') } catch {}
|
|
282
|
+
})
|
|
283
|
+
|
|
284
|
+
clientWs.on('error', (err) => {
|
|
285
|
+
console.error(`[TerminalServer] WSL proxy client error for '${sessionName}': ${err.message}`)
|
|
286
|
+
try { upstream.close() } catch {}
|
|
287
|
+
})
|
|
288
|
+
}
|
|
289
|
+
|
|
228
290
|
/**
|
|
229
291
|
* Stop the WebSocket terminal server.
|
|
230
292
|
*/
|
|
@@ -0,0 +1,503 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WSL Session Server
|
|
3
|
+
*
|
|
4
|
+
* Standalone Fastify + WebSocket server that runs in the target user's
|
|
5
|
+
* interactive session (via schtasks ONLOGON). Because it runs under
|
|
6
|
+
* the real user account (not LocalSystem), WSL commands work natively.
|
|
7
|
+
*
|
|
8
|
+
* minion-agent (LocalSystem, Session 0) proxies requests here for:
|
|
9
|
+
* - WSL terminal sessions (node-pty with wsl.exe)
|
|
10
|
+
* - WSL-mode chat (Claude Code CLI spawned in user context)
|
|
11
|
+
*
|
|
12
|
+
* Port: 7682 (configurable via WSL_SESSION_PORT)
|
|
13
|
+
* Auth: shared Bearer token from .minion/.wsl-session-token
|
|
14
|
+
* Protocol: ttyd binary (WebSocket), SSE (chat), JSON (HTTP API)
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
const fs = require('fs')
|
|
18
|
+
const path = require('path')
|
|
19
|
+
const http = require('http')
|
|
20
|
+
const { spawn } = require('child_process')
|
|
21
|
+
|
|
22
|
+
// ---------------------------------------------------------------------------
|
|
23
|
+
// Configuration
|
|
24
|
+
// ---------------------------------------------------------------------------
|
|
25
|
+
|
|
26
|
+
const WSL_PORT = parseInt(process.env.WSL_SESSION_PORT, 10) || 7682
|
|
27
|
+
const HOME_DIR = process.env.HOME || process.env.USERPROFILE || ''
|
|
28
|
+
const DATA_DIR = path.join(HOME_DIR, '.minion')
|
|
29
|
+
const TOKEN_FILE = path.join(DATA_DIR, '.wsl-session-token')
|
|
30
|
+
const PID_FILE = path.join(DATA_DIR, '.wsl-session.pid')
|
|
31
|
+
|
|
32
|
+
let AUTH_TOKEN = ''
|
|
33
|
+
try {
|
|
34
|
+
AUTH_TOKEN = fs.readFileSync(TOKEN_FILE, 'utf-8').trim()
|
|
35
|
+
} catch {
|
|
36
|
+
console.error('[WSL] WARNING: Could not read auth token from', TOKEN_FILE)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// ---------------------------------------------------------------------------
|
|
40
|
+
// Auth helper
|
|
41
|
+
// ---------------------------------------------------------------------------
|
|
42
|
+
|
|
43
|
+
function verifyToken(request) {
|
|
44
|
+
if (!AUTH_TOKEN) return true // no token = open (development)
|
|
45
|
+
const header = request.headers.authorization || ''
|
|
46
|
+
const token = header.startsWith('Bearer ') ? header.slice(7) : ''
|
|
47
|
+
return token === AUTH_TOKEN
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// ---------------------------------------------------------------------------
|
|
51
|
+
// node-pty loader
|
|
52
|
+
// ---------------------------------------------------------------------------
|
|
53
|
+
|
|
54
|
+
function loadNodePty() {
|
|
55
|
+
try { return require('node-pty-prebuilt-multiarch') } catch {}
|
|
56
|
+
try { return require('node-pty') } catch {}
|
|
57
|
+
return null
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// ---------------------------------------------------------------------------
|
|
61
|
+
// Terminal sessions
|
|
62
|
+
// ---------------------------------------------------------------------------
|
|
63
|
+
|
|
64
|
+
const activeSessions = new Map()
|
|
65
|
+
|
|
66
|
+
// ttyd message types (ASCII character codes)
|
|
67
|
+
const MSG_INPUT = 0x30 // '0'
|
|
68
|
+
const MSG_RESIZE = 0x31 // '1'
|
|
69
|
+
const MSG_OUTPUT = 0x30 // '0'
|
|
70
|
+
const MSG_SET_TITLE = 0x31 // '1'
|
|
71
|
+
|
|
72
|
+
function createWslSession(sessionName, command) {
|
|
73
|
+
const pty = loadNodePty()
|
|
74
|
+
if (!pty) throw new Error('node-pty is not installed')
|
|
75
|
+
|
|
76
|
+
const shell = 'wsl.exe'
|
|
77
|
+
const shellArgs = command ? ['-e', 'bash', '-c', command] : []
|
|
78
|
+
|
|
79
|
+
const ptyProcess = pty.spawn(shell, shellArgs, {
|
|
80
|
+
name: 'xterm-256color',
|
|
81
|
+
cols: 120,
|
|
82
|
+
rows: 30,
|
|
83
|
+
cwd: HOME_DIR,
|
|
84
|
+
env: process.env,
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
const session = {
|
|
88
|
+
pty: ptyProcess,
|
|
89
|
+
buffer: '',
|
|
90
|
+
completed: false,
|
|
91
|
+
exitCode: null,
|
|
92
|
+
startedAt: new Date().toISOString(),
|
|
93
|
+
wsClients: new Set(),
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
ptyProcess.onData((data) => {
|
|
97
|
+
session.buffer += data
|
|
98
|
+
if (session.buffer.length > 1024 * 1024) {
|
|
99
|
+
session.buffer = session.buffer.slice(-512 * 1024)
|
|
100
|
+
}
|
|
101
|
+
const outBuf = Buffer.alloc(1 + Buffer.byteLength(data))
|
|
102
|
+
outBuf[0] = MSG_OUTPUT
|
|
103
|
+
outBuf.write(data, 1)
|
|
104
|
+
for (const ws of session.wsClients) {
|
|
105
|
+
try { if (ws.readyState === 1) ws.send(outBuf) } catch {}
|
|
106
|
+
}
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
ptyProcess.onExit(({ exitCode }) => {
|
|
110
|
+
session.completed = true
|
|
111
|
+
session.exitCode = exitCode
|
|
112
|
+
for (const ws of session.wsClients) {
|
|
113
|
+
try { ws.close() } catch {}
|
|
114
|
+
}
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
activeSessions.set(sessionName, session)
|
|
118
|
+
console.log(`[WSL] Created session '${sessionName}' (PID: ${ptyProcess.pid})`)
|
|
119
|
+
return session
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const specialKeyMap = {
|
|
123
|
+
'Enter': '\r', 'Escape': '\x1b', 'Tab': '\t',
|
|
124
|
+
'C-c': '\x03', 'C-d': '\x04', 'C-z': '\x1a',
|
|
125
|
+
'Up': '\x1b[A', 'Down': '\x1b[B', 'Left': '\x1b[D', 'Right': '\x1b[C',
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// ---------------------------------------------------------------------------
|
|
129
|
+
// Chat (Claude Code CLI spawn in user context)
|
|
130
|
+
// ---------------------------------------------------------------------------
|
|
131
|
+
|
|
132
|
+
let activeChatChild = null
|
|
133
|
+
|
|
134
|
+
function getLlmBinary() {
|
|
135
|
+
const cmd = process.env.LLM_COMMAND
|
|
136
|
+
if (!cmd) return null
|
|
137
|
+
return cmd.split(/\s+/)[0]
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Stream LLM CLI output as SSE events.
|
|
142
|
+
* Same logic as win/routes/chat.js streamLlmResponse(), but runs in user session.
|
|
143
|
+
*/
|
|
144
|
+
function streamLlmResponse(res, prompt, sessionId) {
|
|
145
|
+
return new Promise((resolve, reject) => {
|
|
146
|
+
const binaryName = getLlmBinary()
|
|
147
|
+
if (!binaryName) {
|
|
148
|
+
reject(new Error('LLM_COMMAND is not configured'))
|
|
149
|
+
return
|
|
150
|
+
}
|
|
151
|
+
const binaryPath = path.join(HOME_DIR, '.local', 'bin', binaryName)
|
|
152
|
+
const binary = fs.existsSync(binaryPath) ? binaryPath : binaryName
|
|
153
|
+
|
|
154
|
+
const args = ['-p', '--verbose', '--model', 'sonnet', '--output-format', 'stream-json']
|
|
155
|
+
if (sessionId) args.push('--resume', sessionId)
|
|
156
|
+
|
|
157
|
+
console.log(`[WSL-Chat] spawning: ${binary} ${sessionId ? `--resume ${sessionId}` : '(new)'}`)
|
|
158
|
+
|
|
159
|
+
const child = spawn(binary, args, {
|
|
160
|
+
cwd: HOME_DIR,
|
|
161
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
162
|
+
timeout: 600000,
|
|
163
|
+
shell: true,
|
|
164
|
+
})
|
|
165
|
+
|
|
166
|
+
activeChatChild = child
|
|
167
|
+
child.stdin.write(prompt)
|
|
168
|
+
child.stdin.end()
|
|
169
|
+
|
|
170
|
+
console.log(`[WSL-Chat] child PID: ${child.pid}`)
|
|
171
|
+
|
|
172
|
+
let lineBuffer = ''
|
|
173
|
+
let currentBlockType = null
|
|
174
|
+
let currentToolName = null
|
|
175
|
+
let toolInputBuffer = ''
|
|
176
|
+
|
|
177
|
+
child.stdout.on('data', (data) => {
|
|
178
|
+
lineBuffer += data.toString()
|
|
179
|
+
const parts = lineBuffer.split('\n')
|
|
180
|
+
lineBuffer = parts.pop() || ''
|
|
181
|
+
|
|
182
|
+
for (const line of parts) {
|
|
183
|
+
if (!line.trim()) continue
|
|
184
|
+
// Forward raw JSON lines as SSE — minion-agent handles parsing
|
|
185
|
+
res.write(`data: ${line}\n\n`)
|
|
186
|
+
}
|
|
187
|
+
})
|
|
188
|
+
|
|
189
|
+
child.stderr.on('data', (data) => {
|
|
190
|
+
console.error(`[WSL-Chat] stderr: ${data.toString()}`)
|
|
191
|
+
})
|
|
192
|
+
|
|
193
|
+
child.on('close', (code) => {
|
|
194
|
+
activeChatChild = null
|
|
195
|
+
// Flush remaining buffer
|
|
196
|
+
if (lineBuffer.trim()) {
|
|
197
|
+
res.write(`data: ${lineBuffer}\n\n`)
|
|
198
|
+
}
|
|
199
|
+
res.write(`data: ${JSON.stringify({ type: 'wsl_chat_done', exit_code: code })}\n\n`)
|
|
200
|
+
resolve()
|
|
201
|
+
})
|
|
202
|
+
|
|
203
|
+
child.on('error', (err) => {
|
|
204
|
+
activeChatChild = null
|
|
205
|
+
res.write(`data: ${JSON.stringify({ type: 'error', error: `Failed to start CLI: ${err.message}` })}\n\n`)
|
|
206
|
+
reject(err)
|
|
207
|
+
})
|
|
208
|
+
|
|
209
|
+
res.on('close', () => {
|
|
210
|
+
if (child && !child.killed) child.kill('SIGTERM')
|
|
211
|
+
})
|
|
212
|
+
})
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// ---------------------------------------------------------------------------
|
|
216
|
+
// Fastify HTTP server
|
|
217
|
+
// ---------------------------------------------------------------------------
|
|
218
|
+
|
|
219
|
+
async function startServer() {
|
|
220
|
+
const fastify = require('fastify')({ logger: true })
|
|
221
|
+
|
|
222
|
+
// --- Health (no auth) ---
|
|
223
|
+
fastify.get('/api/wsl/health', async () => {
|
|
224
|
+
return { success: true, service: 'wsl-session-server', pid: process.pid }
|
|
225
|
+
})
|
|
226
|
+
|
|
227
|
+
// --- Terminal: list sessions ---
|
|
228
|
+
fastify.get('/api/wsl/sessions', async (request, reply) => {
|
|
229
|
+
if (!verifyToken(request)) { reply.code(401); return { success: false, error: 'Unauthorized' } }
|
|
230
|
+
const sessions = []
|
|
231
|
+
for (const [name, session] of activeSessions) {
|
|
232
|
+
sessions.push({
|
|
233
|
+
name,
|
|
234
|
+
type: 'wsl',
|
|
235
|
+
completed: session.completed,
|
|
236
|
+
exit_code: session.exitCode,
|
|
237
|
+
started_at: session.startedAt,
|
|
238
|
+
})
|
|
239
|
+
}
|
|
240
|
+
return { success: true, sessions }
|
|
241
|
+
})
|
|
242
|
+
|
|
243
|
+
// --- Terminal: create session ---
|
|
244
|
+
fastify.post('/api/wsl/create', async (request, reply) => {
|
|
245
|
+
if (!verifyToken(request)) { reply.code(401); return { success: false, error: 'Unauthorized' } }
|
|
246
|
+
const { name, command } = request.body || {}
|
|
247
|
+
const sessionName = name || `wsl-session-${Date.now()}`
|
|
248
|
+
|
|
249
|
+
if (!/^[\w-]+$/.test(sessionName)) {
|
|
250
|
+
reply.code(400); return { success: false, error: 'Invalid session name' }
|
|
251
|
+
}
|
|
252
|
+
if (activeSessions.has(sessionName)) {
|
|
253
|
+
reply.code(409); return { success: false, error: `Session '${sessionName}' already exists` }
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
try {
|
|
257
|
+
createWslSession(sessionName, command)
|
|
258
|
+
return { success: true, session: sessionName, message: `WSL session '${sessionName}' created` }
|
|
259
|
+
} catch (err) {
|
|
260
|
+
reply.code(500); return { success: false, error: err.message }
|
|
261
|
+
}
|
|
262
|
+
})
|
|
263
|
+
|
|
264
|
+
// --- Terminal: send input ---
|
|
265
|
+
fastify.post('/api/wsl/send', async (request, reply) => {
|
|
266
|
+
if (!verifyToken(request)) { reply.code(401); return { success: false, error: 'Unauthorized' } }
|
|
267
|
+
const { session: sessionName, input, enter = false, special } = request.body || {}
|
|
268
|
+
if (!sessionName) { reply.code(400); return { success: false, error: 'session is required' } }
|
|
269
|
+
if (!input && !special) { reply.code(400); return { success: false, error: 'input or special key is required' } }
|
|
270
|
+
|
|
271
|
+
const session = activeSessions.get(sessionName)
|
|
272
|
+
if (!session || !session.pty) { reply.code(404); return { success: false, error: `Session '${sessionName}' not found` } }
|
|
273
|
+
if (session.completed) { reply.code(400); return { success: false, error: `Session '${sessionName}' has already exited` } }
|
|
274
|
+
|
|
275
|
+
try {
|
|
276
|
+
if (special) {
|
|
277
|
+
const key = specialKeyMap[special]
|
|
278
|
+
if (!key) { reply.code(400); return { success: false, error: `Invalid special key. Allowed: ${Object.keys(specialKeyMap).join(', ')}` } }
|
|
279
|
+
session.pty.write(key)
|
|
280
|
+
} else {
|
|
281
|
+
session.pty.write(input)
|
|
282
|
+
if (enter) session.pty.write('\r')
|
|
283
|
+
}
|
|
284
|
+
return { success: true, session: sessionName }
|
|
285
|
+
} catch (err) {
|
|
286
|
+
reply.code(500); return { success: false, error: err.message }
|
|
287
|
+
}
|
|
288
|
+
})
|
|
289
|
+
|
|
290
|
+
// --- Terminal: kill session ---
|
|
291
|
+
fastify.post('/api/wsl/kill', async (request, reply) => {
|
|
292
|
+
if (!verifyToken(request)) { reply.code(401); return { success: false, error: 'Unauthorized' } }
|
|
293
|
+
const { session: sessionName } = request.body || {}
|
|
294
|
+
if (!sessionName) { reply.code(400); return { success: false, error: 'session is required' } }
|
|
295
|
+
|
|
296
|
+
const session = activeSessions.get(sessionName)
|
|
297
|
+
if (!session) { reply.code(404); return { success: false, error: `Session '${sessionName}' not found` } }
|
|
298
|
+
|
|
299
|
+
try {
|
|
300
|
+
if (session.pty && !session.completed) session.pty.kill()
|
|
301
|
+
activeSessions.delete(sessionName)
|
|
302
|
+
return { success: true, session: sessionName, message: `Session '${sessionName}' terminated` }
|
|
303
|
+
} catch (err) {
|
|
304
|
+
reply.code(500); return { success: false, error: err.message }
|
|
305
|
+
}
|
|
306
|
+
})
|
|
307
|
+
|
|
308
|
+
// --- Terminal: capture buffer ---
|
|
309
|
+
fastify.get('/api/wsl/capture', async (request, reply) => {
|
|
310
|
+
if (!verifyToken(request)) { reply.code(401); return { success: false, error: 'Unauthorized' } }
|
|
311
|
+
const { session: sessionName, lines = '100' } = request.query || {}
|
|
312
|
+
if (!sessionName) { reply.code(400); return { success: false, error: 'session parameter is required' } }
|
|
313
|
+
|
|
314
|
+
const session = activeSessions.get(sessionName)
|
|
315
|
+
if (!session) { reply.code(404); return { success: false, error: `Session '${sessionName}' not found` } }
|
|
316
|
+
|
|
317
|
+
const lineCount = Math.min(Math.max(parseInt(lines) || 100, 1), 1000)
|
|
318
|
+
const allLines = session.buffer.split('\n')
|
|
319
|
+
const content = allLines.slice(-lineCount).join('\n')
|
|
320
|
+
|
|
321
|
+
return { success: true, session: sessionName, content, lines: lineCount, timestamp: Date.now() }
|
|
322
|
+
})
|
|
323
|
+
|
|
324
|
+
// --- Chat: SSE stream ---
|
|
325
|
+
fastify.post('/api/wsl/chat', async (request, reply) => {
|
|
326
|
+
if (!verifyToken(request)) { reply.code(401); return { success: false, error: 'Unauthorized' } }
|
|
327
|
+
const { prompt, session_id } = request.body || {}
|
|
328
|
+
if (!prompt) { reply.code(400); return { success: false, error: 'prompt is required' } }
|
|
329
|
+
|
|
330
|
+
reply.hijack()
|
|
331
|
+
reply.raw.writeHead(200, {
|
|
332
|
+
'Content-Type': 'text/event-stream',
|
|
333
|
+
'Cache-Control': 'no-cache',
|
|
334
|
+
'Connection': 'keep-alive',
|
|
335
|
+
})
|
|
336
|
+
reply.raw.flushHeaders()
|
|
337
|
+
|
|
338
|
+
try {
|
|
339
|
+
await streamLlmResponse(reply.raw, prompt, session_id)
|
|
340
|
+
} catch (err) {
|
|
341
|
+
console.error('[WSL-Chat] stream error:', err.message)
|
|
342
|
+
reply.raw.write(`data: ${JSON.stringify({ type: 'error', error: err.message })}\n\n`)
|
|
343
|
+
}
|
|
344
|
+
reply.raw.end()
|
|
345
|
+
})
|
|
346
|
+
|
|
347
|
+
// --- Chat: abort ---
|
|
348
|
+
fastify.post('/api/wsl/chat/abort', async (request, reply) => {
|
|
349
|
+
if (!verifyToken(request)) { reply.code(401); return { success: false, error: 'Unauthorized' } }
|
|
350
|
+
if (!activeChatChild) {
|
|
351
|
+
return { success: false, error: 'No active WSL chat process' }
|
|
352
|
+
}
|
|
353
|
+
console.log(`[WSL-Chat] Aborting PID: ${activeChatChild.pid}`)
|
|
354
|
+
activeChatChild.kill('SIGTERM')
|
|
355
|
+
const pid = activeChatChild.pid
|
|
356
|
+
setTimeout(() => {
|
|
357
|
+
try {
|
|
358
|
+
if (activeChatChild && activeChatChild.pid === pid) activeChatChild.kill('SIGKILL')
|
|
359
|
+
} catch {}
|
|
360
|
+
}, 2000)
|
|
361
|
+
return { success: true }
|
|
362
|
+
})
|
|
363
|
+
|
|
364
|
+
// --- Start HTTP + WebSocket ---
|
|
365
|
+
await fastify.listen({ port: WSL_PORT, host: '127.0.0.1' })
|
|
366
|
+
console.log(`[WSL] HTTP server listening on 127.0.0.1:${WSL_PORT}`)
|
|
367
|
+
|
|
368
|
+
// WebSocket server on a separate HTTP server (same port pattern as terminal-server.js)
|
|
369
|
+
startWebSocketServer()
|
|
370
|
+
|
|
371
|
+
// Write PID file
|
|
372
|
+
try {
|
|
373
|
+
fs.writeFileSync(PID_FILE, String(process.pid))
|
|
374
|
+
} catch {}
|
|
375
|
+
|
|
376
|
+
return fastify
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
// ---------------------------------------------------------------------------
|
|
380
|
+
// WebSocket terminal server (ttyd protocol)
|
|
381
|
+
// ---------------------------------------------------------------------------
|
|
382
|
+
|
|
383
|
+
function startWebSocketServer() {
|
|
384
|
+
let WebSocket
|
|
385
|
+
try { WebSocket = require('ws') } catch {
|
|
386
|
+
console.error('[WSL] ws module not installed, WebSocket terminal disabled')
|
|
387
|
+
return
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
// Use a secondary HTTP server for WebSocket on WSL_PORT + 1 (7683)
|
|
391
|
+
// to avoid conflicts with the Fastify server on WSL_PORT.
|
|
392
|
+
// Alternatively, minion-agent's terminal-server.js proxies to us on WSL_PORT WS.
|
|
393
|
+
// For simplicity, we attach WS to the same port via a raw HTTP server.
|
|
394
|
+
const wsPort = WSL_PORT + 1
|
|
395
|
+
const httpServer = http.createServer((req, res) => {
|
|
396
|
+
res.writeHead(426, { 'Content-Type': 'text/plain' })
|
|
397
|
+
res.end('WebSocket connections only')
|
|
398
|
+
})
|
|
399
|
+
|
|
400
|
+
const wss = new WebSocket.Server({ server: httpServer })
|
|
401
|
+
|
|
402
|
+
wss.on('connection', (ws, req) => {
|
|
403
|
+
// Verify token from query string
|
|
404
|
+
const url = new URL(req.url, `http://localhost:${wsPort}`)
|
|
405
|
+
const token = url.searchParams.get('token') || ''
|
|
406
|
+
if (AUTH_TOKEN && token !== AUTH_TOKEN) {
|
|
407
|
+
ws.close(1008, 'Unauthorized')
|
|
408
|
+
return
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
const sessionMatch = req.url?.match(/\/ws\/([^/?]+)/)
|
|
412
|
+
const sessionName = sessionMatch ? sessionMatch[1] : null
|
|
413
|
+
|
|
414
|
+
if (!sessionName || !/^[\w-]+$/.test(sessionName)) {
|
|
415
|
+
ws.close(1008, 'Invalid session name')
|
|
416
|
+
return
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
let session = activeSessions.get(sessionName)
|
|
420
|
+
if (!session || session.completed) {
|
|
421
|
+
// Auto-create WSL session
|
|
422
|
+
try {
|
|
423
|
+
session = createWslSession(sessionName)
|
|
424
|
+
} catch (err) {
|
|
425
|
+
ws.close(1011, err.message)
|
|
426
|
+
return
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
session.wsClients.add(ws)
|
|
431
|
+
|
|
432
|
+
// Send title
|
|
433
|
+
const titleMsg = Buffer.from([MSG_SET_TITLE, ...Buffer.from(JSON.stringify({ title: sessionName }))])
|
|
434
|
+
ws.send(titleMsg)
|
|
435
|
+
|
|
436
|
+
// Replay buffer
|
|
437
|
+
if (session.buffer.length > 0) {
|
|
438
|
+
const outBuf = Buffer.alloc(1 + Buffer.byteLength(session.buffer))
|
|
439
|
+
outBuf[0] = MSG_OUTPUT
|
|
440
|
+
outBuf.write(session.buffer, 1)
|
|
441
|
+
try { ws.send(outBuf) } catch {}
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
ws.on('message', (data) => {
|
|
445
|
+
if (!session.pty || session.completed) return
|
|
446
|
+
const buf = Buffer.isBuffer(data) ? data : Buffer.from(data)
|
|
447
|
+
if (buf.length < 1) return
|
|
448
|
+
const type = buf[0]
|
|
449
|
+
const payload = buf.slice(1)
|
|
450
|
+
|
|
451
|
+
if (type === MSG_INPUT) {
|
|
452
|
+
session.pty.write(payload.toString())
|
|
453
|
+
} else if (type === MSG_RESIZE) {
|
|
454
|
+
try {
|
|
455
|
+
const { columns, rows } = JSON.parse(payload.toString())
|
|
456
|
+
if (columns && rows) session.pty.resize(columns, rows)
|
|
457
|
+
} catch {}
|
|
458
|
+
}
|
|
459
|
+
})
|
|
460
|
+
|
|
461
|
+
ws.on('close', () => { session.wsClients.delete(ws) })
|
|
462
|
+
ws.on('error', () => { session.wsClients.delete(ws) })
|
|
463
|
+
})
|
|
464
|
+
|
|
465
|
+
httpServer.listen(wsPort, '127.0.0.1', () => {
|
|
466
|
+
console.log(`[WSL] WebSocket terminal server listening on 127.0.0.1:${wsPort}`)
|
|
467
|
+
})
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
// ---------------------------------------------------------------------------
|
|
471
|
+
// Graceful shutdown
|
|
472
|
+
// ---------------------------------------------------------------------------
|
|
473
|
+
|
|
474
|
+
function shutdown(signal) {
|
|
475
|
+
console.log(`[WSL] Received ${signal}, shutting down...`)
|
|
476
|
+
for (const [name, session] of activeSessions) {
|
|
477
|
+
try {
|
|
478
|
+
if (session.pty && !session.completed) session.pty.kill()
|
|
479
|
+
} catch {}
|
|
480
|
+
}
|
|
481
|
+
activeSessions.clear()
|
|
482
|
+
if (activeChatChild) {
|
|
483
|
+
try { activeChatChild.kill('SIGTERM') } catch {}
|
|
484
|
+
}
|
|
485
|
+
try { fs.unlinkSync(PID_FILE) } catch {}
|
|
486
|
+
process.exit(0)
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
process.on('SIGTERM', () => shutdown('SIGTERM'))
|
|
490
|
+
process.on('SIGINT', () => shutdown('SIGINT'))
|
|
491
|
+
|
|
492
|
+
// ---------------------------------------------------------------------------
|
|
493
|
+
// Entry
|
|
494
|
+
// ---------------------------------------------------------------------------
|
|
495
|
+
|
|
496
|
+
if (require.main === module) {
|
|
497
|
+
startServer().catch((err) => {
|
|
498
|
+
console.error('[WSL] Failed to start:', err)
|
|
499
|
+
process.exit(1)
|
|
500
|
+
})
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
module.exports = { startServer }
|