@passioncode-ai/passioncode 0.1.53 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,91 @@
1
+ 'use strict';
2
+ /**
3
+ * One proving call against a stdio MCP server, the way a coding agent starts it: the registered
4
+ * command, arguments and environment; `initialize`, `notifications/initialized`, `tools/list`,
5
+ * then one read-only tool. The child is the probe's own process and is stopped when the call
6
+ * returns or the deadline passes. Nothing the server prints is logged; only the outcome is kept.
7
+ */
8
+ const { spawn } = require('child_process');
9
+
10
+ const PROTOCOL = '2025-06-18';
11
+
12
+ function probe({ command, args = [], env = {}, cwd, tool = null, toolArgs = {}, timeoutMs = 20000, version = '0.0.0' }) {
13
+ return new Promise((resolve) => {
14
+ let child;
15
+ try {
16
+ child = spawn(command, args, { cwd, env: { ...process.env, ...env }, stdio: ['pipe', 'pipe', 'pipe'] });
17
+ } catch (error) {
18
+ resolve({ ok: false, stage: 'start', reason: error.message });
19
+ return;
20
+ }
21
+ const result = { ok: false, stage: 'start', server: null, tools: null, reason: null };
22
+ let buffer = '';
23
+ let done = false;
24
+ const pending = new Map();
25
+ let nextId = 1;
26
+ const finish = (patch) => {
27
+ if (done) return;
28
+ done = true;
29
+ clearTimeout(timer);
30
+ Object.assign(result, patch);
31
+ // Release every pipe (a wrapper that does not exec, whose own child holds them, must not keep
32
+ // the launcher alive), ask the server to stop, and wait for it — at most 2.5 s, then SIGKILL —
33
+ // so no probe outlives the command as an orphan.
34
+ for (const stream of [child.stdin, child.stdout, child.stderr]) { try { stream.destroy(); } catch (_) { /* gone */ } }
35
+ let settled = false;
36
+ const settle = () => { if (settled) return; settled = true; clearTimeout(hard); try { child.unref(); } catch (_) { /* gone */ } resolve(result); };
37
+ const hard = setTimeout(() => { try { child.kill('SIGKILL'); } catch (_) { /* gone */ } settle(); }, 2500);
38
+ if (child.exitCode !== null || child.signalCode !== null) { settle(); return; }
39
+ child.once('exit', settle);
40
+ try { child.kill('SIGTERM'); } catch (_) { settle(); }
41
+ };
42
+ const timer = setTimeout(() => finish({ ok: false, reason: `no answer to ${result.stage} within ${Math.round(timeoutMs / 1000)} s` }), timeoutMs);
43
+ const send = (method, params) => new Promise((ok) => {
44
+ const id = nextId++;
45
+ pending.set(id, ok);
46
+ try { child.stdin.write(`${JSON.stringify({ jsonrpc: '2.0', id, method, params })}\n`); } catch (error) { finish({ ok: false, reason: error.message }); }
47
+ });
48
+ const notify = (method, params) => { try { child.stdin.write(`${JSON.stringify({ jsonrpc: '2.0', method, ...(params ? { params } : {}) })}\n`); } catch (_) { /* reported by exit */ } };
49
+ child.on('error', (error) => finish({ ok: false, reason: error.code === 'ENOENT' ? `${command} does not exist` : error.message }));
50
+ child.on('exit', (code, signal) => finish({ ok: false, reason: `the server exited (${signal || `code ${code}`}) during ${result.stage}` }));
51
+ child.stderr.on('data', () => { /* servers log on stderr; never echoed */ });
52
+ child.stdout.on('data', (chunk) => {
53
+ buffer += chunk.toString('utf8');
54
+ let nl;
55
+ while ((nl = buffer.indexOf('\n')) >= 0) {
56
+ const line = buffer.slice(0, nl).trim();
57
+ buffer = buffer.slice(nl + 1);
58
+ if (!line) continue;
59
+ let msg;
60
+ try { msg = JSON.parse(line); } catch (_) { finish({ ok: false, reason: 'the server wrote something that is not JSON-RPC on stdout' }); return; }
61
+ if (msg.id !== undefined && pending.has(msg.id)) { const ok = pending.get(msg.id); pending.delete(msg.id); ok(msg); }
62
+ }
63
+ });
64
+ (async () => {
65
+ result.stage = 'initialize';
66
+ const init = await send('initialize', { protocolVersion: PROTOCOL, capabilities: {}, clientInfo: { name: 'passioncode-doctor', version } });
67
+ if (init.error || !init.result) { finish({ ok: false, reason: `initialize refused: ${(init.error && init.error.message) || 'no result'}` }); return; }
68
+ result.server = init.result.serverInfo ? { name: init.result.serverInfo.name, version: init.result.serverInfo.version } : null;
69
+ notify('notifications/initialized');
70
+ // tool === false: initialize only (a server whose first real call has an effect).
71
+ if (tool === false) { finish({ ok: true, stage: 'initialize' }); return; }
72
+ result.stage = 'tools/list';
73
+ const list = await send('tools/list', {});
74
+ if (list.error || !list.result || !Array.isArray(list.result.tools)) { finish({ ok: false, reason: `tools/list failed: ${(list.error && list.error.message) || 'no tools'}` }); return; }
75
+ result.tools = list.result.tools.length;
76
+ if (!tool) { finish({ ok: true }); return; }
77
+ if (!list.result.tools.some((t) => t.name === tool)) { finish({ ok: false, reason: `the server lists no ${tool} tool` }); return; }
78
+ result.stage = `tools/call ${tool}`;
79
+ const call = await send('tools/call', { name: tool, arguments: toolArgs });
80
+ if (call.error) { finish({ ok: false, reason: `${tool} failed: ${call.error.message}` }); return; }
81
+ if (call.result && call.result.isError) {
82
+ const text = (call.result.content || []).map((c) => c.text || '').join(' ').slice(0, 200);
83
+ finish({ ok: false, reason: `${tool} answered with an error${text ? `: ${text}` : ''}` });
84
+ return;
85
+ }
86
+ finish({ ok: true });
87
+ })().catch((error) => finish({ ok: false, reason: error.message }));
88
+ });
89
+ }
90
+
91
+ module.exports = { probe, PROTOCOL };
package/lib/openpgp.js ADDED
@@ -0,0 +1,209 @@
1
+ 'use strict';
2
+ /**
3
+ * The one OpenPGP check the launcher needs: a detached v4 Ed25519 signature over SHA256SUMS,
4
+ * made by the PassionCode.ai release key. Every product release publishes SHA256SUMS and
5
+ * SHA256SUMS.asc (passioncode-ai/.github release-signing, `release-publish@v1`); the key is
6
+ * bundled as lib/release-key.asc and pinned here by fingerprint, so no gpg and no keyserver are
7
+ * involved and a key swapped in the package would not verify either.
8
+ *
9
+ * Scope on purpose: v4 packets, public-key algorithm 22 (EdDSA, curve Ed25519), hash SHA-256 or
10
+ * SHA-512, signature type 0x00 (binary document). Anything else is refused, never guessed.
11
+ */
12
+ const crypto = require('crypto');
13
+ const fs = require('fs');
14
+ const path = require('path');
15
+
16
+ const RELEASE_KEY_FINGERPRINT = '63B30DC324BD697487AA31944FAFB8AEC803B6A7';
17
+ const RELEASE_KEY_FILE = path.join(__dirname, 'release-key.asc');
18
+ const ED25519_OID = '2b06010401da470f01';
19
+ const HASHES = { 8: 'sha256', 10: 'sha512' };
20
+
21
+ function dearmor(text) {
22
+ const lines = String(text).replace(/\r/g, '').split('\n');
23
+ const start = lines.findIndex((l) => /^-----BEGIN PGP (SIGNATURE|PUBLIC KEY BLOCK)-----$/.test(l));
24
+ const end = lines.findIndex((l, i) => i > start && /^-----END PGP /.test(l));
25
+ if (start < 0 || end < 0) throw new Error('no OpenPGP armour');
26
+ let i = start + 1;
27
+ while (i < end && lines[i].trim() !== '') i += 1; // armour headers
28
+ const body = lines.slice(i + 1, end).filter((l) => !l.startsWith('=')).join('');
29
+ if (!/^[A-Za-z0-9+/=]+$/.test(body)) throw new Error('damaged OpenPGP armour');
30
+ return Buffer.from(body, 'base64');
31
+ }
32
+
33
+ function packets(buf) {
34
+ const out = [];
35
+ let i = 0;
36
+ while (i < buf.length) {
37
+ const ctb = buf[i];
38
+ if (!(ctb & 0x80)) throw new Error('not an OpenPGP packet');
39
+ let tag, len, off;
40
+ if (ctb & 0x40) {
41
+ tag = ctb & 0x3f;
42
+ const o = buf[i + 1];
43
+ if (o < 192) { len = o; off = i + 2; }
44
+ else if (o < 224) { len = ((o - 192) << 8) + buf[i + 2] + 192; off = i + 3; }
45
+ else if (o === 255) { len = buf.readUInt32BE(i + 2); off = i + 6; }
46
+ else throw new Error('partial-length packets are not supported');
47
+ } else {
48
+ tag = (ctb >> 2) & 0x0f;
49
+ const t = ctb & 3;
50
+ if (t === 0) { len = buf[i + 1]; off = i + 2; }
51
+ else if (t === 1) { len = buf.readUInt16BE(i + 1); off = i + 3; }
52
+ else if (t === 2) { len = buf.readUInt32BE(i + 1); off = i + 5; }
53
+ else throw new Error('indeterminate-length packets are not supported');
54
+ }
55
+ if (off + len > buf.length) throw new Error('truncated OpenPGP packet');
56
+ out.push({ tag, body: buf.subarray(off, off + len) });
57
+ i = off + len;
58
+ }
59
+ return out;
60
+ }
61
+
62
+ function mpi(buf, at) {
63
+ const bits = buf.readUInt16BE(at);
64
+ const bytes = (bits + 7) >> 3;
65
+ return { value: buf.subarray(at + 2, at + 2 + bytes), next: at + 2 + bytes };
66
+ }
67
+
68
+ function subpackets(buf) {
69
+ const out = [];
70
+ let i = 0;
71
+ while (i < buf.length) {
72
+ let len = buf[i];
73
+ let off = i + 1;
74
+ if (len >= 192 && len < 255) { len = ((len - 192) << 8) + buf[i + 1] + 192; off = i + 2; }
75
+ else if (len === 255) { len = buf.readUInt32BE(i + 1); off = i + 5; }
76
+ out.push({ type: buf[off] & 0x7f, data: buf.subarray(off + 1, off + len) });
77
+ i = off + len;
78
+ }
79
+ return out;
80
+ }
81
+
82
+ function parseSignature(body) {
83
+ if (body[0] !== 4) throw new Error(`signature version ${body[0]} is not supported`);
84
+ const type = body[1], algo = body[2], hash = body[3];
85
+ const hashedLen = body.readUInt16BE(4);
86
+ const hashed = body.subarray(6, 6 + hashedLen);
87
+ const unhashedLen = body.readUInt16BE(6 + hashedLen);
88
+ const unhashed = body.subarray(8 + hashedLen, 8 + hashedLen + unhashedLen);
89
+ let at = 8 + hashedLen + unhashedLen;
90
+ const left16 = body.subarray(at, at + 2); at += 2;
91
+ const r = mpi(body, at); const s = mpi(body, r.next);
92
+ const sub = [...subpackets(hashed), ...subpackets(unhashed)];
93
+ const fprSub = sub.find((p) => p.type === 33);
94
+ const created = subpackets(hashed).find((p) => p.type === 2);
95
+ return {
96
+ type, algo, hash, left16, r: r.value, s: s.value, hashedSub: subpackets(hashed),
97
+ signed: body.subarray(0, 6 + hashedLen),
98
+ issuer: fprSub ? fprSub.data.subarray(1).toString('hex').toUpperCase() : null,
99
+ createdAt: created ? new Date(created.data.readUInt32BE(0) * 1000) : null,
100
+ };
101
+ }
102
+
103
+ function pad32(b) { return b.length >= 32 ? b.subarray(b.length - 32) : Buffer.concat([Buffer.alloc(32 - b.length), b]); }
104
+
105
+ function sigDigest(sig, data) {
106
+ const name = HASHES[sig.hash];
107
+ if (!name) throw new Error(`hash algorithm ${sig.hash} is not supported`);
108
+ const trailer = Buffer.alloc(6);
109
+ trailer[0] = 4; trailer[1] = 0xff; trailer.writeUInt32BE(sig.signed.length, 2);
110
+ return crypto.createHash(name).update(data).update(sig.signed).update(trailer).digest();
111
+ }
112
+
113
+ function edVerify(rawKey, digest, sig) {
114
+ const key = crypto.createPublicKey({ key: { kty: 'OKP', crv: 'Ed25519', x: rawKey.toString('base64url') }, format: 'jwk' });
115
+ return crypto.verify(null, digest, key, Buffer.concat([pad32(sig.r), pad32(sig.s)]));
116
+ }
117
+
118
+ /** The primary key of an armoured public key block: fingerprint, raw Ed25519 point, expiry. */
119
+ function readPublicKey(armoured) {
120
+ const list = packets(dearmor(armoured));
121
+ const pk = list.find((p) => p.tag === 6);
122
+ if (!pk) throw new Error('no public key packet');
123
+ const b = pk.body;
124
+ if (b[0] !== 4 || b[5] !== 22) throw new Error('the release key must be a v4 EdDSA key');
125
+ const oidLen = b[6];
126
+ if (b.subarray(7, 7 + oidLen).toString('hex') !== ED25519_OID) throw new Error('the release key must be on Ed25519');
127
+ const point = mpi(b, 7 + oidLen).value;
128
+ if (point[0] !== 0x40 || point.length !== 33) throw new Error('unexpected Ed25519 point encoding');
129
+ const header = Buffer.from([0x99, b.length >> 8, b.length & 0xff]);
130
+ const fingerprint = crypto.createHash('sha1').update(header).update(b).digest('hex').toUpperCase();
131
+ const createdAt = new Date(b.readUInt32BE(1) * 1000);
132
+ const raw = point.subarray(1);
133
+ // The newest valid self-signature over the user id (types 0x10–0x13) carries the key's expiry.
134
+ let expiresAt = null;
135
+ const uidIndex = list.findIndex((p) => p.tag === 13);
136
+ if (uidIndex >= 0) {
137
+ const uid = list[uidIndex].body;
138
+ for (const p of list.slice(uidIndex + 1)) {
139
+ if (p.tag !== 2) { if (p.tag === 13 || p.tag === 14) break; continue; }
140
+ const sig = parseSignature(p.body);
141
+ if (sig.type < 0x10 || sig.type > 0x13 || sig.issuer !== fingerprint) continue;
142
+ const uidHeader = Buffer.alloc(5); uidHeader[0] = 0xb4; uidHeader.writeUInt32BE(uid.length, 1);
143
+ const signedData = Buffer.concat([header, b, uidHeader, uid]);
144
+ if (!edVerify(raw, sigDigest(sig, signedData), sig)) continue;
145
+ const exp = sig.hashedSub.find((s) => s.type === 9);
146
+ expiresAt = exp ? new Date(createdAt.getTime() + exp.data.readUInt32BE(0) * 1000) : null;
147
+ }
148
+ }
149
+ return { fingerprint, raw, createdAt, expiresAt };
150
+ }
151
+
152
+ let bundled = null;
153
+ /**
154
+ * The trusted release key. PASSIONCODE_RELEASE_KEY (an armoured key file) replaces it for tests,
155
+ * like the launcher's other PASSIONCODE_* overrides: trusted local configuration, not a sandbox;
156
+ * setup and doctor name the override in their output whenever it is set.
157
+ */
158
+ function releaseKey() {
159
+ if (process.env.PASSIONCODE_RELEASE_KEY) return readPublicKey(fs.readFileSync(process.env.PASSIONCODE_RELEASE_KEY, 'utf8'));
160
+ if (!bundled) {
161
+ bundled = readPublicKey(fs.readFileSync(RELEASE_KEY_FILE, 'utf8'));
162
+ if (bundled.fingerprint !== RELEASE_KEY_FINGERPRINT) throw new Error('the bundled release key does not match its pinned fingerprint');
163
+ }
164
+ return bundled;
165
+ }
166
+
167
+ /**
168
+ * Verify `data` against an armoured detached signature by the PassionCode.ai release key.
169
+ * Returns { ok, reason?, fingerprint?, createdAt? }; it never throws on bad input.
170
+ */
171
+ function verifyDetached(data, armouredSignature, { key, now = new Date() } = {}) {
172
+ try { key = key || releaseKey(); } catch (error) { return { ok: false, reason: `release key: ${error.message}` }; }
173
+ let sig;
174
+ try {
175
+ const p = packets(dearmor(armouredSignature)).find((x) => x.tag === 2);
176
+ if (!p) return { ok: false, reason: 'no signature packet in SHA256SUMS.asc' };
177
+ sig = parseSignature(p.body);
178
+ } catch (error) {
179
+ return { ok: false, reason: `unreadable signature: ${error.message}` };
180
+ }
181
+ if (sig.type !== 0x00) return { ok: false, reason: `signature type ${sig.type} is not a document signature` };
182
+ if (sig.algo !== 22) return { ok: false, reason: `signature algorithm ${sig.algo} is not EdDSA` };
183
+ if (sig.issuer && sig.issuer !== key.fingerprint) {
184
+ return { ok: false, reason: `signed by ${sig.issuer}, which is not the PassionCode.ai release key ${key.fingerprint}` };
185
+ }
186
+ let digest;
187
+ try { digest = sigDigest(sig, data); } catch (error) { return { ok: false, reason: error.message }; }
188
+ if (!digest.subarray(0, 2).equals(sig.left16)) return { ok: false, reason: 'the signature does not match SHA256SUMS (digest prefix differs)' };
189
+ let good = false;
190
+ try { good = edVerify(key.raw, digest, sig); } catch (_) { good = false; }
191
+ if (!good) return { ok: false, reason: 'the signature does not match SHA256SUMS' };
192
+ if (key.expiresAt && sig.createdAt && sig.createdAt > key.expiresAt) return { ok: false, reason: `signed after the release key expired (${key.expiresAt.toISOString()})` };
193
+ if (sig.createdAt && sig.createdAt.getTime() > now.getTime() + 24 * 3600 * 1000) return { ok: false, reason: 'the signature is dated in the future' };
194
+ return { ok: true, fingerprint: key.fingerprint, createdAt: sig.createdAt };
195
+ }
196
+
197
+ /** SHA256SUMS → Map(name → hex). A line that is not `<64 hex> <name>` is an error, not skipped. */
198
+ function parseSums(text) {
199
+ const map = new Map();
200
+ String(text).split('\n').forEach((line, i) => {
201
+ if (!line.trim()) return;
202
+ const m = /^([0-9a-f]{64}) [ *](.+)$/.exec(line.trimEnd());
203
+ if (!m) throw new Error(`SHA256SUMS line ${i + 1} is not "<sha256> <file>"`);
204
+ map.set(m[2], m[1]);
205
+ });
206
+ return map;
207
+ }
208
+
209
+ module.exports = { verifyDetached, readPublicKey, releaseKey, parseSums, RELEASE_KEY_FINGERPRINT };
@@ -0,0 +1,10 @@
1
+ -----BEGIN PGP PUBLIC KEY BLOCK-----
2
+
3
+ mDMEasEA0BYJKwYBBAHaRw8BAQdAzg/il4KR76Liuy7pPkEmveL2sTU8kh3FZn+H
4
+ pfrbJV+0MFBhc3Npb25Db2RlLmFpIHJlbGVhc2VzIDxjb250YWN0QHBhc3Npb25j
5
+ b2RlLmFpPoi1BBMWCgBdFiEEY7MNwyS9aXSHqjGUT6+4rsgDtqcFAmrBANAbFIAA
6
+ AAAABAAObWFudTIsMi41KzEuMTIsMCwzAhsDBQkFo5qABQsJCAcCAiICBhUKCQgL
7
+ AgQWAgMBAh4HAheAAAoJEE+vuK7IA7anM7sBAItPsg0cAR8JcR97aXmgmg+X44cZ
8
+ PhciNxImjH/WhJBjAP9xU0g3+Y/af6sNeoV8PM2w1K1echJaa97VLI38Z/8lCA==
9
+ =5KqR
10
+ -----END PGP PUBLIC KEY BLOCK-----
@@ -0,0 +1,211 @@
1
+ 'use strict';
2
+ /**
3
+ * Latest releases of the PassionCode.ai apps, and an install that trusts no byte it has not
4
+ * verified (ADR-0004): SHA256SUMS.asc against the bundled release key, the asset against both its
5
+ * SHA256SUMS line and GitHub's digest, then the extracted app's Developer ID signature, team,
6
+ * stapled notarization ticket and Gatekeeper assessment. A failure removes the download.
7
+ */
8
+ const { spawnSync } = require('child_process');
9
+ const crypto = require('crypto');
10
+ const fs = require('fs');
11
+ const os = require('os');
12
+ const path = require('path');
13
+ const pgp = require('./openpgp');
14
+ const catalog = require('./catalog');
15
+
16
+ const API = () => (process.env.PASSIONCODE_GITHUB_API || 'https://api.github.com').replace(/\/$/, '');
17
+ const CACHE_TTL_MS = 30 * 60 * 1000;
18
+
19
+ function run(cmd, args, { timeout = 60000, input } = {}) {
20
+ const r = spawnSync(cmd, args, { encoding: 'utf8', timeout, input });
21
+ if (r.error && r.error.code === 'ENOENT') return { code: 127, out: '', err: `${cmd} not found` };
22
+ if (r.error && r.error.code === 'ETIMEDOUT') return { code: 124, out: r.stdout || '', err: `${cmd} timed out after ${Math.round(timeout / 1000)} s` };
23
+ return { code: r.status === null ? 1 : r.status, out: r.stdout || '', err: r.stderr || '' };
24
+ }
25
+
26
+ async function getJson(url) {
27
+ const res = await fetch(url, { headers: { Accept: 'application/vnd.github+json', 'User-Agent': 'passioncode-launcher' }, signal: AbortSignal.timeout(15000) });
28
+ if (!res.ok) throw new Error(`${url} answered HTTP ${res.status}`);
29
+ return res.json();
30
+ }
31
+
32
+ /**
33
+ * The newest published release of a product: `releases/latest` for stable products, the newest
34
+ * non-draft release for one that ships prereleases only (Fabric). Cached for 30 minutes in
35
+ * ~/.passioncode/releases-cache.json, because unauthenticated GitHub allows 60 calls an hour.
36
+ */
37
+ async function latestRelease(product, { cacheFile = null, now = Date.now() } = {}) {
38
+ let cache = {};
39
+ if (cacheFile) { try { cache = JSON.parse(fs.readFileSync(cacheFile, 'utf8')); } catch (_) { cache = {}; } }
40
+ const hit = cache[product.repo];
41
+ if (hit && now - Date.parse(hit.at) < CACHE_TTL_MS && hit.release) return hit.release;
42
+ let raw;
43
+ if (product.prerelease) {
44
+ const list = await getJson(`${API()}/repos/${product.repo}/releases?per_page=10`);
45
+ raw = (Array.isArray(list) ? list : []).find((r) => !r.draft);
46
+ if (!raw) throw new Error(`${product.repo} has no published release`);
47
+ } else {
48
+ raw = await getJson(`${API()}/repos/${product.repo}/releases/latest`);
49
+ }
50
+ const release = {
51
+ tag: raw.tag_name, version: String(raw.tag_name || '').replace(/^v/, ''), prerelease: !!raw.prerelease, url: raw.html_url,
52
+ assets: (raw.assets || []).map((a) => ({ name: a.name, url: a.browser_download_url, size: a.size, digest: a.digest || null })),
53
+ };
54
+ if (!/^\d+\.\d+\.\d+/.test(release.version)) throw new Error(`${product.repo}: unexpected tag ${raw.tag_name}`);
55
+ if (cacheFile) {
56
+ cache[product.repo] = { at: new Date(now).toISOString(), release };
57
+ try { fs.mkdirSync(path.dirname(cacheFile), { recursive: true }); fs.writeFileSync(cacheFile, JSON.stringify(cache, null, 2)); } catch (_) { /* a cache only */ }
58
+ }
59
+ return release;
60
+ }
61
+
62
+ async function download(url, dest) {
63
+ const res = await fetch(url, { headers: { 'User-Agent': 'passioncode-launcher' }, redirect: 'follow', signal: AbortSignal.timeout(30 * 60 * 1000) });
64
+ if (!res.ok || !res.body) throw new Error(`download ${url}: HTTP ${res.status}`);
65
+ const hash = crypto.createHash('sha256');
66
+ const fd = fs.openSync(dest, 'w');
67
+ try {
68
+ for await (const chunk of res.body) { hash.update(chunk); fs.writeSync(fd, chunk); }
69
+ } finally { fs.closeSync(fd); }
70
+ return hash.digest('hex');
71
+ }
72
+
73
+ /** Free bytes on the volume holding `dir` (df -k), or null when it cannot be read. */
74
+ function freeBytes(dir) {
75
+ const r = run('df', ['-k', dir], { timeout: 10000 });
76
+ const line = r.out.trim().split('\n').pop() || '';
77
+ const cols = line.split(/\s+/);
78
+ const kb = Number(cols[3]);
79
+ return r.code === 0 && Number.isFinite(kb) ? kb * 1024 : null;
80
+ }
81
+
82
+ /** Checks of an app bundle on disk: Developer ID signature, the PassionCode.ai team, stapled ticket, Gatekeeper. */
83
+ function verifyApp(appDir, { gatekeeper = true } = {}) {
84
+ const checks = [];
85
+ const sig = run('codesign', ['--verify', '--strict', appDir], { timeout: 120000 });
86
+ checks.push({ check: 'signature', ok: sig.code === 0, detail: sig.code === 0 ? 'codesign --verify --strict: valid' : (sig.err || sig.out).trim().split('\n')[0] });
87
+ const info = run('codesign', ['-dv', appDir], { timeout: 30000 });
88
+ const team = /TeamIdentifier=(\S+)/.exec(`${info.err}\n${info.out}`);
89
+ checks.push({ check: 'team', ok: !!team && team[1] === catalog.TEAM_ID, detail: team ? `team ${team[1]}` : 'no team identifier' });
90
+ const staple = run('xcrun', ['stapler', 'validate', appDir], { timeout: 60000 });
91
+ checks.push({ check: 'notarization', ok: staple.code === 0, detail: staple.code === 0 ? 'notarization ticket stapled' : (staple.err || staple.out || 'stapler validate failed').trim().split('\n').pop() });
92
+ if (gatekeeper) {
93
+ const gk = run('spctl', ['--assess', '--type', 'execute', '-vv', appDir], { timeout: 120000 });
94
+ const text = `${gk.err}\n${gk.out}`;
95
+ checks.push({ check: 'gatekeeper', ok: gk.code === 0 && /source=Notarized Developer ID/.test(text), detail: gk.code === 124 ? gk.err : (/source=([^\n]+)/.exec(text) || [null, text.trim().split('\n')[0] || 'rejected'])[1] });
96
+ }
97
+ return { ok: checks.every((c) => c.ok), checks };
98
+ }
99
+
100
+ /**
101
+ * Whether anything runs from an app bundle: the app itself or an agent's MCP server that execs
102
+ * into its Contents/MacOS (pgrep; reads only). pgrep answers 0 (found) or 1 (none); any other
103
+ * answer — missing, timed out, an error — is "in use", so a doubt never lets a bundle be moved.
104
+ */
105
+ function isRunning(appDir) {
106
+ return run('pgrep', ['-f', `${appDir}/Contents/MacOS/`], { timeout: 5000 }).code !== 1;
107
+ }
108
+
109
+ /** CFBundleShortVersionString of an installed app, or null. */
110
+ function appVersion(appDir) {
111
+ const r = run('plutil', ['-extract', 'CFBundleShortVersionString', 'raw', '-o', '-', path.join(appDir, 'Contents', 'Info.plist')], { timeout: 10000 });
112
+ const v = r.out.trim();
113
+ return r.code === 0 && v ? v : null;
114
+ }
115
+
116
+ /**
117
+ * Download, verify and install one absent app into the applications folder. `report(step)` gets
118
+ * each stage. Throws with the first failed check; the working directory is always removed.
119
+ */
120
+ async function installApp(product, release, { work, report = () => {}, replace = false, home = os.homedir() }) {
121
+ const target = catalog.appPath(product);
122
+ if (fs.existsSync(target) && !replace) throw new Error(`${target} already exists`);
123
+ if (fs.existsSync(target) && isRunning(target)) throw new Error(`${product.name} is in use (the app, or an agent's MCP server that runs from it); quit it first`);
124
+ const assetName = product.asset(release.version);
125
+ const asset = release.assets.find((a) => a.name === assetName);
126
+ const sums = release.assets.find((a) => a.name === 'SHA256SUMS');
127
+ const sig = release.assets.find((a) => a.name === 'SHA256SUMS.asc');
128
+ if (!asset) throw new Error(`${release.tag} has no ${assetName}`);
129
+ if (!sums || !sig) throw new Error(`${release.tag} publishes no signed SHA256SUMS; nothing unsigned is installed`);
130
+ fs.rmSync(work, { recursive: true, force: true });
131
+ fs.mkdirSync(work, { recursive: true });
132
+ try {
133
+ const free = freeBytes(work);
134
+ const need = (asset.size || 0) * 3;
135
+ if (free !== null && asset.size && free < need) throw new Error(`not enough disk: ${Math.round(free / 1e6)} MB free, about ${Math.round(need / 1e6)} MB needed to download and unpack ${assetName}`);
136
+ const sumsFile = path.join(work, 'SHA256SUMS');
137
+ await download(sums.url, sumsFile);
138
+ await download(sig.url, path.join(work, 'SHA256SUMS.asc'));
139
+ const verdict = pgp.verifyDetached(fs.readFileSync(sumsFile), fs.readFileSync(path.join(work, 'SHA256SUMS.asc'), 'utf8'));
140
+ if (!verdict.ok) throw new Error(`SHA256SUMS signature: ${verdict.reason}`);
141
+ report({ kind: 'verify', detail: `SHA256SUMS of ${release.tag} signed by the PassionCode.ai release key ${pgp.RELEASE_KEY_FINGERPRINT.slice(-16)}` });
142
+ const listed = pgp.parseSums(fs.readFileSync(sumsFile, 'utf8')).get(assetName);
143
+ if (!listed) throw new Error(`SHA256SUMS does not list ${assetName}`);
144
+ const file = path.join(work, assetName);
145
+ const got = await download(asset.url, file);
146
+ if (got !== listed) throw new Error(`${assetName}: SHA-256 ${got} is not the signed ${listed}`);
147
+ if (asset.digest && asset.digest !== `sha256:${got}`) throw new Error(`${assetName}: GitHub's digest ${asset.digest} differs from the download`);
148
+ report({ kind: 'verify', detail: `${assetName}: SHA-256 matches the signed SHA256SUMS${asset.digest ? ' and GitHub\'s digest' : ''}` });
149
+ const unpacked = path.join(work, 'unpacked');
150
+ fs.mkdirSync(unpacked);
151
+ let mount = null;
152
+ try {
153
+ let source;
154
+ if (assetName.endsWith('.dmg')) {
155
+ mount = path.join(work, 'mount');
156
+ fs.mkdirSync(mount);
157
+ const a = run('hdiutil', ['attach', '-nobrowse', '-readonly', '-noautoopen', '-mountpoint', mount, file], { timeout: 300000 });
158
+ if (a.code !== 0) { mount = null; throw new Error(`hdiutil attach: ${(a.err || a.out).trim()}`); }
159
+ source = path.join(mount, product.app);
160
+ } else {
161
+ const x = run('ditto', ['-x', '-k', file, unpacked], { timeout: 600000 });
162
+ if (x.code !== 0) throw new Error(`ditto -x: ${(x.err || x.out).trim()}`);
163
+ source = path.join(unpacked, product.app);
164
+ }
165
+ if (!fs.existsSync(source)) throw new Error(`${assetName} holds no ${product.app}`);
166
+ const app = verifyApp(source);
167
+ for (const c of app.checks) report({ kind: 'verify', detail: `${product.app}: ${c.check} — ${c.detail}`, ok: c.ok });
168
+ if (!app.ok) throw new Error(`${product.app} failed ${app.checks.filter((c) => !c.ok).map((c) => c.check).join(', ')}; nothing was installed`);
169
+ // Copy beside the target first, so /Applications never holds a half-written bundle; then two
170
+ // same-volume renames: the old bundle to the person's Trash (never deleted), the new one into place.
171
+ const staged = `${target}.passioncode-${process.pid}.partial`;
172
+ // A run killed mid-copy leaves its staged copy; remove those whose process is gone.
173
+ for (const name of fs.readdirSync(path.dirname(target))) {
174
+ const m = new RegExp(`^${product.app.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\.passioncode-(\\d+)\\.partial$`).exec(name);
175
+ if (!m) continue;
176
+ let alive = true;
177
+ try { process.kill(Number(m[1]), 0); } catch (error) { alive = error.code === 'EPERM'; }
178
+ if (!alive || Number(m[1]) === process.pid) fs.rmSync(path.join(path.dirname(target), name), { recursive: true, force: true });
179
+ }
180
+ const c = run('ditto', [source, staged], { timeout: 600000 });
181
+ if (c.code !== 0) { fs.rmSync(staged, { recursive: true, force: true }); throw new Error(`copy to ${staged}: ${(c.err || c.out).trim()}`); }
182
+ let previous = null;
183
+ try {
184
+ if (fs.existsSync(target)) {
185
+ // The download took minutes: check again, right before the bundle moves.
186
+ if (isRunning(target)) throw new Error(`${product.name} came into use while it downloaded; quit it, then run setup again`);
187
+ const trash = path.join(home, '.Trash');
188
+ fs.mkdirSync(trash, { recursive: true });
189
+ previous = path.join(trash, `${product.app.replace(/\.app$/, '')} ${appVersion(target) || 'old'} ${new Date().toISOString().replace(/[:.]/g, '-')}.app`);
190
+ fs.renameSync(target, previous);
191
+ report({ kind: 'trash', detail: `moved the previous ${product.name} to ${previous}` });
192
+ }
193
+ try { fs.renameSync(staged, target); } catch (error) {
194
+ if (previous) {
195
+ try { fs.renameSync(previous, target); } catch (back) { throw new Error(`${error.message}; and putting the previous app back failed (${back.message}); it is in ${previous}`); }
196
+ }
197
+ throw error;
198
+ }
199
+ } finally {
200
+ fs.rmSync(staged, { recursive: true, force: true }); // only the staged copy, never the target
201
+ }
202
+ return { path: target, version: release.version, replaced: previous };
203
+ } finally {
204
+ if (mount) run('hdiutil', ['detach', mount, '-quiet'], { timeout: 120000 });
205
+ }
206
+ } finally {
207
+ fs.rmSync(work, { recursive: true, force: true });
208
+ }
209
+ }
210
+
211
+ module.exports = { latestRelease, installApp, verifyApp, appVersion, isRunning, freeBytes, run, download, API };