puppet-mcp 4.3.0 → 4.4.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/LICENSE +21 -21
- package/README.md +44 -44
- package/bin/puppet-mcp.js +413 -413
- package/engine-manifest.json +7 -7
- package/package.json +23 -23
package/LICENSE
CHANGED
|
@@ -1,21 +1,21 @@
|
|
|
1
|
-
MIT License
|
|
2
|
-
|
|
3
|
-
Copyright (c) 2026 nathan <137482711+nathwn12@users.noreply.github.com>
|
|
4
|
-
|
|
5
|
-
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
-
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
-
in the Software without restriction, including without limitation the rights
|
|
8
|
-
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
-
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
-
furnished to do so, subject to the following conditions:
|
|
11
|
-
|
|
12
|
-
The above copyright notice and this permission notice shall be included in all
|
|
13
|
-
copies or substantial portions of the Software.
|
|
14
|
-
|
|
15
|
-
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
-
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
-
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
-
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
-
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
-
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
-
SOFTWARE.
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 nathan <137482711+nathwn12@users.noreply.github.com>
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,44 +1,44 @@
|
|
|
1
|
-
# puppet-mcp
|
|
2
|
-
|
|
3
|
-
Windows desktop-control MCP server: a native Rust binary served over stdio.
|
|
4
|
-
This package is a dependency-free Node launcher plus a pinned server
|
|
5
|
-
manifest — it contains no server bytes.
|
|
6
|
-
|
|
7
|
-
## Run
|
|
8
|
-
|
|
9
|
-
```sh
|
|
10
|
-
npx puppet-mcp@latest
|
|
11
|
-
```
|
|
12
|
-
|
|
13
|
-
Or pin it in an MCP client as a stdio server with command and args:
|
|
14
|
-
|
|
15
|
-
```json
|
|
16
|
-
["cmd", "/c", "npx", "-y", "puppet-mcp@latest"]
|
|
17
|
-
```
|
|
18
|
-
|
|
19
|
-
First run provisions the pinned server from the tagged GitHub Release into
|
|
20
|
-
`%LOCALAPPDATA%\puppet\engine\<serverVersion>-<bundleSha8>\`
|
|
21
|
-
(sha256-verified before and after extraction), then spawns it with stdio
|
|
22
|
-
passthrough. The cache is reused on later runs (re-verified each time); any
|
|
23
|
-
mismatch refuses and writes nothing. All launcher chatter goes to stderr;
|
|
24
|
-
stdout belongs to the MCP channel.
|
|
25
|
-
|
|
26
|
-
## Diagnostics (no network)
|
|
27
|
-
|
|
28
|
-
```sh
|
|
29
|
-
npx puppet-mcp@latest --doctor
|
|
30
|
-
```
|
|
31
|
-
|
|
32
|
-
Prints the pinned manifest and the resolved cache path, exits 0, performs
|
|
33
|
-
no fetch. `--print-manifest` is an alias.
|
|
34
|
-
|
|
35
|
-
## Requirements
|
|
36
|
-
|
|
37
|
-
- Windows x86_64. Any other platform refuses at startup
|
|
38
|
-
(override for diagnostics only: `PUPPET_MCP_ALLOW_NON_WINDOWS=1`).
|
|
39
|
-
- Node.js >= 22.14.0 (global `fetch` plus `crypto`/`zlib` builtins only;
|
|
40
|
-
zero dependencies).
|
|
41
|
-
|
|
42
|
-
## License
|
|
43
|
-
|
|
44
|
-
MIT — see LICENSE.
|
|
1
|
+
# puppet-mcp
|
|
2
|
+
|
|
3
|
+
Windows desktop-control MCP server: a native Rust binary served over stdio.
|
|
4
|
+
This package is a dependency-free Node launcher plus a pinned server
|
|
5
|
+
manifest — it contains no server bytes.
|
|
6
|
+
|
|
7
|
+
## Run
|
|
8
|
+
|
|
9
|
+
```sh
|
|
10
|
+
npx puppet-mcp@latest
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
Or pin it in an MCP client as a stdio server with command and args:
|
|
14
|
+
|
|
15
|
+
```json
|
|
16
|
+
["cmd", "/c", "npx", "-y", "puppet-mcp@latest"]
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
First run provisions the pinned server from the tagged GitHub Release into
|
|
20
|
+
`%LOCALAPPDATA%\puppet\engine\<serverVersion>-<bundleSha8>\`
|
|
21
|
+
(sha256-verified before and after extraction), then spawns it with stdio
|
|
22
|
+
passthrough. The cache is reused on later runs (re-verified each time); any
|
|
23
|
+
mismatch refuses and writes nothing. All launcher chatter goes to stderr;
|
|
24
|
+
stdout belongs to the MCP channel.
|
|
25
|
+
|
|
26
|
+
## Diagnostics (no network)
|
|
27
|
+
|
|
28
|
+
```sh
|
|
29
|
+
npx puppet-mcp@latest --doctor
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
Prints the pinned manifest and the resolved cache path, exits 0, performs
|
|
33
|
+
no fetch. `--print-manifest` is an alias.
|
|
34
|
+
|
|
35
|
+
## Requirements
|
|
36
|
+
|
|
37
|
+
- Windows x86_64. Any other platform refuses at startup
|
|
38
|
+
(override for diagnostics only: `PUPPET_MCP_ALLOW_NON_WINDOWS=1`).
|
|
39
|
+
- Node.js >= 22.14.0 (global `fetch` plus `crypto`/`zlib` builtins only;
|
|
40
|
+
zero dependencies).
|
|
41
|
+
|
|
42
|
+
## License
|
|
43
|
+
|
|
44
|
+
MIT — see LICENSE.
|
package/bin/puppet-mcp.js
CHANGED
|
@@ -1,413 +1,413 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
'use strict';
|
|
3
|
-
//
|
|
4
|
-
// puppet-mcp launcher.
|
|
5
|
-
//
|
|
6
|
-
// Published package model (see docs/PUBLISHING.md): this tarball holds ONLY
|
|
7
|
-
// this launcher plus the pinned server manifest (engine-manifest.json).
|
|
8
|
-
// It holds NO server bytes, NO `os` field, NO `scripts`/postinstall field.
|
|
9
|
-
// npm 12 blocks dependency install scripts by default (measured
|
|
10
|
-
// `allow-scripts=false`), and npx has no project package.json for
|
|
11
|
-
// `allowScripts` to consult, so a postinstall download would silently never
|
|
12
|
-
// run. Everything is therefore acquired on first RUN by the code below.
|
|
13
|
-
//
|
|
14
|
-
// Provisioning, one cache, verified:
|
|
15
|
-
// - The server (manifest runtime.archive) downloads from the tagged GitHub
|
|
16
|
-
// Release, verifies zip sha256+size BEFORE extraction, extracts
|
|
17
|
-
// atomically (staging dir, then rename) into
|
|
18
|
-
// %LOCALAPPDATA%\puppet\engine\<serverVersion>-<bundleSha8>\, verifies
|
|
19
|
-
// the server binary against the manifest, writes a .ready marker, and is
|
|
20
|
-
// reused on later runs (re-verified each time). Any mismatch refuses and
|
|
21
|
-
// writes nothing.
|
|
22
|
-
// - The server (puppet.exe, no args = MCP stdio serve) spawns with stdio
|
|
23
|
-
// inherited. ALL launcher chatter goes to stderr; stdout belongs to the
|
|
24
|
-
// MCP channel. No stdout writes exist on the run path.
|
|
25
|
-
// - `--doctor` / `--print-manifest` is the no-network diagnostic: it
|
|
26
|
-
// prints the manifest plus the resolved cache path and exits 0. It
|
|
27
|
-
// returns BEFORE any fetch call (all fetch use lives in
|
|
28
|
-
// downloadToMemory, reachable only from ensureRuntime, which the
|
|
29
|
-
// diagnostic path never calls).
|
|
30
|
-
//
|
|
31
|
-
// Builtins only: node:fs, node:path, node:os, node:crypto,
|
|
32
|
-
// node:child_process, node:zlib, and the global fetch (Node >= 22.14).
|
|
33
|
-
// No dependencies, so the published tarball stays tiny and auditable.
|
|
34
|
-
//
|
|
35
|
-
|
|
36
|
-
const fs = require('node:fs');
|
|
37
|
-
const path = require('node:path');
|
|
38
|
-
const os = require('node:os');
|
|
39
|
-
const crypto = require('node:crypto');
|
|
40
|
-
const zlib = require('node:zlib');
|
|
41
|
-
const { spawn } = require('node:child_process');
|
|
42
|
-
|
|
43
|
-
const PKG_DIR = path.dirname(path.dirname(__filename));
|
|
44
|
-
const MANIFEST_PATH = path.join(PKG_DIR, 'engine-manifest.json');
|
|
45
|
-
const PLATFORM_OVERRIDE_ENV = 'PUPPET_MCP_ALLOW_NON_WINDOWS';
|
|
46
|
-
const RUNTIME_CACHE_OVERRIDE_ENV = 'PUPPET_ENGINE_CACHE';
|
|
47
|
-
const DOWNLOAD_TIMEOUT_MS = 15 * 60 * 1000;
|
|
48
|
-
// Staging/extraction guards: the real runtime bundle is ~2 MB. These caps
|
|
49
|
-
// reject zip bombs, never the real artifact.
|
|
50
|
-
const MAX_ENTRIES = 4096;
|
|
51
|
-
const MAX_TOTAL_UNCOMPRESSED = 2 * 1024 * 1024 * 1024;
|
|
52
|
-
const MAX_ENTRY_SIZE = 1 * 1024 * 1024 * 1024;
|
|
53
|
-
|
|
54
|
-
function fail(message) {
|
|
55
|
-
process.stderr.write(`puppet-mcp: error: ${message}\n`);
|
|
56
|
-
process.exit(1);
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
function log(message) {
|
|
60
|
-
process.stderr.write(`puppet-mcp: ${message}\n`);
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
function loadManifest() {
|
|
64
|
-
let raw;
|
|
65
|
-
try {
|
|
66
|
-
raw = fs.readFileSync(MANIFEST_PATH, 'utf8');
|
|
67
|
-
} catch (err) {
|
|
68
|
-
fail(`cannot read engine manifest at ${MANIFEST_PATH}: ${err.message}`);
|
|
69
|
-
}
|
|
70
|
-
try {
|
|
71
|
-
return JSON.parse(raw);
|
|
72
|
-
} catch (err) {
|
|
73
|
-
fail(`engine manifest is not valid JSON: ${err.message}`);
|
|
74
|
-
}
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
// A sha256 slot the release workflow has not filled yet. The committed
|
|
78
|
-
// manifest carries the sentinel for every hash computed at release time;
|
|
79
|
-
// the workflow replaces each with the real 64-hex digest. Anything else
|
|
80
|
-
// shaped (64 hex chars) is release-provisioned.
|
|
81
|
-
function isPlaceholderSha(sha) {
|
|
82
|
-
return typeof sha !== 'string' || !/^[0-9a-fA-F]{64}$/.test(sha);
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
function shortHash(sha) {
|
|
86
|
-
return isPlaceholderSha(sha) ? 'unpublished' : sha.toLowerCase().slice(0, 8);
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
// Resolved runtime cache directory. Never throws: the diagnostic path must
|
|
90
|
-
// print SOMETHING and exit 0 even where provisioning could never run.
|
|
91
|
-
function resolveRuntimeDir(manifest) {
|
|
92
|
-
const notes = [];
|
|
93
|
-
let root = process.env[RUNTIME_CACHE_OVERRIDE_ENV];
|
|
94
|
-
let overridden = Boolean(root);
|
|
95
|
-
if (!root) {
|
|
96
|
-
root = process.env.LOCALAPPDATA;
|
|
97
|
-
if (!root) notes.push('LOCALAPPDATA is unset; set it or ' + RUNTIME_CACHE_OVERRIDE_ENV);
|
|
98
|
-
}
|
|
99
|
-
const leaf = `${manifest.serverVersion}-${shortHash(manifest.runtime.archive.sha256)}`;
|
|
100
|
-
if (!root) return { dir: path.join('<unresolvable>', 'puppet', 'engine', leaf), overridden, notes };
|
|
101
|
-
return { dir: path.join(root, 'puppet', 'engine', leaf), overridden, notes };
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
function printDoctor(manifest) {
|
|
105
|
-
const runtime = resolveRuntimeDir(manifest);
|
|
106
|
-
const report = {
|
|
107
|
-
package: manifest.package,
|
|
108
|
-
serverVersion: manifest.serverVersion,
|
|
109
|
-
platform: process.platform,
|
|
110
|
-
node: process.version,
|
|
111
|
-
runtimeCacheDir: runtime.dir,
|
|
112
|
-
runtimeCacheOverridden: runtime.overridden,
|
|
113
|
-
runtimeCacheNotes: runtime.notes,
|
|
114
|
-
runtime: manifest.runtime,
|
|
115
|
-
network: 'none: this diagnostic performs no fetch and exits before any download code',
|
|
116
|
-
};
|
|
117
|
-
process.stdout.write(JSON.stringify(report, null, 2) + '\n');
|
|
118
|
-
process.exit(0);
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
function usage(exitCode, stream) {
|
|
122
|
-
const text = [
|
|
123
|
-
'usage: puppet-mcp [--doctor | --print-manifest | --help]',
|
|
124
|
-
'',
|
|
125
|
-
' (no args) provision the pinned server on first run, then serve MCP over stdio',
|
|
126
|
-
' --doctor, print the pinned manifest and the resolved cache',
|
|
127
|
-
' --print-manifest path, perform no network access, exit 0',
|
|
128
|
-
' --help, -h print this usage',
|
|
129
|
-
'',
|
|
130
|
-
'runtime cache: ' + path.join('%LOCALAPPDATA%', 'puppet', 'engine', '<serverVersion>-<bundleSha8>') + ` (override: ${RUNTIME_CACHE_OVERRIDE_ENV})`,
|
|
131
|
-
`platform: windows-x86_64 only (override: ${PLATFORM_OVERRIDE_ENV}=1, diagnostics only)`,
|
|
132
|
-
].join('\n') + '\n';
|
|
133
|
-
stream.write(text);
|
|
134
|
-
process.exit(exitCode);
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
// --- minimal zip reader (stored + deflate, builtins only) -----------------
|
|
138
|
-
// The runtime bundle is an ordinary deflate zip written by PowerShell's
|
|
139
|
-
// Compress-Archive. Node has no builtin unzip, and this package is
|
|
140
|
-
// dependency-free by design, so the container parse is hand-rolled here
|
|
141
|
-
// (~120 lines) over zlib.inflateRawSync. Anything outside the artifact's
|
|
142
|
-
// actual shape (encryption, data descriptors, zip64, unknown methods) is an
|
|
143
|
-
// explicit error, never a silent misparse.
|
|
144
|
-
|
|
145
|
-
const CRC_TABLE = (() => {
|
|
146
|
-
const t = new Int32Array(256);
|
|
147
|
-
for (let n = 0; n < 256; n++) {
|
|
148
|
-
let c = n;
|
|
149
|
-
for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
|
|
150
|
-
t[n] = c;
|
|
151
|
-
}
|
|
152
|
-
return t;
|
|
153
|
-
})();
|
|
154
|
-
|
|
155
|
-
function crc32(buf) {
|
|
156
|
-
let c = 0xffffffff;
|
|
157
|
-
for (let i = 0; i < buf.length; i++) c = CRC_TABLE[(c ^ buf[i]) & 0xff] ^ (c >>> 8);
|
|
158
|
-
return (c ^ 0xffffffff) >>> 0;
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
function findEocd(buf) {
|
|
162
|
-
// EOCD is >= 22 bytes at the very end, with up to 64 KiB of comment.
|
|
163
|
-
const lo = Math.max(0, buf.length - (65557 + 22));
|
|
164
|
-
for (let i = buf.length - 22; i >= lo; i--) {
|
|
165
|
-
if (buf.readUInt32LE(i) === 0x06054b50) return i;
|
|
166
|
-
}
|
|
167
|
-
return -1;
|
|
168
|
-
}
|
|
169
|
-
|
|
170
|
-
function extractZip(buf, destDir) {
|
|
171
|
-
const eocd = findEocd(buf);
|
|
172
|
-
if (eocd < 0) throw new Error('not a zip archive: end-of-central-directory not found');
|
|
173
|
-
const entries = buf.readUInt16LE(eocd + 10);
|
|
174
|
-
const cdSize = buf.readUInt32LE(eocd + 12);
|
|
175
|
-
const cdOff = buf.readUInt32LE(eocd + 16);
|
|
176
|
-
if (entries === 0xffff || cdSize === 0xffffffff || cdOff === 0xffffffff) {
|
|
177
|
-
throw new Error('zip64 archives are not supported');
|
|
178
|
-
}
|
|
179
|
-
if (entries > MAX_ENTRIES) throw new Error(`too many entries: ${entries}`);
|
|
180
|
-
if (cdOff + cdSize > buf.length) throw new Error('central directory runs past end of buffer');
|
|
181
|
-
|
|
182
|
-
let total = 0;
|
|
183
|
-
const extracted = [];
|
|
184
|
-
let p = cdOff;
|
|
185
|
-
for (let n = 0; n < entries; n++) {
|
|
186
|
-
if (buf.readUInt32LE(p) !== 0x02014b50) throw new Error(`bad central-directory entry ${n}`);
|
|
187
|
-
const flags = buf.readUInt16LE(p + 8);
|
|
188
|
-
const method = buf.readUInt16LE(p + 10);
|
|
189
|
-
const crc = buf.readUInt32LE(p + 16);
|
|
190
|
-
const compSize = buf.readUInt32LE(p + 20);
|
|
191
|
-
const uncompSize = buf.readUInt32LE(p + 24);
|
|
192
|
-
const nameLen = buf.readUInt16LE(p + 28);
|
|
193
|
-
const extraLen = buf.readUInt16LE(p + 30);
|
|
194
|
-
const commentLen = buf.readUInt16LE(p + 32);
|
|
195
|
-
const localOff = buf.readUInt32LE(p + 42);
|
|
196
|
-
const rawName = buf.toString('utf8', p + 46, p + 46 + nameLen);
|
|
197
|
-
p += 46 + nameLen + extraLen + commentLen;
|
|
198
|
-
|
|
199
|
-
if (flags & 0x0001) throw new Error(`encrypted entry unsupported: ${rawName}`);
|
|
200
|
-
if (flags & 0x0008) throw new Error(`data-descriptor entry unsupported: ${rawName}`);
|
|
201
|
-
if (method !== 0 && method !== 8) throw new Error(`compression method ${method} unsupported: ${rawName}`);
|
|
202
|
-
if (compSize === 0xffffffff || uncompSize === 0xffffffff) throw new Error(`zip64 entry unsupported: ${rawName}`);
|
|
203
|
-
if (uncompSize > MAX_ENTRY_SIZE) throw new Error(`entry too large: ${rawName}`);
|
|
204
|
-
total += uncompSize;
|
|
205
|
-
if (total > MAX_TOTAL_UNCOMPRESSED) throw new Error('archive uncompressed total exceeds cap');
|
|
206
|
-
|
|
207
|
-
// Traversal guard: zip names normally use forward slashes, but
|
|
208
|
-
// PowerShell's Compress-Archive can write nested entry names
|
|
209
|
-
// with backslashes. Normalize every backslash to `/` FIRST, then run
|
|
210
|
-
// the unchanged checks below on the normalized name: absolute paths,
|
|
211
|
-
// drive letters, and `..` segments stay rejected, and the resolved
|
|
212
|
-
// target must still sit under destDir.
|
|
213
|
-
// (Spelled via char code so the source never holds a literal
|
|
214
|
-
// double-backslash.)
|
|
215
|
-
const BACKSLASH = String.fromCharCode(92);
|
|
216
|
-
const name = rawName.split(BACKSLASH).join('/');
|
|
217
|
-
if (name.startsWith('/') || /^[A-Za-z]:/.test(name)) {
|
|
218
|
-
throw new Error(`unsafe entry name: ${rawName}`);
|
|
219
|
-
}
|
|
220
|
-
const parts = name.split('/').filter((s) => s !== '' && s !== '.');
|
|
221
|
-
if (parts.length === 0 || parts.includes('..')) throw new Error(`unsafe entry name: ${rawName}`);
|
|
222
|
-
const target = path.join(destDir, ...parts);
|
|
223
|
-
const rel = path.relative(destDir, target);
|
|
224
|
-
if (rel.startsWith('..') || path.isAbsolute(rel)) throw new Error(`entry escapes destination: ${rawName}`);
|
|
225
|
-
const isDir = name.endsWith('/');
|
|
226
|
-
if (isDir) {
|
|
227
|
-
fs.mkdirSync(target, { recursive: true });
|
|
228
|
-
continue;
|
|
229
|
-
}
|
|
230
|
-
|
|
231
|
-
if (buf.readUInt32LE(localOff) !== 0x04034b50) throw new Error(`bad local header: ${rawName}`);
|
|
232
|
-
const lhNameLen = buf.readUInt16LE(localOff + 26);
|
|
233
|
-
const lhExtraLen = buf.readUInt16LE(localOff + 28);
|
|
234
|
-
const dataOff = localOff + 30 + lhNameLen + lhExtraLen;
|
|
235
|
-
const comp = buf.subarray(dataOff, dataOff + compSize);
|
|
236
|
-
if (comp.length !== compSize) throw new Error(`truncated entry data: ${rawName}`);
|
|
237
|
-
const data = method === 0 ? Buffer.from(comp) : zlib.inflateRawSync(comp);
|
|
238
|
-
if (data.length !== uncompSize) throw new Error(`size mismatch after inflate: ${rawName}`);
|
|
239
|
-
if (crc32(data) !== crc) throw new Error(`crc mismatch: ${rawName}`);
|
|
240
|
-
fs.mkdirSync(path.dirname(target), { recursive: true });
|
|
241
|
-
fs.writeFileSync(target, data);
|
|
242
|
-
extracted.push(parts.join('/'));
|
|
243
|
-
}
|
|
244
|
-
return extracted;
|
|
245
|
-
}
|
|
246
|
-
|
|
247
|
-
// --- provisioning ---------------------------------------------------------
|
|
248
|
-
|
|
249
|
-
function sha256File(filePath) {
|
|
250
|
-
const h = crypto.createHash('sha256');
|
|
251
|
-
h.update(fs.readFileSync(filePath));
|
|
252
|
-
return h.digest('hex');
|
|
253
|
-
}
|
|
254
|
-
|
|
255
|
-
function checkFile(filePath, want) {
|
|
256
|
-
// want: {name, sha256, size}. Returns null when good, else a reason.
|
|
257
|
-
let st;
|
|
258
|
-
try {
|
|
259
|
-
st = fs.statSync(filePath);
|
|
260
|
-
} catch {
|
|
261
|
-
return 'missing';
|
|
262
|
-
}
|
|
263
|
-
if (!st.isFile()) return 'not a file';
|
|
264
|
-
if (st.size !== want.size) return `size ${st.size} != manifest ${want.size}`;
|
|
265
|
-
const got = sha256File(filePath);
|
|
266
|
-
if (got !== want.sha256.toLowerCase()) return 'sha256 mismatch';
|
|
267
|
-
return null;
|
|
268
|
-
}
|
|
269
|
-
|
|
270
|
-
// The ONLY fetch call site in this package. The diagnostic path returns
|
|
271
|
-
// before the ensure* functions, so --doctor/--print-manifest provably reach
|
|
272
|
-
// no network: grep `fetch(` — it appears exactly once, here.
|
|
273
|
-
async function downloadToMemory(url) {
|
|
274
|
-
const ctrl = new AbortController();
|
|
275
|
-
const timer = setTimeout(() => ctrl.abort(), DOWNLOAD_TIMEOUT_MS);
|
|
276
|
-
let res;
|
|
277
|
-
try {
|
|
278
|
-
res = await fetch(url, { signal: ctrl.signal });
|
|
279
|
-
} catch (err) {
|
|
280
|
-
throw new Error(`download failed: ${err.message}`);
|
|
281
|
-
} finally {
|
|
282
|
-
clearTimeout(timer);
|
|
283
|
-
}
|
|
284
|
-
if (!res.ok) throw new Error(`download failed: HTTP ${res.status} ${res.statusText}`);
|
|
285
|
-
return Buffer.from(await res.arrayBuffer());
|
|
286
|
-
}
|
|
287
|
-
|
|
288
|
-
async function ensureRuntime(manifest, cacheDir) {
|
|
289
|
-
const rt = manifest.runtime;
|
|
290
|
-
if (isPlaceholderSha(rt.archive.sha256) || !(rt.archive.size > 0)) {
|
|
291
|
-
fail(
|
|
292
|
-
'this package was not published by the release workflow: runtime hashes are placeholders. ' +
|
|
293
|
-
'See docs/PUBLISHING.md: publish only the workflow-built npm-package artifact.'
|
|
294
|
-
);
|
|
295
|
-
}
|
|
296
|
-
if (isPlaceholderSha(rt.server.sha256) || !(rt.server.size > 0)) {
|
|
297
|
-
fail('engine manifest has no release-provisioned server hash; refusing to run.');
|
|
298
|
-
}
|
|
299
|
-
|
|
300
|
-
const markerPath = path.join(cacheDir, '.ready');
|
|
301
|
-
const reuse = (() => {
|
|
302
|
-
try {
|
|
303
|
-
const marker = JSON.parse(fs.readFileSync(markerPath, 'utf8'));
|
|
304
|
-
if (marker.serverVersion !== manifest.serverVersion) return false;
|
|
305
|
-
if (String(marker.bundleSha256).toLowerCase() !== rt.archive.sha256.toLowerCase()) return false;
|
|
306
|
-
} catch {
|
|
307
|
-
return false;
|
|
308
|
-
}
|
|
309
|
-
const bad = checkFile(path.join(cacheDir, rt.server.name), rt.server);
|
|
310
|
-
if (bad) {
|
|
311
|
-
log(`cached ${rt.server.name} invalid (${bad}); re-provisioning.`);
|
|
312
|
-
return false;
|
|
313
|
-
}
|
|
314
|
-
return true;
|
|
315
|
-
})();
|
|
316
|
-
if (reuse) {
|
|
317
|
-
log(`runtime cache ready (reused): ${cacheDir}`);
|
|
318
|
-
return cacheDir;
|
|
319
|
-
}
|
|
320
|
-
|
|
321
|
-
const url = `${rt.source}/${rt.archive.name}`;
|
|
322
|
-
log(`provisioning runtime cache: ${cacheDir}`);
|
|
323
|
-
log(`downloading ${url} (${rt.archive.size} bytes)`);
|
|
324
|
-
let data;
|
|
325
|
-
try {
|
|
326
|
-
data = await downloadToMemory(url);
|
|
327
|
-
} catch (err) {
|
|
328
|
-
fail(err.message);
|
|
329
|
-
}
|
|
330
|
-
if (data.length !== rt.archive.size) {
|
|
331
|
-
fail(`download size ${data.length} != manifest ${rt.archive.size}; wrote nothing.`);
|
|
332
|
-
}
|
|
333
|
-
const digest = crypto.createHash('sha256').update(data).digest('hex');
|
|
334
|
-
if (digest !== rt.archive.sha256.toLowerCase()) {
|
|
335
|
-
fail('download sha256 mismatch; wrote nothing.');
|
|
336
|
-
}
|
|
337
|
-
|
|
338
|
-
// Extract into a staging dir, verify, then rename: the target dir is
|
|
339
|
-
// never observed half-written.
|
|
340
|
-
const staging = `${cacheDir}.staging-${process.pid}`;
|
|
341
|
-
fs.rmSync(staging, { recursive: true, force: true });
|
|
342
|
-
fs.rmSync(cacheDir, { recursive: true, force: true });
|
|
343
|
-
fs.mkdirSync(staging, { recursive: true });
|
|
344
|
-
try {
|
|
345
|
-
const names = extractZip(data, staging);
|
|
346
|
-
log(`extracted ${names.length} entries`);
|
|
347
|
-
const bad = checkFile(path.join(staging, rt.server.name), rt.server);
|
|
348
|
-
if (bad) throw new Error(`extracted ${rt.server.name} invalid (${bad})`);
|
|
349
|
-
fs.renameSync(staging, cacheDir);
|
|
350
|
-
fs.writeFileSync(
|
|
351
|
-
markerPath,
|
|
352
|
-
JSON.stringify({ serverVersion: manifest.serverVersion, bundleSha256: rt.archive.sha256.toLowerCase() }, null, 2)
|
|
353
|
-
);
|
|
354
|
-
} catch (err) {
|
|
355
|
-
fs.rmSync(staging, { recursive: true, force: true });
|
|
356
|
-
fs.rmSync(cacheDir, { recursive: true, force: true });
|
|
357
|
-
fail(`provisioning failed (${err.message}); wrote nothing.`);
|
|
358
|
-
}
|
|
359
|
-
log(`runtime cache ready (provisioned): ${cacheDir}`);
|
|
360
|
-
return cacheDir;
|
|
361
|
-
}
|
|
362
|
-
|
|
363
|
-
function runServer(cacheDir, manifest) {
|
|
364
|
-
const exe = path.join(cacheDir, manifest.runtime.server.name);
|
|
365
|
-
if (!fs.existsSync(exe)) {
|
|
366
|
-
fail(`server binary missing after provisioning: ${exe}`);
|
|
367
|
-
}
|
|
368
|
-
// Bare puppet with no args serves MCP over stdio (src/main.rs: the server
|
|
369
|
-
// path below the --version early-return). stdio is inherited so the child
|
|
370
|
-
// IS the MCP channel; the launcher writes nothing to stdout on this path.
|
|
371
|
-
// Ctrl+C reaches both processes on Windows (shared console), so no
|
|
372
|
-
// signal relay is needed; the exit code is forwarded below.
|
|
373
|
-
const child = spawn(exe, [], { stdio: 'inherit', env: { ...process.env } });
|
|
374
|
-
child.on('error', (err) => fail(`cannot start ${exe}: ${err.message}`));
|
|
375
|
-
child.on('exit', (code, signal) => {
|
|
376
|
-
if (signal) {
|
|
377
|
-
log(`server terminated by signal ${signal}`);
|
|
378
|
-
process.exit(1);
|
|
379
|
-
}
|
|
380
|
-
process.exit(code === null ? 1 : code);
|
|
381
|
-
});
|
|
382
|
-
}
|
|
383
|
-
|
|
384
|
-
async function main() {
|
|
385
|
-
const args = process.argv.slice(2);
|
|
386
|
-
const manifest = loadManifest();
|
|
387
|
-
if (manifest.package !== 'puppet-mcp') fail('engine manifest names the wrong package.');
|
|
388
|
-
if (args.includes('--help') || args.includes('-h')) usage(0, process.stdout);
|
|
389
|
-
if (args.includes('--doctor') || args.includes('--print-manifest')) {
|
|
390
|
-
const known = new Set(['--doctor', '--print-manifest']);
|
|
391
|
-
const unknown = args.filter((a) => !known.has(a));
|
|
392
|
-
if (unknown.length > 0) usage(2, process.stderr);
|
|
393
|
-
printDoctor(manifest); // exits 0; no network on this path by construction
|
|
394
|
-
}
|
|
395
|
-
// Every known flag returns above; anything left is unknown.
|
|
396
|
-
if (args.length > 0) usage(2, process.stderr);
|
|
397
|
-
|
|
398
|
-
if (process.platform !== 'win32' && process.env[PLATFORM_OVERRIDE_ENV] !== '1') {
|
|
399
|
-
fail(
|
|
400
|
-
`unsupported platform '${process.platform}': puppet-mcp ships the windows-x86_64 server only. ` +
|
|
401
|
-
`Set ${PLATFORM_OVERRIDE_ENV}=1 to bypass this gate for diagnostics (the server still will not run elsewhere).`
|
|
402
|
-
);
|
|
403
|
-
}
|
|
404
|
-
|
|
405
|
-
const runtime = resolveRuntimeDir(manifest);
|
|
406
|
-
if (runtime.dir.startsWith('<unresolvable>')) {
|
|
407
|
-
fail(`cannot resolve runtime cache dir: ${runtime.notes.join('; ')}. Set ${RUNTIME_CACHE_OVERRIDE_ENV} to an absolute directory.`);
|
|
408
|
-
}
|
|
409
|
-
const readyRuntime = await ensureRuntime(manifest, runtime.dir);
|
|
410
|
-
runServer(readyRuntime, manifest);
|
|
411
|
-
}
|
|
412
|
-
|
|
413
|
-
main().catch((err) => fail(err && err.message ? err.message : String(err)));
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
//
|
|
4
|
+
// puppet-mcp launcher.
|
|
5
|
+
//
|
|
6
|
+
// Published package model (see docs/PUBLISHING.md): this tarball holds ONLY
|
|
7
|
+
// this launcher plus the pinned server manifest (engine-manifest.json).
|
|
8
|
+
// It holds NO server bytes, NO `os` field, NO `scripts`/postinstall field.
|
|
9
|
+
// npm 12 blocks dependency install scripts by default (measured
|
|
10
|
+
// `allow-scripts=false`), and npx has no project package.json for
|
|
11
|
+
// `allowScripts` to consult, so a postinstall download would silently never
|
|
12
|
+
// run. Everything is therefore acquired on first RUN by the code below.
|
|
13
|
+
//
|
|
14
|
+
// Provisioning, one cache, verified:
|
|
15
|
+
// - The server (manifest runtime.archive) downloads from the tagged GitHub
|
|
16
|
+
// Release, verifies zip sha256+size BEFORE extraction, extracts
|
|
17
|
+
// atomically (staging dir, then rename) into
|
|
18
|
+
// %LOCALAPPDATA%\puppet\engine\<serverVersion>-<bundleSha8>\, verifies
|
|
19
|
+
// the server binary against the manifest, writes a .ready marker, and is
|
|
20
|
+
// reused on later runs (re-verified each time). Any mismatch refuses and
|
|
21
|
+
// writes nothing.
|
|
22
|
+
// - The server (puppet.exe, no args = MCP stdio serve) spawns with stdio
|
|
23
|
+
// inherited. ALL launcher chatter goes to stderr; stdout belongs to the
|
|
24
|
+
// MCP channel. No stdout writes exist on the run path.
|
|
25
|
+
// - `--doctor` / `--print-manifest` is the no-network diagnostic: it
|
|
26
|
+
// prints the manifest plus the resolved cache path and exits 0. It
|
|
27
|
+
// returns BEFORE any fetch call (all fetch use lives in
|
|
28
|
+
// downloadToMemory, reachable only from ensureRuntime, which the
|
|
29
|
+
// diagnostic path never calls).
|
|
30
|
+
//
|
|
31
|
+
// Builtins only: node:fs, node:path, node:os, node:crypto,
|
|
32
|
+
// node:child_process, node:zlib, and the global fetch (Node >= 22.14).
|
|
33
|
+
// No dependencies, so the published tarball stays tiny and auditable.
|
|
34
|
+
//
|
|
35
|
+
|
|
36
|
+
const fs = require('node:fs');
|
|
37
|
+
const path = require('node:path');
|
|
38
|
+
const os = require('node:os');
|
|
39
|
+
const crypto = require('node:crypto');
|
|
40
|
+
const zlib = require('node:zlib');
|
|
41
|
+
const { spawn } = require('node:child_process');
|
|
42
|
+
|
|
43
|
+
const PKG_DIR = path.dirname(path.dirname(__filename));
|
|
44
|
+
const MANIFEST_PATH = path.join(PKG_DIR, 'engine-manifest.json');
|
|
45
|
+
const PLATFORM_OVERRIDE_ENV = 'PUPPET_MCP_ALLOW_NON_WINDOWS';
|
|
46
|
+
const RUNTIME_CACHE_OVERRIDE_ENV = 'PUPPET_ENGINE_CACHE';
|
|
47
|
+
const DOWNLOAD_TIMEOUT_MS = 15 * 60 * 1000;
|
|
48
|
+
// Staging/extraction guards: the real runtime bundle is ~2 MB. These caps
|
|
49
|
+
// reject zip bombs, never the real artifact.
|
|
50
|
+
const MAX_ENTRIES = 4096;
|
|
51
|
+
const MAX_TOTAL_UNCOMPRESSED = 2 * 1024 * 1024 * 1024;
|
|
52
|
+
const MAX_ENTRY_SIZE = 1 * 1024 * 1024 * 1024;
|
|
53
|
+
|
|
54
|
+
function fail(message) {
|
|
55
|
+
process.stderr.write(`puppet-mcp: error: ${message}\n`);
|
|
56
|
+
process.exit(1);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function log(message) {
|
|
60
|
+
process.stderr.write(`puppet-mcp: ${message}\n`);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function loadManifest() {
|
|
64
|
+
let raw;
|
|
65
|
+
try {
|
|
66
|
+
raw = fs.readFileSync(MANIFEST_PATH, 'utf8');
|
|
67
|
+
} catch (err) {
|
|
68
|
+
fail(`cannot read engine manifest at ${MANIFEST_PATH}: ${err.message}`);
|
|
69
|
+
}
|
|
70
|
+
try {
|
|
71
|
+
return JSON.parse(raw);
|
|
72
|
+
} catch (err) {
|
|
73
|
+
fail(`engine manifest is not valid JSON: ${err.message}`);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// A sha256 slot the release workflow has not filled yet. The committed
|
|
78
|
+
// manifest carries the sentinel for every hash computed at release time;
|
|
79
|
+
// the workflow replaces each with the real 64-hex digest. Anything else
|
|
80
|
+
// shaped (64 hex chars) is release-provisioned.
|
|
81
|
+
function isPlaceholderSha(sha) {
|
|
82
|
+
return typeof sha !== 'string' || !/^[0-9a-fA-F]{64}$/.test(sha);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function shortHash(sha) {
|
|
86
|
+
return isPlaceholderSha(sha) ? 'unpublished' : sha.toLowerCase().slice(0, 8);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Resolved runtime cache directory. Never throws: the diagnostic path must
|
|
90
|
+
// print SOMETHING and exit 0 even where provisioning could never run.
|
|
91
|
+
function resolveRuntimeDir(manifest) {
|
|
92
|
+
const notes = [];
|
|
93
|
+
let root = process.env[RUNTIME_CACHE_OVERRIDE_ENV];
|
|
94
|
+
let overridden = Boolean(root);
|
|
95
|
+
if (!root) {
|
|
96
|
+
root = process.env.LOCALAPPDATA;
|
|
97
|
+
if (!root) notes.push('LOCALAPPDATA is unset; set it or ' + RUNTIME_CACHE_OVERRIDE_ENV);
|
|
98
|
+
}
|
|
99
|
+
const leaf = `${manifest.serverVersion}-${shortHash(manifest.runtime.archive.sha256)}`;
|
|
100
|
+
if (!root) return { dir: path.join('<unresolvable>', 'puppet', 'engine', leaf), overridden, notes };
|
|
101
|
+
return { dir: path.join(root, 'puppet', 'engine', leaf), overridden, notes };
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function printDoctor(manifest) {
|
|
105
|
+
const runtime = resolveRuntimeDir(manifest);
|
|
106
|
+
const report = {
|
|
107
|
+
package: manifest.package,
|
|
108
|
+
serverVersion: manifest.serverVersion,
|
|
109
|
+
platform: process.platform,
|
|
110
|
+
node: process.version,
|
|
111
|
+
runtimeCacheDir: runtime.dir,
|
|
112
|
+
runtimeCacheOverridden: runtime.overridden,
|
|
113
|
+
runtimeCacheNotes: runtime.notes,
|
|
114
|
+
runtime: manifest.runtime,
|
|
115
|
+
network: 'none: this diagnostic performs no fetch and exits before any download code',
|
|
116
|
+
};
|
|
117
|
+
process.stdout.write(JSON.stringify(report, null, 2) + '\n');
|
|
118
|
+
process.exit(0);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function usage(exitCode, stream) {
|
|
122
|
+
const text = [
|
|
123
|
+
'usage: puppet-mcp [--doctor | --print-manifest | --help]',
|
|
124
|
+
'',
|
|
125
|
+
' (no args) provision the pinned server on first run, then serve MCP over stdio',
|
|
126
|
+
' --doctor, print the pinned manifest and the resolved cache',
|
|
127
|
+
' --print-manifest path, perform no network access, exit 0',
|
|
128
|
+
' --help, -h print this usage',
|
|
129
|
+
'',
|
|
130
|
+
'runtime cache: ' + path.join('%LOCALAPPDATA%', 'puppet', 'engine', '<serverVersion>-<bundleSha8>') + ` (override: ${RUNTIME_CACHE_OVERRIDE_ENV})`,
|
|
131
|
+
`platform: windows-x86_64 only (override: ${PLATFORM_OVERRIDE_ENV}=1, diagnostics only)`,
|
|
132
|
+
].join('\n') + '\n';
|
|
133
|
+
stream.write(text);
|
|
134
|
+
process.exit(exitCode);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// --- minimal zip reader (stored + deflate, builtins only) -----------------
|
|
138
|
+
// The runtime bundle is an ordinary deflate zip written by PowerShell's
|
|
139
|
+
// Compress-Archive. Node has no builtin unzip, and this package is
|
|
140
|
+
// dependency-free by design, so the container parse is hand-rolled here
|
|
141
|
+
// (~120 lines) over zlib.inflateRawSync. Anything outside the artifact's
|
|
142
|
+
// actual shape (encryption, data descriptors, zip64, unknown methods) is an
|
|
143
|
+
// explicit error, never a silent misparse.
|
|
144
|
+
|
|
145
|
+
const CRC_TABLE = (() => {
|
|
146
|
+
const t = new Int32Array(256);
|
|
147
|
+
for (let n = 0; n < 256; n++) {
|
|
148
|
+
let c = n;
|
|
149
|
+
for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
|
|
150
|
+
t[n] = c;
|
|
151
|
+
}
|
|
152
|
+
return t;
|
|
153
|
+
})();
|
|
154
|
+
|
|
155
|
+
function crc32(buf) {
|
|
156
|
+
let c = 0xffffffff;
|
|
157
|
+
for (let i = 0; i < buf.length; i++) c = CRC_TABLE[(c ^ buf[i]) & 0xff] ^ (c >>> 8);
|
|
158
|
+
return (c ^ 0xffffffff) >>> 0;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function findEocd(buf) {
|
|
162
|
+
// EOCD is >= 22 bytes at the very end, with up to 64 KiB of comment.
|
|
163
|
+
const lo = Math.max(0, buf.length - (65557 + 22));
|
|
164
|
+
for (let i = buf.length - 22; i >= lo; i--) {
|
|
165
|
+
if (buf.readUInt32LE(i) === 0x06054b50) return i;
|
|
166
|
+
}
|
|
167
|
+
return -1;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function extractZip(buf, destDir) {
|
|
171
|
+
const eocd = findEocd(buf);
|
|
172
|
+
if (eocd < 0) throw new Error('not a zip archive: end-of-central-directory not found');
|
|
173
|
+
const entries = buf.readUInt16LE(eocd + 10);
|
|
174
|
+
const cdSize = buf.readUInt32LE(eocd + 12);
|
|
175
|
+
const cdOff = buf.readUInt32LE(eocd + 16);
|
|
176
|
+
if (entries === 0xffff || cdSize === 0xffffffff || cdOff === 0xffffffff) {
|
|
177
|
+
throw new Error('zip64 archives are not supported');
|
|
178
|
+
}
|
|
179
|
+
if (entries > MAX_ENTRIES) throw new Error(`too many entries: ${entries}`);
|
|
180
|
+
if (cdOff + cdSize > buf.length) throw new Error('central directory runs past end of buffer');
|
|
181
|
+
|
|
182
|
+
let total = 0;
|
|
183
|
+
const extracted = [];
|
|
184
|
+
let p = cdOff;
|
|
185
|
+
for (let n = 0; n < entries; n++) {
|
|
186
|
+
if (buf.readUInt32LE(p) !== 0x02014b50) throw new Error(`bad central-directory entry ${n}`);
|
|
187
|
+
const flags = buf.readUInt16LE(p + 8);
|
|
188
|
+
const method = buf.readUInt16LE(p + 10);
|
|
189
|
+
const crc = buf.readUInt32LE(p + 16);
|
|
190
|
+
const compSize = buf.readUInt32LE(p + 20);
|
|
191
|
+
const uncompSize = buf.readUInt32LE(p + 24);
|
|
192
|
+
const nameLen = buf.readUInt16LE(p + 28);
|
|
193
|
+
const extraLen = buf.readUInt16LE(p + 30);
|
|
194
|
+
const commentLen = buf.readUInt16LE(p + 32);
|
|
195
|
+
const localOff = buf.readUInt32LE(p + 42);
|
|
196
|
+
const rawName = buf.toString('utf8', p + 46, p + 46 + nameLen);
|
|
197
|
+
p += 46 + nameLen + extraLen + commentLen;
|
|
198
|
+
|
|
199
|
+
if (flags & 0x0001) throw new Error(`encrypted entry unsupported: ${rawName}`);
|
|
200
|
+
if (flags & 0x0008) throw new Error(`data-descriptor entry unsupported: ${rawName}`);
|
|
201
|
+
if (method !== 0 && method !== 8) throw new Error(`compression method ${method} unsupported: ${rawName}`);
|
|
202
|
+
if (compSize === 0xffffffff || uncompSize === 0xffffffff) throw new Error(`zip64 entry unsupported: ${rawName}`);
|
|
203
|
+
if (uncompSize > MAX_ENTRY_SIZE) throw new Error(`entry too large: ${rawName}`);
|
|
204
|
+
total += uncompSize;
|
|
205
|
+
if (total > MAX_TOTAL_UNCOMPRESSED) throw new Error('archive uncompressed total exceeds cap');
|
|
206
|
+
|
|
207
|
+
// Traversal guard: zip names normally use forward slashes, but
|
|
208
|
+
// PowerShell's Compress-Archive can write nested entry names
|
|
209
|
+
// with backslashes. Normalize every backslash to `/` FIRST, then run
|
|
210
|
+
// the unchanged checks below on the normalized name: absolute paths,
|
|
211
|
+
// drive letters, and `..` segments stay rejected, and the resolved
|
|
212
|
+
// target must still sit under destDir.
|
|
213
|
+
// (Spelled via char code so the source never holds a literal
|
|
214
|
+
// double-backslash.)
|
|
215
|
+
const BACKSLASH = String.fromCharCode(92);
|
|
216
|
+
const name = rawName.split(BACKSLASH).join('/');
|
|
217
|
+
if (name.startsWith('/') || /^[A-Za-z]:/.test(name)) {
|
|
218
|
+
throw new Error(`unsafe entry name: ${rawName}`);
|
|
219
|
+
}
|
|
220
|
+
const parts = name.split('/').filter((s) => s !== '' && s !== '.');
|
|
221
|
+
if (parts.length === 0 || parts.includes('..')) throw new Error(`unsafe entry name: ${rawName}`);
|
|
222
|
+
const target = path.join(destDir, ...parts);
|
|
223
|
+
const rel = path.relative(destDir, target);
|
|
224
|
+
if (rel.startsWith('..') || path.isAbsolute(rel)) throw new Error(`entry escapes destination: ${rawName}`);
|
|
225
|
+
const isDir = name.endsWith('/');
|
|
226
|
+
if (isDir) {
|
|
227
|
+
fs.mkdirSync(target, { recursive: true });
|
|
228
|
+
continue;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
if (buf.readUInt32LE(localOff) !== 0x04034b50) throw new Error(`bad local header: ${rawName}`);
|
|
232
|
+
const lhNameLen = buf.readUInt16LE(localOff + 26);
|
|
233
|
+
const lhExtraLen = buf.readUInt16LE(localOff + 28);
|
|
234
|
+
const dataOff = localOff + 30 + lhNameLen + lhExtraLen;
|
|
235
|
+
const comp = buf.subarray(dataOff, dataOff + compSize);
|
|
236
|
+
if (comp.length !== compSize) throw new Error(`truncated entry data: ${rawName}`);
|
|
237
|
+
const data = method === 0 ? Buffer.from(comp) : zlib.inflateRawSync(comp);
|
|
238
|
+
if (data.length !== uncompSize) throw new Error(`size mismatch after inflate: ${rawName}`);
|
|
239
|
+
if (crc32(data) !== crc) throw new Error(`crc mismatch: ${rawName}`);
|
|
240
|
+
fs.mkdirSync(path.dirname(target), { recursive: true });
|
|
241
|
+
fs.writeFileSync(target, data);
|
|
242
|
+
extracted.push(parts.join('/'));
|
|
243
|
+
}
|
|
244
|
+
return extracted;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// --- provisioning ---------------------------------------------------------
|
|
248
|
+
|
|
249
|
+
function sha256File(filePath) {
|
|
250
|
+
const h = crypto.createHash('sha256');
|
|
251
|
+
h.update(fs.readFileSync(filePath));
|
|
252
|
+
return h.digest('hex');
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function checkFile(filePath, want) {
|
|
256
|
+
// want: {name, sha256, size}. Returns null when good, else a reason.
|
|
257
|
+
let st;
|
|
258
|
+
try {
|
|
259
|
+
st = fs.statSync(filePath);
|
|
260
|
+
} catch {
|
|
261
|
+
return 'missing';
|
|
262
|
+
}
|
|
263
|
+
if (!st.isFile()) return 'not a file';
|
|
264
|
+
if (st.size !== want.size) return `size ${st.size} != manifest ${want.size}`;
|
|
265
|
+
const got = sha256File(filePath);
|
|
266
|
+
if (got !== want.sha256.toLowerCase()) return 'sha256 mismatch';
|
|
267
|
+
return null;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// The ONLY fetch call site in this package. The diagnostic path returns
|
|
271
|
+
// before the ensure* functions, so --doctor/--print-manifest provably reach
|
|
272
|
+
// no network: grep `fetch(` — it appears exactly once, here.
|
|
273
|
+
async function downloadToMemory(url) {
|
|
274
|
+
const ctrl = new AbortController();
|
|
275
|
+
const timer = setTimeout(() => ctrl.abort(), DOWNLOAD_TIMEOUT_MS);
|
|
276
|
+
let res;
|
|
277
|
+
try {
|
|
278
|
+
res = await fetch(url, { signal: ctrl.signal });
|
|
279
|
+
} catch (err) {
|
|
280
|
+
throw new Error(`download failed: ${err.message}`);
|
|
281
|
+
} finally {
|
|
282
|
+
clearTimeout(timer);
|
|
283
|
+
}
|
|
284
|
+
if (!res.ok) throw new Error(`download failed: HTTP ${res.status} ${res.statusText}`);
|
|
285
|
+
return Buffer.from(await res.arrayBuffer());
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
async function ensureRuntime(manifest, cacheDir) {
|
|
289
|
+
const rt = manifest.runtime;
|
|
290
|
+
if (isPlaceholderSha(rt.archive.sha256) || !(rt.archive.size > 0)) {
|
|
291
|
+
fail(
|
|
292
|
+
'this package was not published by the release workflow: runtime hashes are placeholders. ' +
|
|
293
|
+
'See docs/PUBLISHING.md: publish only the workflow-built npm-package artifact.'
|
|
294
|
+
);
|
|
295
|
+
}
|
|
296
|
+
if (isPlaceholderSha(rt.server.sha256) || !(rt.server.size > 0)) {
|
|
297
|
+
fail('engine manifest has no release-provisioned server hash; refusing to run.');
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
const markerPath = path.join(cacheDir, '.ready');
|
|
301
|
+
const reuse = (() => {
|
|
302
|
+
try {
|
|
303
|
+
const marker = JSON.parse(fs.readFileSync(markerPath, 'utf8'));
|
|
304
|
+
if (marker.serverVersion !== manifest.serverVersion) return false;
|
|
305
|
+
if (String(marker.bundleSha256).toLowerCase() !== rt.archive.sha256.toLowerCase()) return false;
|
|
306
|
+
} catch {
|
|
307
|
+
return false;
|
|
308
|
+
}
|
|
309
|
+
const bad = checkFile(path.join(cacheDir, rt.server.name), rt.server);
|
|
310
|
+
if (bad) {
|
|
311
|
+
log(`cached ${rt.server.name} invalid (${bad}); re-provisioning.`);
|
|
312
|
+
return false;
|
|
313
|
+
}
|
|
314
|
+
return true;
|
|
315
|
+
})();
|
|
316
|
+
if (reuse) {
|
|
317
|
+
log(`runtime cache ready (reused): ${cacheDir}`);
|
|
318
|
+
return cacheDir;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
const url = `${rt.source}/${rt.archive.name}`;
|
|
322
|
+
log(`provisioning runtime cache: ${cacheDir}`);
|
|
323
|
+
log(`downloading ${url} (${rt.archive.size} bytes)`);
|
|
324
|
+
let data;
|
|
325
|
+
try {
|
|
326
|
+
data = await downloadToMemory(url);
|
|
327
|
+
} catch (err) {
|
|
328
|
+
fail(err.message);
|
|
329
|
+
}
|
|
330
|
+
if (data.length !== rt.archive.size) {
|
|
331
|
+
fail(`download size ${data.length} != manifest ${rt.archive.size}; wrote nothing.`);
|
|
332
|
+
}
|
|
333
|
+
const digest = crypto.createHash('sha256').update(data).digest('hex');
|
|
334
|
+
if (digest !== rt.archive.sha256.toLowerCase()) {
|
|
335
|
+
fail('download sha256 mismatch; wrote nothing.');
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
// Extract into a staging dir, verify, then rename: the target dir is
|
|
339
|
+
// never observed half-written.
|
|
340
|
+
const staging = `${cacheDir}.staging-${process.pid}`;
|
|
341
|
+
fs.rmSync(staging, { recursive: true, force: true });
|
|
342
|
+
fs.rmSync(cacheDir, { recursive: true, force: true });
|
|
343
|
+
fs.mkdirSync(staging, { recursive: true });
|
|
344
|
+
try {
|
|
345
|
+
const names = extractZip(data, staging);
|
|
346
|
+
log(`extracted ${names.length} entries`);
|
|
347
|
+
const bad = checkFile(path.join(staging, rt.server.name), rt.server);
|
|
348
|
+
if (bad) throw new Error(`extracted ${rt.server.name} invalid (${bad})`);
|
|
349
|
+
fs.renameSync(staging, cacheDir);
|
|
350
|
+
fs.writeFileSync(
|
|
351
|
+
markerPath,
|
|
352
|
+
JSON.stringify({ serverVersion: manifest.serverVersion, bundleSha256: rt.archive.sha256.toLowerCase() }, null, 2)
|
|
353
|
+
);
|
|
354
|
+
} catch (err) {
|
|
355
|
+
fs.rmSync(staging, { recursive: true, force: true });
|
|
356
|
+
fs.rmSync(cacheDir, { recursive: true, force: true });
|
|
357
|
+
fail(`provisioning failed (${err.message}); wrote nothing.`);
|
|
358
|
+
}
|
|
359
|
+
log(`runtime cache ready (provisioned): ${cacheDir}`);
|
|
360
|
+
return cacheDir;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
function runServer(cacheDir, manifest) {
|
|
364
|
+
const exe = path.join(cacheDir, manifest.runtime.server.name);
|
|
365
|
+
if (!fs.existsSync(exe)) {
|
|
366
|
+
fail(`server binary missing after provisioning: ${exe}`);
|
|
367
|
+
}
|
|
368
|
+
// Bare puppet with no args serves MCP over stdio (src/main.rs: the server
|
|
369
|
+
// path below the --version early-return). stdio is inherited so the child
|
|
370
|
+
// IS the MCP channel; the launcher writes nothing to stdout on this path.
|
|
371
|
+
// Ctrl+C reaches both processes on Windows (shared console), so no
|
|
372
|
+
// signal relay is needed; the exit code is forwarded below.
|
|
373
|
+
const child = spawn(exe, [], { stdio: 'inherit', env: { ...process.env } });
|
|
374
|
+
child.on('error', (err) => fail(`cannot start ${exe}: ${err.message}`));
|
|
375
|
+
child.on('exit', (code, signal) => {
|
|
376
|
+
if (signal) {
|
|
377
|
+
log(`server terminated by signal ${signal}`);
|
|
378
|
+
process.exit(1);
|
|
379
|
+
}
|
|
380
|
+
process.exit(code === null ? 1 : code);
|
|
381
|
+
});
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
async function main() {
|
|
385
|
+
const args = process.argv.slice(2);
|
|
386
|
+
const manifest = loadManifest();
|
|
387
|
+
if (manifest.package !== 'puppet-mcp') fail('engine manifest names the wrong package.');
|
|
388
|
+
if (args.includes('--help') || args.includes('-h')) usage(0, process.stdout);
|
|
389
|
+
if (args.includes('--doctor') || args.includes('--print-manifest')) {
|
|
390
|
+
const known = new Set(['--doctor', '--print-manifest']);
|
|
391
|
+
const unknown = args.filter((a) => !known.has(a));
|
|
392
|
+
if (unknown.length > 0) usage(2, process.stderr);
|
|
393
|
+
printDoctor(manifest); // exits 0; no network on this path by construction
|
|
394
|
+
}
|
|
395
|
+
// Every known flag returns above; anything left is unknown.
|
|
396
|
+
if (args.length > 0) usage(2, process.stderr);
|
|
397
|
+
|
|
398
|
+
if (process.platform !== 'win32' && process.env[PLATFORM_OVERRIDE_ENV] !== '1') {
|
|
399
|
+
fail(
|
|
400
|
+
`unsupported platform '${process.platform}': puppet-mcp ships the windows-x86_64 server only. ` +
|
|
401
|
+
`Set ${PLATFORM_OVERRIDE_ENV}=1 to bypass this gate for diagnostics (the server still will not run elsewhere).`
|
|
402
|
+
);
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
const runtime = resolveRuntimeDir(manifest);
|
|
406
|
+
if (runtime.dir.startsWith('<unresolvable>')) {
|
|
407
|
+
fail(`cannot resolve runtime cache dir: ${runtime.notes.join('; ')}. Set ${RUNTIME_CACHE_OVERRIDE_ENV} to an absolute directory.`);
|
|
408
|
+
}
|
|
409
|
+
const readyRuntime = await ensureRuntime(manifest, runtime.dir);
|
|
410
|
+
runServer(readyRuntime, manifest);
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
main().catch((err) => fail(err && err.message ? err.message : String(err)));
|
package/engine-manifest.json
CHANGED
|
@@ -1,18 +1,18 @@
|
|
|
1
1
|
{
|
|
2
2
|
"_comment": "Pinned server manifest for the puppet-mcp launcher. The published package holds ONLY this launcher plus this manifest (no server bytes). On first run the launcher downloads ONLY runtime.archive from the tagged GitHub Release, verifying its sha256 before and after extraction. The release workflow fills the runtime.* hashes it computes from the real build artifacts; the committed manifest carries placeholders the launcher refuses to run with.",
|
|
3
3
|
"package": "puppet-mcp",
|
|
4
|
-
"serverVersion": "4.
|
|
4
|
+
"serverVersion": "4.4.1",
|
|
5
5
|
"runtime": {
|
|
6
|
-
"source": "https://github.com/nathwn12/puppet/releases/download/v4.
|
|
6
|
+
"source": "https://github.com/nathwn12/puppet/releases/download/v4.4.1",
|
|
7
7
|
"archive": {
|
|
8
|
-
"name": "puppet-v4.
|
|
9
|
-
"sha256": "
|
|
10
|
-
"size":
|
|
8
|
+
"name": "puppet-v4.4.1-windows-x86_64.zip",
|
|
9
|
+
"sha256": "75118f351735b9d73c98a116b487c5c2c6db090cd34dd5d9ce85defc00bbd583",
|
|
10
|
+
"size": 565857
|
|
11
11
|
},
|
|
12
12
|
"server": {
|
|
13
13
|
"name": "puppet.exe",
|
|
14
|
-
"sha256": "
|
|
15
|
-
"size":
|
|
14
|
+
"sha256": "3fd3132647b273193386695889d66a2e6d1e0768772eb9efd824f3f465a2fbd6",
|
|
15
|
+
"size": 1481216
|
|
16
16
|
}
|
|
17
17
|
}
|
|
18
18
|
}
|
package/package.json
CHANGED
|
@@ -1,23 +1,23 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "puppet-mcp",
|
|
3
|
-
"version": "4.
|
|
4
|
-
"description": "Windows desktop-control MCP server (npx launcher): provisions the pinned server binary from the GitHub Release on first run, then serves MCP over stdio",
|
|
5
|
-
"license": "MIT",
|
|
6
|
-
"repository": {
|
|
7
|
-
"type": "git",
|
|
8
|
-
"url": "https://github.com/nathwn12/puppet"
|
|
9
|
-
},
|
|
10
|
-
"homepage": "https://github.com/nathwn12/puppet",
|
|
11
|
-
"engines": {
|
|
12
|
-
"node": ">=22.14.0"
|
|
13
|
-
},
|
|
14
|
-
"bin": {
|
|
15
|
-
"puppet-mcp": "./bin/puppet-mcp.js"
|
|
16
|
-
},
|
|
17
|
-
"files": [
|
|
18
|
-
"bin/",
|
|
19
|
-
"engine-manifest.json",
|
|
20
|
-
"LICENSE",
|
|
21
|
-
"README.md"
|
|
22
|
-
]
|
|
23
|
-
}
|
|
1
|
+
{
|
|
2
|
+
"name": "puppet-mcp",
|
|
3
|
+
"version": "4.4.1",
|
|
4
|
+
"description": "Windows desktop-control MCP server (npx launcher): provisions the pinned server binary from the GitHub Release on first run, then serves MCP over stdio",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "https://github.com/nathwn12/puppet"
|
|
9
|
+
},
|
|
10
|
+
"homepage": "https://github.com/nathwn12/puppet",
|
|
11
|
+
"engines": {
|
|
12
|
+
"node": ">=22.14.0"
|
|
13
|
+
},
|
|
14
|
+
"bin": {
|
|
15
|
+
"puppet-mcp": "./bin/puppet-mcp.js"
|
|
16
|
+
},
|
|
17
|
+
"files": [
|
|
18
|
+
"bin/",
|
|
19
|
+
"engine-manifest.json",
|
|
20
|
+
"LICENSE",
|
|
21
|
+
"README.md"
|
|
22
|
+
]
|
|
23
|
+
}
|