@opute/host-agent 0.1.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/README.md +67 -0
- package/index.js +447 -0
- package/package.json +26 -0
package/README.md
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# @opute/host-agent
|
|
2
|
+
|
|
3
|
+
Downloads and launches the checksum-verified Opute Go host agent as a local
|
|
4
|
+
**Streamable HTTP** MCP server for VS Code, Claude Desktop, Cursor, and other
|
|
5
|
+
MCP clients.
|
|
6
|
+
|
|
7
|
+
## Quick start
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
# Start in the foreground (logs on stderr)
|
|
11
|
+
npx -y @opute/host-agent start
|
|
12
|
+
|
|
13
|
+
# Or run as a background daemon and print the MCP URL
|
|
14
|
+
npx -y @opute/host-agent start --background
|
|
15
|
+
npx -y @opute/host-agent status
|
|
16
|
+
npx -y @opute/host-agent stop
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
Point your MCP client at the printed URL (default port **3014**):
|
|
20
|
+
|
|
21
|
+
```json
|
|
22
|
+
{
|
|
23
|
+
"servers": {
|
|
24
|
+
"oputeLocal": {
|
|
25
|
+
"type": "http",
|
|
26
|
+
"url": "http://127.0.0.1:3014/mcp"
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
stdio MCP transport is not supported.
|
|
33
|
+
|
|
34
|
+
## Commands
|
|
35
|
+
|
|
36
|
+
| Command | Description |
|
|
37
|
+
|---------|-------------|
|
|
38
|
+
| `start` | Start standalone Streamable HTTP (foreground by default) |
|
|
39
|
+
| `start --background` / `-d` | Daemonize, write pid state, print MCP URL |
|
|
40
|
+
| `stop` | Stop a background daemon started by this launcher |
|
|
41
|
+
| `status` | JSON status (`running`, `healthy`, `url`) |
|
|
42
|
+
| `url` | Print the MCP URL |
|
|
43
|
+
|
|
44
|
+
## Environment
|
|
45
|
+
|
|
46
|
+
| Variable | Purpose |
|
|
47
|
+
|----------|---------|
|
|
48
|
+
| `HOST_MCP_PORT` | Listen port (default `3014`) |
|
|
49
|
+
| `HOST_MCP_BIND_HOST` | Bind host (default `127.0.0.1`) |
|
|
50
|
+
| `OPUTE_HOST_AGENT_BINARY` | Use a local binary instead of downloading a release |
|
|
51
|
+
| `OPUTE_STANDALONE_ALLOW_MUTATIONS=true` | Enable mutating infrastructure tools |
|
|
52
|
+
| `OPUTE_STANDALONE_STATE_DIR` | Local SQLite / operation journal directory |
|
|
53
|
+
|
|
54
|
+
For development, set `OPUTE_HOST_AGENT_BINARY` to a locally built binary.
|
|
55
|
+
Released packages download the matching versioned artifact and verify it
|
|
56
|
+
against the release `SHA256SUMS` manifest. `OPUTE_HOST_AGENT_SHA256` may be
|
|
57
|
+
supplied to pin a development or mirror artifact explicitly.
|
|
58
|
+
|
|
59
|
+
The stable MVP claim covers local Incus inspection and VM lifecycle. K3s,
|
|
60
|
+
PostgreSQL, SQL execution, and Cloudflare Tunnel tools are exposed as
|
|
61
|
+
experimental capabilities and require the same explicit mutation opt-in.
|
|
62
|
+
|
|
63
|
+
The launcher supports Linux x64 and arm64. Native Windows and macOS are not
|
|
64
|
+
supported by the Incus provider; Windows users should run the Linux binary
|
|
65
|
+
inside WSL and connect from the Windows MCP client over HTTP to
|
|
66
|
+
`http://127.0.0.1:3014/mcp` (with WSL port forwarding as needed). The launcher
|
|
67
|
+
does not install dependencies or run a postinstall hook.
|
package/index.js
ADDED
|
@@ -0,0 +1,447 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
const fs = require('node:fs')
|
|
4
|
+
const os = require('node:os')
|
|
5
|
+
const path = require('node:path')
|
|
6
|
+
const crypto = require('node:crypto')
|
|
7
|
+
const http = require('node:http')
|
|
8
|
+
const https = require('node:https')
|
|
9
|
+
const zlib = require('node:zlib')
|
|
10
|
+
const { spawn } = require('node:child_process')
|
|
11
|
+
|
|
12
|
+
const packageVersion = require('./package.json').version
|
|
13
|
+
const MAX_ARCHIVE_BYTES = 128 * 1024 * 1024
|
|
14
|
+
const MAX_BINARY_BYTES = 256 * 1024 * 1024
|
|
15
|
+
const REQUEST_TIMEOUT_MS = 15_000
|
|
16
|
+
const LOCK_TIMEOUT_MS = 30_000
|
|
17
|
+
const HEALTH_WAIT_MS = 15_000
|
|
18
|
+
|
|
19
|
+
function targetName() {
|
|
20
|
+
const platform = process.platform
|
|
21
|
+
const arch = process.arch
|
|
22
|
+
if (platform === 'linux' && arch === 'x64') return 'linux-x64'
|
|
23
|
+
if (platform === 'linux' && arch === 'arm64') return 'linux-arm64'
|
|
24
|
+
if (platform === 'win32') {
|
|
25
|
+
throw new Error('native Windows is not supported by the Incus host agent; run the Linux binary inside WSL and point your MCP client at http://127.0.0.1:3014/mcp')
|
|
26
|
+
}
|
|
27
|
+
throw new Error(`unsupported local host-agent target: ${platform}/${arch}; supported targets are Linux x64 and arm64 (Windows via WSL)`)
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function request(url, redirects = 0) {
|
|
31
|
+
if (redirects > 5) return Promise.reject(new Error('too many artifact redirects'))
|
|
32
|
+
return new Promise((resolve, reject) => {
|
|
33
|
+
let parsed
|
|
34
|
+
try { parsed = new URL(url) } catch { reject(new Error(`invalid artifact URL: ${url}`)); return }
|
|
35
|
+
const transport = parsed.protocol === 'http:' ? http : parsed.protocol === 'https:' ? https : null
|
|
36
|
+
if (!transport) { reject(new Error(`unsupported artifact URL protocol: ${parsed.protocol}`)); return }
|
|
37
|
+
const req = transport.get(parsed, { headers: { 'User-Agent': '@opute/host-agent' } }, response => {
|
|
38
|
+
if ([301, 302, 303, 307, 308].includes(response.statusCode) && response.headers.location) {
|
|
39
|
+
response.resume()
|
|
40
|
+
resolve(request(new URL(response.headers.location, parsed).toString(), redirects + 1))
|
|
41
|
+
return
|
|
42
|
+
}
|
|
43
|
+
if (response.statusCode !== 200) {
|
|
44
|
+
response.resume()
|
|
45
|
+
reject(new Error(`host-agent download failed: HTTP ${response.statusCode}`))
|
|
46
|
+
return
|
|
47
|
+
}
|
|
48
|
+
const declared = Number(response.headers['content-length'] || 0)
|
|
49
|
+
if (declared > MAX_ARCHIVE_BYTES) {
|
|
50
|
+
response.resume()
|
|
51
|
+
reject(new Error('host-agent download exceeds the maximum artifact size'))
|
|
52
|
+
return
|
|
53
|
+
}
|
|
54
|
+
const chunks = []
|
|
55
|
+
let size = 0
|
|
56
|
+
response.on('data', chunk => {
|
|
57
|
+
size += chunk.length
|
|
58
|
+
if (size > MAX_ARCHIVE_BYTES) {
|
|
59
|
+
req.destroy(new Error('host-agent download exceeds the maximum artifact size'))
|
|
60
|
+
return
|
|
61
|
+
}
|
|
62
|
+
chunks.push(chunk)
|
|
63
|
+
})
|
|
64
|
+
response.on('end', () => resolve(Buffer.concat(chunks)))
|
|
65
|
+
response.on('error', reject)
|
|
66
|
+
})
|
|
67
|
+
req.setTimeout(REQUEST_TIMEOUT_MS, () => req.destroy(new Error('host-agent download timed out')))
|
|
68
|
+
req.on('error', reject)
|
|
69
|
+
})
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function sha256(data) {
|
|
73
|
+
return crypto.createHash('sha256').update(data).digest('hex')
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
async function withCacheLock(lockPath, fn) {
|
|
77
|
+
const deadline = Date.now() + LOCK_TIMEOUT_MS
|
|
78
|
+
while (true) {
|
|
79
|
+
try {
|
|
80
|
+
fs.mkdirSync(lockPath, { recursive: false, mode: 0o700 })
|
|
81
|
+
try { return await fn() } finally { fs.rmSync(lockPath, { recursive: true, force: true }) }
|
|
82
|
+
} catch (error) {
|
|
83
|
+
if (error && error.code !== 'EEXIST') throw error
|
|
84
|
+
if (Date.now() >= deadline) throw new Error('timed out waiting for another host-agent download')
|
|
85
|
+
await new Promise(resolve => setTimeout(resolve, 100))
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function readCacheMarker(markerPath, binaryPath, expected) {
|
|
91
|
+
try {
|
|
92
|
+
const marker = JSON.parse(fs.readFileSync(markerPath, 'utf8'))
|
|
93
|
+
if (marker.archiveSha256 !== expected) return false
|
|
94
|
+
const binary = fs.readFileSync(binaryPath)
|
|
95
|
+
if (binary.length === 0 || binary.length > MAX_BINARY_BYTES) return false
|
|
96
|
+
return marker.binarySha256 === sha256(binary)
|
|
97
|
+
} catch {
|
|
98
|
+
return false
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
async function resolveBinary() {
|
|
103
|
+
if (process.env.OPUTE_HOST_AGENT_BINARY) return process.env.OPUTE_HOST_AGENT_BINARY
|
|
104
|
+
const target = targetName()
|
|
105
|
+
const cacheDir = process.env.OPUTE_HOST_AGENT_CACHE_DIR || path.join(os.homedir(), '.cache', 'opute', 'host-agent')
|
|
106
|
+
const binaryName = `host-agent-${target}`
|
|
107
|
+
const binaryPath = path.join(cacheDir, packageVersion, binaryName)
|
|
108
|
+
const markerPath = `${binaryPath}.verified.json`
|
|
109
|
+
const lockPath = path.join(cacheDir, `${packageVersion}.lock`)
|
|
110
|
+
const base = (process.env.OPUTE_HOST_AGENT_RELEASE_BASE_URL || 'https://github.com/wunderous/host-agents/releases/download').replace(/\/$/, '')
|
|
111
|
+
const artifactName = `host-agent-${target}.gz`
|
|
112
|
+
const expected = process.env.OPUTE_HOST_AGENT_SHA256
|
|
113
|
+
? process.env.OPUTE_HOST_AGENT_SHA256.toLowerCase().trim()
|
|
114
|
+
: await resolveChecksum(base, artifactName)
|
|
115
|
+
if (!/^[a-f0-9]{64}$/.test(expected)) throw new Error('OPUTE_HOST_AGENT_SHA256 must be a 64-character hexadecimal SHA-256')
|
|
116
|
+
|
|
117
|
+
fs.mkdirSync(cacheDir, { recursive: true, mode: 0o700 })
|
|
118
|
+
|
|
119
|
+
return withCacheLock(lockPath, async () => {
|
|
120
|
+
if (readCacheMarker(markerPath, binaryPath, expected)) return binaryPath
|
|
121
|
+
const archive = await request(`${base}/v${packageVersion}/${artifactName}`)
|
|
122
|
+
const actualArchive = sha256(archive)
|
|
123
|
+
if (actualArchive !== expected) throw new Error('host-agent artifact checksum mismatch')
|
|
124
|
+
let binary
|
|
125
|
+
try { binary = zlib.gunzipSync(archive) } catch { throw new Error('host-agent artifact is not valid gzip') }
|
|
126
|
+
if (binary.length === 0 || binary.length > MAX_BINARY_BYTES) throw new Error('host-agent binary exceeds the maximum size')
|
|
127
|
+
fs.mkdirSync(path.dirname(binaryPath), { recursive: true, mode: 0o700 })
|
|
128
|
+
const temporary = `${binaryPath}.download-${process.pid}-${Date.now()}`
|
|
129
|
+
const temporaryMarker = `${markerPath}.download-${process.pid}-${Date.now()}`
|
|
130
|
+
try {
|
|
131
|
+
fs.writeFileSync(temporary, binary, { mode: 0o700 })
|
|
132
|
+
fs.writeFileSync(temporaryMarker, JSON.stringify({ archiveSha256: expected, binarySha256: sha256(binary) }) + '\n', { mode: 0o600 })
|
|
133
|
+
fs.renameSync(temporary, binaryPath)
|
|
134
|
+
fs.renameSync(temporaryMarker, markerPath)
|
|
135
|
+
} catch (error) {
|
|
136
|
+
fs.rmSync(temporary, { force: true })
|
|
137
|
+
fs.rmSync(temporaryMarker, { force: true })
|
|
138
|
+
throw error
|
|
139
|
+
}
|
|
140
|
+
return binaryPath
|
|
141
|
+
})
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
async function resolveChecksum(base, artifactName) {
|
|
145
|
+
const manifestUrl = process.env.OPUTE_HOST_AGENT_CHECKSUM_URL || `${base}/v${packageVersion}/SHA256SUMS`
|
|
146
|
+
const manifest = (await request(manifestUrl)).toString('utf8')
|
|
147
|
+
for (const line of manifest.split(/\r?\n/)) {
|
|
148
|
+
const match = line.trim().match(/^([a-f0-9]{64})\s+[* ]?(.+)$/i)
|
|
149
|
+
if (match && path.basename(match[2]) === artifactName) return match[1].toLowerCase()
|
|
150
|
+
}
|
|
151
|
+
throw new Error(`release checksum manifest does not contain ${artifactName}`)
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function runtimeDir() {
|
|
155
|
+
return process.env.OPUTE_LOCAL_HOST_AGENT_RUNTIME_DIR
|
|
156
|
+
|| path.join(os.homedir(), '.cache', 'opute', 'host-agent', 'standalone')
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function daemonStatePath() {
|
|
160
|
+
return path.join(runtimeDir(), 'daemon.json')
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function bindHost() {
|
|
164
|
+
return (process.env.HOST_MCP_BIND_HOST || '127.0.0.1').trim() || '127.0.0.1'
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function mcpPort() {
|
|
168
|
+
const raw = (process.env.HOST_MCP_PORT || '3014').trim()
|
|
169
|
+
const port = Number(raw)
|
|
170
|
+
if (!Number.isInteger(port) || port <= 0 || port > 65535) {
|
|
171
|
+
throw new Error(`invalid HOST_MCP_PORT ${JSON.stringify(raw)}`)
|
|
172
|
+
}
|
|
173
|
+
return port
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function mcpUrl(host = bindHost(), port = mcpPort()) {
|
|
177
|
+
return `http://${host}:${port}/mcp`
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function healthUrl(host = bindHost(), port = mcpPort()) {
|
|
181
|
+
return `http://${host}:${port}/health`
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function readDaemonState() {
|
|
185
|
+
try {
|
|
186
|
+
const raw = fs.readFileSync(daemonStatePath(), 'utf8')
|
|
187
|
+
const state = JSON.parse(raw)
|
|
188
|
+
if (!state || typeof state !== 'object' || !Number.isInteger(state.pid)) {
|
|
189
|
+
return null
|
|
190
|
+
}
|
|
191
|
+
return state
|
|
192
|
+
} catch (error) {
|
|
193
|
+
if (error && (error.code === 'ENOENT' || error instanceof SyntaxError)) {
|
|
194
|
+
return null
|
|
195
|
+
}
|
|
196
|
+
throw error
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function writeDaemonState(state) {
|
|
201
|
+
fs.mkdirSync(runtimeDir(), { recursive: true, mode: 0o700 })
|
|
202
|
+
fs.writeFileSync(daemonStatePath(), JSON.stringify(state, null, 2) + '\n', { mode: 0o600 })
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function clearDaemonState() {
|
|
206
|
+
fs.rmSync(daemonStatePath(), { force: true })
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function isPidRunning(pid) {
|
|
210
|
+
if (!Number.isInteger(pid) || pid <= 0) return false
|
|
211
|
+
try {
|
|
212
|
+
process.kill(pid, 0)
|
|
213
|
+
return true
|
|
214
|
+
} catch {
|
|
215
|
+
return false
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function probeHealth(url, timeoutMs = 2000) {
|
|
220
|
+
return new Promise(resolve => {
|
|
221
|
+
const req = http.get(url, { timeout: timeoutMs }, response => {
|
|
222
|
+
const chunks = []
|
|
223
|
+
response.on('data', chunk => { chunks.push(chunk) })
|
|
224
|
+
response.on('end', () => {
|
|
225
|
+
if (response.statusCode !== 200) {
|
|
226
|
+
resolve(false)
|
|
227
|
+
return
|
|
228
|
+
}
|
|
229
|
+
try {
|
|
230
|
+
const payload = JSON.parse(Buffer.concat(chunks).toString('utf8'))
|
|
231
|
+
resolve(payload && payload.ok === true)
|
|
232
|
+
} catch {
|
|
233
|
+
resolve(false)
|
|
234
|
+
}
|
|
235
|
+
})
|
|
236
|
+
})
|
|
237
|
+
req.on('timeout', () => { req.destroy(); resolve(false) })
|
|
238
|
+
req.on('error', () => resolve(false))
|
|
239
|
+
})
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
async function waitForHealth(url, timeoutMs = HEALTH_WAIT_MS, failure = null) {
|
|
243
|
+
const deadline = Date.now() + timeoutMs
|
|
244
|
+
while (Date.now() < deadline) {
|
|
245
|
+
const earlyFailure = typeof failure === 'function' ? failure() : null
|
|
246
|
+
if (earlyFailure) throw new Error(earlyFailure)
|
|
247
|
+
if (await probeHealth(url)) return
|
|
248
|
+
await new Promise(resolve => setTimeout(resolve, 100))
|
|
249
|
+
}
|
|
250
|
+
throw new Error(`timed out waiting for health at ${url}`)
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function buildAgentEnv() {
|
|
254
|
+
const env = { ...process.env }
|
|
255
|
+
for (const key of Object.keys(env)) {
|
|
256
|
+
if (key.startsWith('OPUTE_') || key === 'MCP_AUTH_TOKEN' || key === 'BRIDGE_TOKEN') {
|
|
257
|
+
// Keep launcher-only and standalone-safe vars.
|
|
258
|
+
if (
|
|
259
|
+
key === 'OPUTE_HOST_AGENT_BINARY'
|
|
260
|
+
|| key === 'OPUTE_HOST_AGENT_CACHE_DIR'
|
|
261
|
+
|| key === 'OPUTE_HOST_AGENT_RELEASE_BASE_URL'
|
|
262
|
+
|| key === 'OPUTE_HOST_AGENT_CHECKSUM_URL'
|
|
263
|
+
|| key === 'OPUTE_HOST_AGENT_SHA256'
|
|
264
|
+
|| key === 'OPUTE_LOCAL_HOST_AGENT_RUNTIME_DIR'
|
|
265
|
+
|| key === 'OPUTE_STANDALONE_STATE_DIR'
|
|
266
|
+
|| key === 'OPUTE_STANDALONE_ALLOW_MUTATIONS'
|
|
267
|
+
|| key === 'OPUTE_STANDALONE_ALLOW_INSECURE_DOWNLOADS'
|
|
268
|
+
|| key === 'OPUTE_INFRA_PROVIDER_ID'
|
|
269
|
+
) {
|
|
270
|
+
continue
|
|
271
|
+
}
|
|
272
|
+
delete env[key]
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
env.OPUTE_AGENT_MODE = 'standalone'
|
|
276
|
+
env.OPUTE_TRANSPORT = 'http'
|
|
277
|
+
env.HOST_MCP_BIND_HOST = bindHost()
|
|
278
|
+
env.HOST_MCP_PORT = String(mcpPort())
|
|
279
|
+
if (!env.OPUTE_INFRA_PROVIDER_ID) env.OPUTE_INFRA_PROVIDER_ID = 'incus'
|
|
280
|
+
return env
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
function printUsage() {
|
|
284
|
+
console.log(`Usage: opute-host-agent <command>
|
|
285
|
+
|
|
286
|
+
Commands:
|
|
287
|
+
start [--background|-d] Start standalone Streamable HTTP MCP (default: foreground)
|
|
288
|
+
stop Stop a background daemon started by this launcher
|
|
289
|
+
status Show daemon/listener status and MCP URL
|
|
290
|
+
url Print the MCP URL (http://127.0.0.1:3014/mcp by default)
|
|
291
|
+
|
|
292
|
+
Environment:
|
|
293
|
+
HOST_MCP_PORT Listen port (default 3014)
|
|
294
|
+
HOST_MCP_BIND_HOST Bind host (default 127.0.0.1)
|
|
295
|
+
OPUTE_HOST_AGENT_BINARY Use a local binary instead of downloading a release
|
|
296
|
+
OPUTE_STANDALONE_ALLOW_MUTATIONS=true Enable mutating tools
|
|
297
|
+
|
|
298
|
+
MCP client config:
|
|
299
|
+
{ "type": "http", "url": "http://127.0.0.1:3014/mcp" }
|
|
300
|
+
`)
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
async function startForeground(binary, passthroughArgs) {
|
|
304
|
+
const host = bindHost()
|
|
305
|
+
const port = mcpPort()
|
|
306
|
+
const url = mcpUrl(host, port)
|
|
307
|
+
console.error(`starting standalone Streamable HTTP MCP at ${url}`)
|
|
308
|
+
const child = spawn(binary, ['--mode=standalone', '--transport=http', ...passthroughArgs], {
|
|
309
|
+
stdio: 'inherit',
|
|
310
|
+
env: buildAgentEnv(),
|
|
311
|
+
})
|
|
312
|
+
let exiting = false
|
|
313
|
+
for (const signal of ['SIGINT', 'SIGTERM', 'SIGHUP']) {
|
|
314
|
+
process.on(signal, () => { if (!exiting) child.kill(signal) })
|
|
315
|
+
}
|
|
316
|
+
return await new Promise((resolve, reject) => {
|
|
317
|
+
child.on('error', reject)
|
|
318
|
+
child.on('exit', (code, signal) => {
|
|
319
|
+
exiting = true
|
|
320
|
+
resolve(code ?? (signal ? 1 : 0))
|
|
321
|
+
})
|
|
322
|
+
})
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
async function startBackground(binary, passthroughArgs) {
|
|
326
|
+
const existing = readDaemonState()
|
|
327
|
+
if (existing && isPidRunning(existing.pid)) {
|
|
328
|
+
const existingUrl = mcpUrl(existing.host || bindHost(), existing.port || mcpPort())
|
|
329
|
+
throw new Error(`standalone agent already running (pid ${existing.pid}) at ${existingUrl}`)
|
|
330
|
+
}
|
|
331
|
+
const host = bindHost()
|
|
332
|
+
const port = mcpPort()
|
|
333
|
+
const url = mcpUrl(host, port)
|
|
334
|
+
const health = healthUrl(host, port)
|
|
335
|
+
fs.mkdirSync(runtimeDir(), { recursive: true, mode: 0o700 })
|
|
336
|
+
const logPath = path.join(runtimeDir(), 'daemon.log')
|
|
337
|
+
const logFd = fs.openSync(logPath, 'a')
|
|
338
|
+
const child = spawn(binary, ['--mode=standalone', '--transport=http', ...passthroughArgs], {
|
|
339
|
+
detached: true,
|
|
340
|
+
stdio: ['ignore', logFd, logFd],
|
|
341
|
+
env: buildAgentEnv(),
|
|
342
|
+
})
|
|
343
|
+
let spawnError = null
|
|
344
|
+
let childExit = null
|
|
345
|
+
child.once('error', error => { spawnError = error })
|
|
346
|
+
child.once('exit', (code, signal) => { childExit = { code, signal } })
|
|
347
|
+
child.unref()
|
|
348
|
+
fs.closeSync(logFd)
|
|
349
|
+
writeDaemonState({
|
|
350
|
+
pid: child.pid,
|
|
351
|
+
host,
|
|
352
|
+
port,
|
|
353
|
+
startedAt: new Date().toISOString(),
|
|
354
|
+
logPath,
|
|
355
|
+
})
|
|
356
|
+
try {
|
|
357
|
+
await waitForHealth(health, HEALTH_WAIT_MS, () => {
|
|
358
|
+
if (spawnError) return `child process failed: ${spawnError.message}`
|
|
359
|
+
if (childExit) return `child process exited before health (code=${childExit.code}, signal=${childExit.signal || 'none'})`
|
|
360
|
+
return null
|
|
361
|
+
})
|
|
362
|
+
} catch (error) {
|
|
363
|
+
try { process.kill(child.pid, 'SIGTERM') } catch { /* ignore */ }
|
|
364
|
+
clearDaemonState()
|
|
365
|
+
throw new Error(`failed to start standalone agent: ${error.message}`)
|
|
366
|
+
}
|
|
367
|
+
console.log(url)
|
|
368
|
+
return 0
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
async function cmdStop() {
|
|
372
|
+
const state = readDaemonState()
|
|
373
|
+
if (!state || !isPidRunning(state.pid)) {
|
|
374
|
+
clearDaemonState()
|
|
375
|
+
console.error('standalone agent is not running')
|
|
376
|
+
return 0
|
|
377
|
+
}
|
|
378
|
+
process.kill(state.pid, 'SIGTERM')
|
|
379
|
+
const deadline = Date.now() + 10_000
|
|
380
|
+
while (Date.now() < deadline && isPidRunning(state.pid)) {
|
|
381
|
+
await new Promise(resolve => setTimeout(resolve, 100))
|
|
382
|
+
}
|
|
383
|
+
if (isPidRunning(state.pid)) {
|
|
384
|
+
process.kill(state.pid, 'SIGKILL')
|
|
385
|
+
}
|
|
386
|
+
clearDaemonState()
|
|
387
|
+
console.error(`stopped standalone agent (pid ${state.pid})`)
|
|
388
|
+
return 0
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
async function cmdStatus() {
|
|
392
|
+
const state = readDaemonState()
|
|
393
|
+
const host = state?.host || bindHost()
|
|
394
|
+
const port = state?.port || mcpPort()
|
|
395
|
+
const url = mcpUrl(host, port)
|
|
396
|
+
const health = healthUrl(host, port)
|
|
397
|
+
const running = state ? isPidRunning(state.pid) : false
|
|
398
|
+
const healthy = await probeHealth(health)
|
|
399
|
+
console.log(JSON.stringify({
|
|
400
|
+
running,
|
|
401
|
+
healthy,
|
|
402
|
+
pid: running ? state.pid : null,
|
|
403
|
+
url,
|
|
404
|
+
health,
|
|
405
|
+
}))
|
|
406
|
+
return running && healthy ? 0 : 1
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
async function cmdUrl() {
|
|
410
|
+
const state = readDaemonState()
|
|
411
|
+
const host = state?.host || bindHost()
|
|
412
|
+
const port = state?.port || mcpPort()
|
|
413
|
+
console.log(mcpUrl(host, port))
|
|
414
|
+
return 0
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
async function main() {
|
|
418
|
+
const argv = process.argv.slice(2)
|
|
419
|
+
// Backward-compatible: bare invocation with no args starts foreground (tests + local use).
|
|
420
|
+
const command = argv[0] && !argv[0].startsWith('-') ? argv[0] : 'start'
|
|
421
|
+
const rest = command === argv[0] ? argv.slice(1) : argv
|
|
422
|
+
|
|
423
|
+
if (command === 'help' || command === '--help' || command === '-h') {
|
|
424
|
+
printUsage()
|
|
425
|
+
return 0
|
|
426
|
+
}
|
|
427
|
+
if (command === 'stop') return cmdStop()
|
|
428
|
+
if (command === 'status') return cmdStatus()
|
|
429
|
+
if (command === 'url') return cmdUrl()
|
|
430
|
+
if (command !== 'start') {
|
|
431
|
+
printUsage()
|
|
432
|
+
throw new Error(`unknown command: ${command}`)
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
const background = rest.includes('--background') || rest.includes('-d')
|
|
436
|
+
const passthroughArgs = rest.filter(arg => arg !== '--background' && arg !== '-d')
|
|
437
|
+
const binary = await resolveBinary()
|
|
438
|
+
if (background) return startBackground(binary, passthroughArgs)
|
|
439
|
+
return startForeground(binary, passthroughArgs)
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
main().then(code => {
|
|
443
|
+
if (typeof code === 'number') process.exitCode = code
|
|
444
|
+
}).catch(error => {
|
|
445
|
+
console.error(error.message)
|
|
446
|
+
process.exitCode = 1
|
|
447
|
+
})
|
package/package.json
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@opute/host-agent",
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"description": "Local Streamable HTTP launcher for the Opute Go host agent",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "https://github.com/wunderous/host-agents.git",
|
|
9
|
+
"directory": "npm/local-host-agent"
|
|
10
|
+
},
|
|
11
|
+
"homepage": "https://github.com/wunderous/host-agents#readme",
|
|
12
|
+
"bugs": {
|
|
13
|
+
"url": "https://github.com/wunderous/host-agents/issues"
|
|
14
|
+
},
|
|
15
|
+
"keywords": ["mcp", "model-context-protocol", "incus", "infrastructure", "opute"],
|
|
16
|
+
"scripts": {
|
|
17
|
+
"test": "node --test"
|
|
18
|
+
},
|
|
19
|
+
"bin": {
|
|
20
|
+
"opute-host-agent": "index.js"
|
|
21
|
+
},
|
|
22
|
+
"files": ["index.js", "package.json", "README.md"],
|
|
23
|
+
"engines": {
|
|
24
|
+
"node": ">=18"
|
|
25
|
+
}
|
|
26
|
+
}
|