@geekbeer/minion 3.43.0 → 3.49.1
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/core/config.js +3 -1
- package/core/lib/board-task-context.js +87 -0
- package/core/lib/board-task-poller.js +210 -0
- package/core/lib/concurrency-manager.js +56 -0
- package/core/lib/dag-step-poller.js +16 -10
- package/core/lib/end-of-day.js +20 -6
- package/core/lib/platform.js +39 -19
- package/core/routes/daemons.js +2 -0
- package/core/routes/diagnose.js +27 -2
- package/docs/api-reference.md +73 -1
- package/linux/board-task-runner.js +227 -0
- package/linux/routes/chat.js +26 -6
- package/linux/server.js +5 -0
- package/mac/bin/hq +4 -0
- package/mac/board-task-runner.js +4 -0
- package/mac/lib/process-manager.js +109 -0
- package/mac/minion-cli.sh +1353 -0
- package/mac/routes/chat.js +7 -0
- package/mac/routes/commands.js +119 -0
- package/mac/routes/config.js +8 -0
- package/mac/routes/directives.js +6 -0
- package/mac/routes/files.js +6 -0
- package/mac/routes/terminal.js +7 -0
- package/mac/routine-runner.js +4 -0
- package/mac/server.js +413 -0
- package/mac/terminal-proxy.js +6 -0
- package/mac/vnc-auth-proxy.js +402 -0
- package/mac/workflow-runner.js +7 -0
- package/package.json +6 -2
- package/postinstall.js +33 -12
- package/rules/core.md +30 -0
- package/win/board-task-runner.js +181 -0
- package/win/routes/chat.js +24 -6
- package/win/routes/terminal.js +8 -0
- package/win/server.js +5 -0
- package/win/wsl-session-server.js +136 -1
|
@@ -0,0 +1,402 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* VNC Auth Proxy (macOS)
|
|
3
|
+
*
|
|
4
|
+
* Replaces websockify for the macOS Screen Sharing backend.
|
|
5
|
+
*
|
|
6
|
+
* Why this exists:
|
|
7
|
+
* macOS `screensharingd` (port 5900) requires authentication; there is no
|
|
8
|
+
* no-auth mode for the system display. websockify is a dumb WS<->TCP relay
|
|
9
|
+
* and cannot perform RFB authentication, so noVNC on the HQ side hangs at
|
|
10
|
+
* the security handshake unless it is given a password — which would force
|
|
11
|
+
* storing VNC credentials in the HQ database.
|
|
12
|
+
*
|
|
13
|
+
* This proxy keeps the password local to the minion (read from
|
|
14
|
+
* `~/.minion/.env` as VNC_PASSWORD). It speaks RFB on both sides:
|
|
15
|
+
* - acts as a no-auth VNC SERVER toward noVNC (on the WS side)
|
|
16
|
+
* - acts as a VNC CLIENT toward screensharingd (on the TCP side),
|
|
17
|
+
* performing VNC Auth (RFB security type 2) with the local password
|
|
18
|
+
* After both handshakes complete it bridges the streams transparently.
|
|
19
|
+
*
|
|
20
|
+
* Flow:
|
|
21
|
+
* browser (noVNC) <--WS--> HQ proxy <--WS--> THIS (port 6080)
|
|
22
|
+
* |
|
|
23
|
+
* +--TCP--> localhost:5900 (screensharingd)
|
|
24
|
+
*
|
|
25
|
+
* RFB protocol references: RFC 6143
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
const http = require('http')
|
|
29
|
+
const net = require('net')
|
|
30
|
+
const crypto = require('crypto')
|
|
31
|
+
const fs = require('fs')
|
|
32
|
+
const path = require('path')
|
|
33
|
+
const { WebSocketServer } = require('ws')
|
|
34
|
+
|
|
35
|
+
const PROXY_PORT = 6080
|
|
36
|
+
const VNC_BACKEND_HOST = '127.0.0.1'
|
|
37
|
+
const VNC_BACKEND_PORT = 5900
|
|
38
|
+
|
|
39
|
+
const RFB_VERSION = Buffer.from('RFB 003.008\n', 'ascii')
|
|
40
|
+
const SEC_TYPE_NONE = 1
|
|
41
|
+
const SEC_TYPE_VNC_AUTH = 2
|
|
42
|
+
const SECURITY_RESULT_OK = Buffer.from([0, 0, 0, 0])
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Mangle a VNC password into an 8-byte DES key.
|
|
46
|
+
* VNC Auth historic quirk: password is NUL-padded / truncated to 8 bytes,
|
|
47
|
+
* then the bit order of each byte is REVERSED (LSB<->MSB).
|
|
48
|
+
*/
|
|
49
|
+
function vncPasswordToKey(password) {
|
|
50
|
+
const key = Buffer.alloc(8, 0)
|
|
51
|
+
Buffer.from(password, 'utf-8').copy(key, 0, 0, Math.min(8, password.length))
|
|
52
|
+
for (let i = 0; i < 8; i++) {
|
|
53
|
+
let b = key[i]
|
|
54
|
+
b = ((b & 0x01) << 7) | ((b & 0x02) << 5) | ((b & 0x04) << 3) | ((b & 0x08) << 1)
|
|
55
|
+
| ((b & 0x10) >> 1) | ((b & 0x20) >> 3) | ((b & 0x40) >> 5) | ((b & 0x80) >> 7)
|
|
56
|
+
key[i] = b
|
|
57
|
+
}
|
|
58
|
+
return key
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Encrypt the 16-byte server challenge with DES-ECB using the mangled password key.
|
|
63
|
+
* Two 8-byte blocks; no padding (block-aligned by definition).
|
|
64
|
+
*/
|
|
65
|
+
function vncEncryptChallenge(challenge, password) {
|
|
66
|
+
const key = vncPasswordToKey(password)
|
|
67
|
+
const cipher = crypto.createCipheriv('des-ecb', key, null)
|
|
68
|
+
cipher.setAutoPadding(false)
|
|
69
|
+
return Buffer.concat([cipher.update(challenge), cipher.final()])
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Build a sequential "read N bytes" function over a socket.
|
|
74
|
+
*
|
|
75
|
+
* Why not just attach/detach 'data' listeners per call: in flowing mode,
|
|
76
|
+
* any over-read bytes that we'd `socket.unshift()` are lost — they were
|
|
77
|
+
* already drained out of the internal buffer before the next listener
|
|
78
|
+
* attaches. Maintaining one persistent listener + a single accumulating
|
|
79
|
+
* buffer avoids that race entirely.
|
|
80
|
+
*
|
|
81
|
+
* Concurrent reads are not allowed. The handshake is sequential by spec.
|
|
82
|
+
*
|
|
83
|
+
* Also exposes `release()` which detaches our listeners and returns the
|
|
84
|
+
* residual buffered bytes — used to hand the socket off to the bridge
|
|
85
|
+
* after the handshake completes.
|
|
86
|
+
*/
|
|
87
|
+
function makeSocketReader(socket) {
|
|
88
|
+
let buf = Buffer.alloc(0)
|
|
89
|
+
let pending = null
|
|
90
|
+
let closed = false
|
|
91
|
+
let lastError = null
|
|
92
|
+
|
|
93
|
+
const tryResolve = () => {
|
|
94
|
+
if (pending && buf.length >= pending.n) {
|
|
95
|
+
const { n, resolve } = pending
|
|
96
|
+
const out = buf.subarray(0, n)
|
|
97
|
+
buf = buf.subarray(n)
|
|
98
|
+
pending = null
|
|
99
|
+
resolve(out)
|
|
100
|
+
} else if (pending && (closed || lastError)) {
|
|
101
|
+
const { reject } = pending
|
|
102
|
+
pending = null
|
|
103
|
+
reject(lastError || new Error(`Socket closed before reading ${pending?.n} bytes`))
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const onData = (chunk) => {
|
|
108
|
+
buf = Buffer.concat([buf, Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)])
|
|
109
|
+
tryResolve()
|
|
110
|
+
}
|
|
111
|
+
const onError = (err) => { lastError = err; tryResolve() }
|
|
112
|
+
const onClose = () => { closed = true; tryResolve() }
|
|
113
|
+
|
|
114
|
+
socket.on('data', onData)
|
|
115
|
+
socket.once('error', onError)
|
|
116
|
+
socket.once('close', onClose)
|
|
117
|
+
|
|
118
|
+
return {
|
|
119
|
+
readExactly(n) {
|
|
120
|
+
return new Promise((resolve, reject) => {
|
|
121
|
+
if (pending) return reject(new Error('Concurrent reads not supported'))
|
|
122
|
+
pending = { n, resolve, reject }
|
|
123
|
+
tryResolve()
|
|
124
|
+
})
|
|
125
|
+
},
|
|
126
|
+
/** Detach handlers and return whatever bytes are still buffered (for bridging). */
|
|
127
|
+
release() {
|
|
128
|
+
socket.removeListener('data', onData)
|
|
129
|
+
socket.removeListener('error', onError)
|
|
130
|
+
socket.removeListener('close', onClose)
|
|
131
|
+
const residual = buf
|
|
132
|
+
buf = Buffer.alloc(0)
|
|
133
|
+
return residual
|
|
134
|
+
},
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Drive the upstream RFB handshake with screensharingd.
|
|
140
|
+
* Returns when the upstream connection is fully authenticated and ready
|
|
141
|
+
* to receive ClientInit.
|
|
142
|
+
*/
|
|
143
|
+
async function authenticateUpstream(reader, write, password) {
|
|
144
|
+
// 1. ProtocolVersion: server -> client
|
|
145
|
+
const serverVersion = await reader.readExactly(12)
|
|
146
|
+
if (!serverVersion.toString('ascii').startsWith('RFB ')) {
|
|
147
|
+
throw new Error(`Unexpected ProtocolVersion from upstream: ${serverVersion.toString('ascii')}`)
|
|
148
|
+
}
|
|
149
|
+
// 2. ProtocolVersion: client -> server (we always speak 3.8)
|
|
150
|
+
write(RFB_VERSION)
|
|
151
|
+
|
|
152
|
+
// 3. Security types: server -> client
|
|
153
|
+
const numTypes = (await reader.readExactly(1))[0]
|
|
154
|
+
if (numTypes === 0) {
|
|
155
|
+
// Failure: server sends 4-byte reason length + reason string
|
|
156
|
+
const reasonLen = (await reader.readExactly(4)).readUInt32BE(0)
|
|
157
|
+
const reason = (await reader.readExactly(reasonLen)).toString('utf-8')
|
|
158
|
+
throw new Error(`Upstream rejected handshake: ${reason}`)
|
|
159
|
+
}
|
|
160
|
+
const types = await reader.readExactly(numTypes)
|
|
161
|
+
|
|
162
|
+
// Pick VNC Auth (type 2) — that's what macOS Screen Sharing uses with -setvncpw
|
|
163
|
+
if (!types.includes(SEC_TYPE_VNC_AUTH)) {
|
|
164
|
+
throw new Error(`Upstream does not offer VNC Auth (type 2). Offered: [${[...types].join(',')}]. Did you enable "VNC viewers may control screen with password"?`)
|
|
165
|
+
}
|
|
166
|
+
write(Buffer.from([SEC_TYPE_VNC_AUTH]))
|
|
167
|
+
|
|
168
|
+
// 4. VNC Auth challenge: 16 bytes
|
|
169
|
+
const challenge = await reader.readExactly(16)
|
|
170
|
+
const response = vncEncryptChallenge(challenge, password)
|
|
171
|
+
write(response)
|
|
172
|
+
|
|
173
|
+
// 5. SecurityResult: 4 bytes (0=OK, 1=failed)
|
|
174
|
+
const result = (await reader.readExactly(4)).readUInt32BE(0)
|
|
175
|
+
if (result !== 0) {
|
|
176
|
+
// RFB 3.8 includes a failure reason after a non-zero result
|
|
177
|
+
try {
|
|
178
|
+
const reasonLen = (await reader.readExactly(4)).readUInt32BE(0)
|
|
179
|
+
const reason = (await reader.readExactly(reasonLen)).toString('utf-8')
|
|
180
|
+
throw new Error(`Upstream VNC Auth failed: ${reason}`)
|
|
181
|
+
} catch {
|
|
182
|
+
throw new Error(`Upstream VNC Auth failed (no reason provided)`)
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Drive the downstream RFB handshake with noVNC, presenting "no auth required".
|
|
189
|
+
* After this returns, noVNC is in the post-security state and will send
|
|
190
|
+
* ClientInit next, which we just forward to upstream.
|
|
191
|
+
*/
|
|
192
|
+
async function presentNoAuthDownstream(reader, write) {
|
|
193
|
+
// 1. ProtocolVersion: server (us) -> client (noVNC)
|
|
194
|
+
write(RFB_VERSION)
|
|
195
|
+
// 2. ProtocolVersion: client (noVNC) -> server (us)
|
|
196
|
+
const clientVersion = await reader.readExactly(12)
|
|
197
|
+
if (!clientVersion.toString('ascii').startsWith('RFB ')) {
|
|
198
|
+
throw new Error(`noVNC sent unexpected ProtocolVersion: ${clientVersion.toString('ascii')}`)
|
|
199
|
+
}
|
|
200
|
+
// 3. Security types: us -> noVNC. Offer only "None" (type 1).
|
|
201
|
+
write(Buffer.from([1, SEC_TYPE_NONE]))
|
|
202
|
+
// 4. Chosen type: noVNC -> us. Must be 1.
|
|
203
|
+
const chosen = (await reader.readExactly(1))[0]
|
|
204
|
+
if (chosen !== SEC_TYPE_NONE) {
|
|
205
|
+
throw new Error(`noVNC chose unexpected security type: ${chosen} (expected ${SEC_TYPE_NONE})`)
|
|
206
|
+
}
|
|
207
|
+
// 5. SecurityResult: us -> noVNC. Always OK.
|
|
208
|
+
write(SECURITY_RESULT_OK)
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Build a reader+writer for a WebSocket that has the same shape as
|
|
213
|
+
* `makeSocketReader(tcpSocket)`. Lets the handshake code be agnostic to
|
|
214
|
+
* the underlying transport.
|
|
215
|
+
*/
|
|
216
|
+
function makeWsReader(ws) {
|
|
217
|
+
// Re-emit incoming WS binary frames through an EventEmitter that quacks
|
|
218
|
+
// enough like a Socket for makeSocketReader (only on('data'|'error'|'close')).
|
|
219
|
+
const { EventEmitter } = require('events')
|
|
220
|
+
const fakeSock = new EventEmitter()
|
|
221
|
+
ws.on('message', (data) => fakeSock.emit('data', Buffer.isBuffer(data) ? data : Buffer.from(data)))
|
|
222
|
+
ws.on('error', (err) => fakeSock.emit('error', err))
|
|
223
|
+
ws.on('close', () => fakeSock.emit('close'))
|
|
224
|
+
// EventEmitter doesn't have removeListener-by-removeListener method that
|
|
225
|
+
// matches Socket's API exactly, but it does — same `removeListener` exists.
|
|
226
|
+
return makeSocketReader(fakeSock)
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Load VNC password from ~/.minion/.env.
|
|
231
|
+
* Returns null if not configured (server should refuse connections in that case).
|
|
232
|
+
*/
|
|
233
|
+
function loadVncPassword() {
|
|
234
|
+
const envPath = path.join(process.env.HOME || '', '.minion', '.env')
|
|
235
|
+
try {
|
|
236
|
+
const content = fs.readFileSync(envPath, 'utf-8')
|
|
237
|
+
for (const line of content.split('\n')) {
|
|
238
|
+
const trimmed = line.trim()
|
|
239
|
+
if (!trimmed || trimmed.startsWith('#')) continue
|
|
240
|
+
const eq = trimmed.indexOf('=')
|
|
241
|
+
if (eq === -1) continue
|
|
242
|
+
const key = trimmed.slice(0, eq).trim()
|
|
243
|
+
if (key === 'VNC_PASSWORD') {
|
|
244
|
+
let value = trimmed.slice(eq + 1).trim()
|
|
245
|
+
// Strip surrounding quotes if any
|
|
246
|
+
if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
|
|
247
|
+
value = value.slice(1, -1)
|
|
248
|
+
}
|
|
249
|
+
return value
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
} catch (err) {
|
|
253
|
+
if (err.code !== 'ENOENT') {
|
|
254
|
+
console.error(`[VNC Proxy] Failed to read ${envPath}: ${err.message}`)
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
return null
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* Handle a single WS upgrade: drive both handshakes, then bridge.
|
|
262
|
+
*/
|
|
263
|
+
async function handleConnection(ws, password) {
|
|
264
|
+
const tcp = net.connect(VNC_BACKEND_PORT, VNC_BACKEND_HOST)
|
|
265
|
+
|
|
266
|
+
let authDone = false
|
|
267
|
+
tcp.once('error', (err) => {
|
|
268
|
+
console.error(`[VNC Proxy] Backend connection error: ${err.message}`)
|
|
269
|
+
if (!authDone) {
|
|
270
|
+
try { ws.close(1011, 'Backend unavailable') } catch { /* noop */ }
|
|
271
|
+
}
|
|
272
|
+
})
|
|
273
|
+
|
|
274
|
+
try {
|
|
275
|
+
await new Promise((resolve, reject) => {
|
|
276
|
+
tcp.once('connect', resolve)
|
|
277
|
+
tcp.once('error', reject)
|
|
278
|
+
})
|
|
279
|
+
|
|
280
|
+
const tcpReader = makeSocketReader(tcp)
|
|
281
|
+
const wsReader = makeWsReader(ws)
|
|
282
|
+
const tcpWrite = (buf) => tcp.write(buf)
|
|
283
|
+
const wsWrite = (buf) => ws.send(buf, { binary: true })
|
|
284
|
+
|
|
285
|
+
// Drive both handshakes in parallel — they don't depend on each other
|
|
286
|
+
// until after both complete.
|
|
287
|
+
await Promise.all([
|
|
288
|
+
authenticateUpstream(tcpReader, tcpWrite, password),
|
|
289
|
+
presentNoAuthDownstream(wsReader, wsWrite),
|
|
290
|
+
])
|
|
291
|
+
|
|
292
|
+
authDone = true
|
|
293
|
+
|
|
294
|
+
// Detach handshake readers; reclaim any over-buffered residual bytes.
|
|
295
|
+
const wsResidual = wsReader.release()
|
|
296
|
+
const tcpResidual = tcpReader.release()
|
|
297
|
+
console.log(`[VNC Proxy] Handshake complete, bridging (residual: ws=${wsResidual.length}b tcp=${tcpResidual.length}b)`)
|
|
298
|
+
|
|
299
|
+
// Forward residual bytes that arrived during handshake but past the
|
|
300
|
+
// handshake's read window (e.g. ClientInit piggybacked with the
|
|
301
|
+
// chosen security type).
|
|
302
|
+
if (wsResidual.length > 0 && !tcp.destroyed) tcp.write(wsResidual)
|
|
303
|
+
if (tcpResidual.length > 0 && ws.readyState === ws.OPEN) ws.send(tcpResidual, { binary: true })
|
|
304
|
+
|
|
305
|
+
// Transparent bridge: WS frames <-> TCP bytes
|
|
306
|
+
ws.on('message', (data) => {
|
|
307
|
+
const buf = Buffer.isBuffer(data) ? data : Buffer.from(data)
|
|
308
|
+
if (!tcp.destroyed) tcp.write(buf)
|
|
309
|
+
})
|
|
310
|
+
tcp.on('data', (chunk) => {
|
|
311
|
+
if (ws.readyState === ws.OPEN) ws.send(chunk, { binary: true })
|
|
312
|
+
})
|
|
313
|
+
|
|
314
|
+
const cleanup = () => {
|
|
315
|
+
try { tcp.destroy() } catch { /* noop */ }
|
|
316
|
+
try { ws.close() } catch { /* noop */ }
|
|
317
|
+
}
|
|
318
|
+
tcp.on('close', cleanup)
|
|
319
|
+
tcp.on('error', cleanup)
|
|
320
|
+
ws.on('close', cleanup)
|
|
321
|
+
ws.on('error', cleanup)
|
|
322
|
+
} catch (err) {
|
|
323
|
+
console.error(`[VNC Proxy] Handshake failed: ${err.message}`)
|
|
324
|
+
try { tcp.destroy() } catch { /* noop */ }
|
|
325
|
+
try { ws.close(1011, `Handshake failed: ${err.message}`) } catch { /* noop */ }
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/**
|
|
330
|
+
* Start the VNC auth proxy server on PROXY_PORT (default 6080).
|
|
331
|
+
* Returns a Promise that resolves once listening, or rejects on bind failure.
|
|
332
|
+
*/
|
|
333
|
+
function startVncAuthProxy() {
|
|
334
|
+
return new Promise((resolve, reject) => {
|
|
335
|
+
const httpServer = http.createServer((req, res) => {
|
|
336
|
+
// Non-WebSocket requests get a friendly error (websockify does the same)
|
|
337
|
+
res.writeHead(426, { 'Content-Type': 'text/plain', 'Upgrade': 'websocket' })
|
|
338
|
+
res.end('WebSocket connections only')
|
|
339
|
+
})
|
|
340
|
+
|
|
341
|
+
const wss = new WebSocketServer({ server: httpServer, handleProtocols: (protocols) => {
|
|
342
|
+
// noVNC asks for 'binary' subprotocol; honor it if offered
|
|
343
|
+
if (protocols.has('binary')) return 'binary'
|
|
344
|
+
return false
|
|
345
|
+
} })
|
|
346
|
+
|
|
347
|
+
wss.on('connection', (ws, req) => {
|
|
348
|
+
console.log(`[VNC Proxy] Connection from ${req.socket.remoteAddress}`)
|
|
349
|
+
const password = loadVncPassword()
|
|
350
|
+
if (!password) {
|
|
351
|
+
console.error('[VNC Proxy] VNC_PASSWORD not configured in ~/.minion/.env; refusing')
|
|
352
|
+
ws.close(1011, 'VNC password not configured on minion')
|
|
353
|
+
return
|
|
354
|
+
}
|
|
355
|
+
handleConnection(ws, password).catch((err) => {
|
|
356
|
+
console.error(`[VNC Proxy] Unhandled error: ${err.message}`)
|
|
357
|
+
})
|
|
358
|
+
})
|
|
359
|
+
|
|
360
|
+
httpServer.listen(PROXY_PORT, '0.0.0.0', () => {
|
|
361
|
+
console.log(`[VNC Proxy] Listening on port ${PROXY_PORT}, backend ${VNC_BACKEND_HOST}:${VNC_BACKEND_PORT}`)
|
|
362
|
+
resolve(httpServer)
|
|
363
|
+
})
|
|
364
|
+
httpServer.once('error', (err) => {
|
|
365
|
+
console.error(`[VNC Proxy] Failed to bind ${PROXY_PORT}: ${err.message}`)
|
|
366
|
+
reject(err)
|
|
367
|
+
})
|
|
368
|
+
})
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
/**
|
|
372
|
+
* Smoke-test DES availability at startup. Node 17+ (OpenSSL 3) requires
|
|
373
|
+
* --openssl-legacy-provider to enable DES. Failing fast here is much clearer
|
|
374
|
+
* than failing per-connection at challenge encryption time.
|
|
375
|
+
*/
|
|
376
|
+
function assertDesAvailable() {
|
|
377
|
+
try {
|
|
378
|
+
const c = crypto.createCipheriv('des-ecb', Buffer.alloc(8, 0), null)
|
|
379
|
+
c.setAutoPadding(false)
|
|
380
|
+
c.update(Buffer.alloc(8, 0))
|
|
381
|
+
c.final()
|
|
382
|
+
} catch (err) {
|
|
383
|
+
if (err.code === 'ERR_OSSL_EVP_UNSUPPORTED') {
|
|
384
|
+
console.error('[VNC Proxy] FATAL: DES is unavailable in this Node build.')
|
|
385
|
+
console.error('[VNC Proxy] Re-launch node with --openssl-legacy-provider')
|
|
386
|
+
console.error('[VNC Proxy] (the bundled LaunchAgent plist sets this automatically; fix the plist if you are seeing this).')
|
|
387
|
+
process.exit(2)
|
|
388
|
+
}
|
|
389
|
+
throw err
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
if (require.main === module) {
|
|
394
|
+
// Standalone entrypoint for running under launchd
|
|
395
|
+
assertDesAvailable()
|
|
396
|
+
startVncAuthProxy().catch((err) => {
|
|
397
|
+
console.error(`[VNC Proxy] Fatal: ${err.message}`)
|
|
398
|
+
process.exit(1)
|
|
399
|
+
})
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
module.exports = { startVncAuthProxy, vncPasswordToKey, vncEncryptChallenge }
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* macOS workflow runner — re-exports the Linux implementation.
|
|
3
|
+
*
|
|
4
|
+
* Uses tmux + Claude CLI for cron-scheduled workflow execution. Both are
|
|
5
|
+
* available on macOS via Homebrew, no platform branching needed.
|
|
6
|
+
*/
|
|
7
|
+
module.exports = require('../linux/workflow-runner')
|
package/package.json
CHANGED
|
@@ -1,19 +1,22 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@geekbeer/minion",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.49.1",
|
|
4
4
|
"description": "AI Agent runtime for Minion - manages status and skill deployment on VPS",
|
|
5
5
|
"main": "linux/server.js",
|
|
6
6
|
"bin": {
|
|
7
7
|
"minion-cli": "./linux/minion-cli.sh",
|
|
8
8
|
"hq": "./linux/bin/hq",
|
|
9
9
|
"minion-cli-win": "./win/bin/minion-cli-win.js",
|
|
10
|
-
"hq-win": "./win/bin/hq-win.js"
|
|
10
|
+
"hq-win": "./win/bin/hq-win.js",
|
|
11
|
+
"minion-cli-mac": "./mac/minion-cli.sh",
|
|
12
|
+
"hq-mac": "./mac/bin/hq"
|
|
11
13
|
},
|
|
12
14
|
"files": [
|
|
13
15
|
"postinstall.js",
|
|
14
16
|
"core/",
|
|
15
17
|
"linux/",
|
|
16
18
|
"win/",
|
|
19
|
+
"mac/",
|
|
17
20
|
"skills/",
|
|
18
21
|
"rules/",
|
|
19
22
|
"roles/",
|
|
@@ -25,6 +28,7 @@
|
|
|
25
28
|
"scripts": {
|
|
26
29
|
"start": "node linux/server.js",
|
|
27
30
|
"start:win": "node win/server.js",
|
|
31
|
+
"start:mac": "node mac/server.js",
|
|
28
32
|
"postinstall": "node postinstall.js",
|
|
29
33
|
"db:migration:new": "node scripts/new-migration.js"
|
|
30
34
|
},
|
package/postinstall.js
CHANGED
|
@@ -1,18 +1,39 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
// postinstall.js —
|
|
3
|
-
//
|
|
4
|
-
|
|
5
|
-
|
|
2
|
+
// postinstall.js — Platform-specific post-install hooks.
|
|
3
|
+
// - Windows: re-apply file ACLs via win/postinstall.ps1.
|
|
4
|
+
// - macOS: ensure mac/minion-cli.sh and mac/bin/hq are executable
|
|
5
|
+
// (npm preserves modes from the tarball, but defensive chmod matches the win pattern).
|
|
6
|
+
// - Linux: no-op.
|
|
6
7
|
|
|
7
8
|
const { spawnSync } = require('child_process');
|
|
8
9
|
const path = require('path');
|
|
10
|
+
const fs = require('fs');
|
|
11
|
+
|
|
12
|
+
if (process.platform === 'win32') {
|
|
13
|
+
const ps1 = path.join(__dirname, 'win', 'postinstall.ps1');
|
|
14
|
+
const result = spawnSync('powershell.exe', [
|
|
15
|
+
'-ExecutionPolicy', 'Bypass',
|
|
16
|
+
'-NoProfile',
|
|
17
|
+
'-NoLogo',
|
|
18
|
+
'-File', ps1,
|
|
19
|
+
], { stdio: 'inherit', timeout: 30_000 });
|
|
20
|
+
process.exit(result.status || 0);
|
|
21
|
+
}
|
|
9
22
|
|
|
10
|
-
|
|
11
|
-
const
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
23
|
+
if (process.platform === 'darwin') {
|
|
24
|
+
const macFiles = [
|
|
25
|
+
path.join(__dirname, 'mac', 'minion-cli.sh'),
|
|
26
|
+
path.join(__dirname, 'mac', 'bin', 'hq'),
|
|
27
|
+
];
|
|
28
|
+
for (const file of macFiles) {
|
|
29
|
+
try {
|
|
30
|
+
if (fs.existsSync(file)) fs.chmodSync(file, 0o755);
|
|
31
|
+
} catch (err) {
|
|
32
|
+
console.error(`[postinstall] Failed to chmod ${file}: ${err.message}`);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
process.exit(0);
|
|
36
|
+
}
|
|
17
37
|
|
|
18
|
-
|
|
38
|
+
// Linux: nothing to do
|
|
39
|
+
process.exit(0);
|
package/rules/core.md
CHANGED
|
@@ -43,6 +43,7 @@ Minion
|
|
|
43
43
|
- **priority**: `low` / `normal` / `high` / `urgent` (default `normal`)。可視化とフィルタ用途(並び順は `sort_order` が優先)
|
|
44
44
|
- **`milestone_id` は親 EPIC に付ければ配下の子タスクも自動的にその進捗に含まれる**(effective milestone)。子を個別にひもづけ直す必要はない。`/health` は leaf タスク基準で `progress_pct` を算出する
|
|
45
45
|
- **タスク検索**: `?q=<keyword>` でタイトル+説明の部分一致検索(pg_trgm、日本語OK)、`?priority=high,urgent` でカンマ区切り複数指定
|
|
46
|
+
- **`[task:UUID]` チケットタグ**: HQチャットでユーザーがチケットを指す際に使う形式。受信メッセージに含まれる場合、HQが解決した詳細がプロンプト先頭の「参照チケット」ブロックに同梱される。応答内でチケットへ言及する際にも同タグを使ってよい(HQ側でチップに描画される)
|
|
46
47
|
- 詳細は `~/.minion/docs/api-reference.md` の「Project Tasks」「Project Milestones」「Project Health」と `~/.minion/docs/task-guides.md` の「プロジェクトタスク管理」を参照
|
|
47
48
|
- **Workspace**: ミニオンは複数のワークスペースに所属でき、スキルやプロジェクトはワークスペース単位でスコープされる。チャットセッションもワークスペース別に分離される。所属ワークスペースはハートビートで自動同期され、`hq list workspaces` で確認できる。
|
|
48
49
|
- ミニオンは複数プロジェクトに `pm`、`engineer`、`accountant` として参加できる。
|
|
@@ -219,6 +220,35 @@ Routine 実行中は以下もtmuxセッション環境で利用可能:
|
|
|
219
220
|
- `pm.md` — PM (Project Manager) のガイドライン
|
|
220
221
|
- `engineer.md` — Engineer のガイドライン
|
|
221
222
|
|
|
223
|
+
## Board Task Auto-Pickup (ボードタスク自動着手)
|
|
224
|
+
|
|
225
|
+
アクティブスプリント (`sprint.status = 'active'`) に所属し、自分宛 (`assignee_minion_id == 自分`) かつ `status IN ('todo', 'doing')` のボードタスクは、`board-task-poller` が30秒間隔で自動検知し、claim→`doing`遷移→**専用 tmux セッションでの自走**を起動する (v3.46.0〜)。
|
|
226
|
+
|
|
227
|
+
**重要 (v3.46.0):** 以前はチャットセッションを代用していたが、ユーザーの会話履歴を破壊するため廃止。**今は `bt-{taskId8}` 名の独立したtmuxセッション**で `claude -p` を直接実行する。Linuxはホスト上のtmux、Windowsは WSL 内の tmux で動作。
|
|
228
|
+
|
|
229
|
+
セッションには以下が自動でプロンプトに注入される:
|
|
230
|
+
|
|
231
|
+
- タスクのタイトル / 説明 / 優先度 / 期限
|
|
232
|
+
- 受け入れ要件 (acceptance_criteria) のチェックリスト
|
|
233
|
+
- プロジェクトコンテキスト (`project_contexts.content`)
|
|
234
|
+
- プロジェクトメンバー一覧
|
|
235
|
+
|
|
236
|
+
**自動着手時の動作ルール:**
|
|
237
|
+
1. **受け入れ要件をすべて満たしてから `review` へ遷移する。** `done` への直接遷移は禁止 (人間レビュー後)。
|
|
238
|
+
2. **acceptance_criteria のチェックを更新する場合は既存IDを保持する。** PATCH の body で `acceptance_criteria: [...]` を渡すと全置換になるため、未編集の項目もIDごと送ること。
|
|
239
|
+
3. **判断に迷ったら勝手に進めない。** 受け入れ要件が曖昧/不足している場合は、状況を Notes (`hq note create`) や Memory (`POST /api/memory`) に記録し、`status` を `doing` のまま threads (`POST /api/threads`) で PM にエスカレーションする。
|
|
240
|
+
4. **タスクIDは `[task:UUID]` 形式で参照する。** ノートやスレッドで言及するときも同じタグを使うこと (HQ側でチップ描画される)。
|
|
241
|
+
|
|
242
|
+
**ステータス遷移と持ち越し:**
|
|
243
|
+
- 実行ミニオンが行うのは `todo→doing` (claim時) と `doing→review` (受け入れ要件達成後) のみ。`done` 遷移はレビュアーの責務。
|
|
244
|
+
- 前スプリントから `doing` 状態で持ち越されたタスクもポーラーが拾う。同名 tmux セッションが既に生きていれば再起動せずスキップ (二重実行防止)。
|
|
245
|
+
|
|
246
|
+
**セッション可視性:**
|
|
247
|
+
- Linuxミニオン: `tmux ls` で `bt-*` セッションが見える。WSターミナルからアタッチ可能。
|
|
248
|
+
- Windowsミニオン: WSL内の tmux で動作。HQ ダッシュボードのターミナル一覧 (`/api/terminal/sessions`) でも `wsl-tmux` タイプとして表示される。
|
|
249
|
+
|
|
250
|
+
詳細な API 仕様は `~/.minion/docs/api-reference.md` の「Project Sprints」「Project Tasks」セクションを参照。
|
|
251
|
+
|
|
222
252
|
## Todo運用ルール
|
|
223
253
|
|
|
224
254
|
チャット中のタスクは `/api/todos` に登録して進捗管理すること。圧縮(context compaction)を跨いでも作業を完遂するための仕組みがある。
|