@empir3/empir3-bridge 0.3.21
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/CHANGELOG.md +1531 -0
- package/CODE_OF_CONDUCT.md +9 -0
- package/CONTRIBUTING.md +75 -0
- package/LICENSE +21 -0
- package/README.md +464 -0
- package/SECURITY.md +130 -0
- package/assets/accuracy-lab.html +2639 -0
- package/assets/api-clis-real.jpg +0 -0
- package/assets/bridge-console-hero.jpg +0 -0
- package/assets/browser-privacy.svg +151 -0
- package/assets/demo-orchestration.svg +74 -0
- package/assets/desktop-select-region.jpg +0 -0
- package/assets/in-page-chat.gif +0 -0
- package/assets/orchestration-hero.svg +126 -0
- package/assets/social-preview.png +0 -0
- package/assets/zara-accent.png +0 -0
- package/build/bootstrap.js +548 -0
- package/build/build.js +680 -0
- package/build/payload-entry.js +649 -0
- package/build/payload-signing-pub.json +7 -0
- package/docs/AGENT_GUIDE.md +259 -0
- package/docs/RELEASE.md +106 -0
- package/docs/SAFETY.md +112 -0
- package/docs/TESTING.md +181 -0
- package/installer/server.js +231 -0
- package/installer/ui/app.js +278 -0
- package/installer/ui/index.html +24 -0
- package/installer/ui/styles.css +146 -0
- package/package.json +95 -0
- package/scripts/bootstrap-e2e.mjs +650 -0
- package/scripts/certify-bridge.mjs +636 -0
- package/scripts/check-companion-surface.mjs +118 -0
- package/scripts/extract-welcome.mjs +64 -0
- package/scripts/gh-route-handler-check.mjs +57 -0
- package/scripts/gh-wire-test.mjs +107 -0
- package/scripts/publish-downloads.mjs +180 -0
- package/scripts/smoke-all-tools.mjs +509 -0
- package/scripts/smoke-live-bridge.mjs +696 -0
- package/scripts/splice-welcome.mjs +63 -0
- package/scripts/welcome-body.txt +2733 -0
- package/src/anthropic-client.ts +192 -0
- package/src/bootstrap-exe.ts +69 -0
- package/src/bridge.ts +2444 -0
- package/src/chat.ts +345 -0
- package/src/cli-runner.ts +239 -0
- package/src/cli.ts +649 -0
- package/src/config.ts +199 -0
- package/src/desktop-overlay.ps1 +121 -0
- package/src/executable-resolver.ts +330 -0
- package/src/handlers/agy-imagegen.ts +179 -0
- package/src/handlers/github-cli.ts +399 -0
- package/src/handlers/higgsfield-cli.ts +783 -0
- package/src/launch.js +337 -0
- package/src/mcp-server.ts +1265 -0
- package/src/pair-claim.ts +218 -0
- package/src/payload-daemon.ts +168 -0
- package/src/server.ts +21036 -0
- package/src/tool-defaults.ts +230 -0
- package/src/update-check.js +136 -0
- package/tray/build.py +76 -0
- package/tray/requirements.txt +2 -0
- package/tray/tray.py +1843 -0
|
@@ -0,0 +1,650 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Local, fully-isolated end-to-end harness for the Go bootstrapper.
|
|
4
|
+
*
|
|
5
|
+
* NEVER touches the real install: every test runs the built
|
|
6
|
+
* build/dist/Empir3Setup.exe with USERPROFILE + APPDATA pointed at a fresh temp
|
|
7
|
+
* dir and EMPIR3_BRIDGE_VERSION_URL pointed at a local HTTP "CDN" serving a
|
|
8
|
+
* test manifest + artifacts. Artifacts are signed with the REAL Ed25519 key
|
|
9
|
+
* (build/payload-signing-key.pem) so the production exe (which embeds the real
|
|
10
|
+
* pubkey) accepts them. The real Node tarball from build/dist is reused for the
|
|
11
|
+
* runtime; payloads are tiny synthetic tarballs whose entry.js records the env
|
|
12
|
+
* it was spawned with and exits fast (no real daemon).
|
|
13
|
+
*
|
|
14
|
+
* Covers the bootstrap test-plan items 1-21.
|
|
15
|
+
* Items that need a real tray/daemon GUI (17) are marked SKIP with a reason
|
|
16
|
+
* (covered by the fresh-machine manual install/uninstall in the release step).
|
|
17
|
+
*
|
|
18
|
+
* Run: node scripts/bootstrap-e2e.mjs
|
|
19
|
+
*/
|
|
20
|
+
import { createServer } from 'http';
|
|
21
|
+
import { spawn, spawnSync, execFileSync } from 'child_process';
|
|
22
|
+
import { createHash, createPrivateKey, sign as edSign } from 'crypto';
|
|
23
|
+
import {
|
|
24
|
+
mkdtempSync, mkdirSync, writeFileSync, readFileSync, existsSync, rmSync,
|
|
25
|
+
copyFileSync, readdirSync, statSync,
|
|
26
|
+
} from 'fs';
|
|
27
|
+
import { tmpdir } from 'os';
|
|
28
|
+
import { join, basename, resolve, dirname } from 'path';
|
|
29
|
+
import { fileURLToPath } from 'url';
|
|
30
|
+
import { createRequire } from 'module';
|
|
31
|
+
|
|
32
|
+
const require = createRequire(import.meta.url);
|
|
33
|
+
const { buildDeterministicTarGz } = require('../build/tar-util.js');
|
|
34
|
+
const { canonicalizeManifest, signManifest } = require('../build/manifest-canonical.js');
|
|
35
|
+
|
|
36
|
+
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
|
37
|
+
const DIST = join(ROOT, 'build', 'dist');
|
|
38
|
+
const EXE = join(DIST, 'Empir3Setup.exe');
|
|
39
|
+
const SIGNING_KEY_PEM = join(ROOT, 'build', 'payload-signing-key.pem');
|
|
40
|
+
const PUB_HEX = JSON.parse(readFileSync(join(ROOT, 'build', 'payload-signing-pub.json'), 'utf8')).publicKeyHex.toLowerCase();
|
|
41
|
+
const LEGACY_BOOTSTRAP = join(ROOT, 'build', 'bootstrap.js');
|
|
42
|
+
|
|
43
|
+
const PAYLOAD_VERSION = JSON.parse(readFileSync(join(ROOT, 'package.json'), 'utf8')).version;
|
|
44
|
+
// Read the stub's BootstrapVersion const from source so tests track bumps.
|
|
45
|
+
const BOOT_VERSION = (readFileSync(join(ROOT, 'build', 'bootstrap-go', 'main.go'), 'utf8')
|
|
46
|
+
.match(/const BootstrapVersion = "([^"]+)"/) || [])[1] || '';
|
|
47
|
+
|
|
48
|
+
const NODE_PIN = JSON.parse(readFileSync(join(ROOT, 'build', 'node-pin.json'), 'utf8'));
|
|
49
|
+
|
|
50
|
+
// Locate the real Node artifact built by build.js (real node.exe inside). Prefer
|
|
51
|
+
// the PINNED version so a stale older node-*.tar.gz in dist can't be picked.
|
|
52
|
+
function findRealNodeTarball() {
|
|
53
|
+
const pinned = `node-win-x64-v${NODE_PIN.version}.tar.gz`;
|
|
54
|
+
const f = existsSync(join(DIST, pinned))
|
|
55
|
+
? pinned
|
|
56
|
+
: readdirSync(DIST).find((n) => /^node-win-x64-v.*\.tar\.gz$/.test(n));
|
|
57
|
+
if (!f) throw new Error('no node-win-x64 tarball in build/dist — run `node build/build.js` first');
|
|
58
|
+
const m = f.match(/v(\d+\.\d+\.\d+)\.tar\.gz$/);
|
|
59
|
+
return { path: join(DIST, f), name: f, version: m[1] };
|
|
60
|
+
}
|
|
61
|
+
const REAL_NODE = findRealNodeTarball();
|
|
62
|
+
|
|
63
|
+
const PRIV = createPrivateKey(readFileSync(SIGNING_KEY_PEM));
|
|
64
|
+
|
|
65
|
+
// ── tiny utils ───────────────────────────────────────────────────────────
|
|
66
|
+
const sha256 = (buf) => createHash('sha256').update(buf).digest('hex');
|
|
67
|
+
const sign = (buf) => edSign(null, buf, PRIV);
|
|
68
|
+
let TMP_ROOTS = [];
|
|
69
|
+
function freshTmp(label) {
|
|
70
|
+
const d = mkdtempSync(join(tmpdir(), `e2e-${label}-`));
|
|
71
|
+
TMP_ROOTS.push(d);
|
|
72
|
+
return d;
|
|
73
|
+
}
|
|
74
|
+
function cleanupAll() { for (const d of TMP_ROOTS) { try { rmSync(d, { recursive: true, force: true }); } catch {} } TMP_ROOTS = []; }
|
|
75
|
+
|
|
76
|
+
// Synthetic payload tarball: entry.js records spawn env to E2E_MARKER_FILE,
|
|
77
|
+
// handles --version / --mcp / job-child modes, exits fast. Works whether the Go
|
|
78
|
+
// stub spawns it (`node entry.js …`) or the legacy bootstrap.js require()s it.
|
|
79
|
+
function syntheticPayloadTarGz(version) {
|
|
80
|
+
const stage = freshTmp('payload-stage');
|
|
81
|
+
const entry = `'use strict';
|
|
82
|
+
const fs = require('fs'); const path = require('path');
|
|
83
|
+
const { spawn } = require('child_process');
|
|
84
|
+
const VERSION = (() => { try { return fs.readFileSync(path.join(process.env.EMPIR3_BRIDGE_PAYLOAD_DIR || __dirname, '.payload-version'), 'utf8').trim(); } catch { return 'dev'; } })();
|
|
85
|
+
function marker(extra) {
|
|
86
|
+
const f = process.env.E2E_MARKER_FILE; if (!f) return;
|
|
87
|
+
fs.writeFileSync(f, JSON.stringify({
|
|
88
|
+
bootstrapExe: process.env.EMPIR3_BOOTSTRAP_EXE || '',
|
|
89
|
+
payloadDir: process.env.EMPIR3_BRIDGE_PAYLOAD_DIR || '',
|
|
90
|
+
payloadVersion: process.env.EMPIR3_BRIDGE_PAYLOAD_VERSION || '',
|
|
91
|
+
bootstrapVersion: process.env.EMPIR3_BRIDGE_BOOTSTRAP_VERSION || '',
|
|
92
|
+
execPath: process.execPath, version: VERSION, ...extra,
|
|
93
|
+
}));
|
|
94
|
+
}
|
|
95
|
+
async function start(argv) {
|
|
96
|
+
argv = argv || process.argv.slice(2);
|
|
97
|
+
const mode = process.env.E2E_MODE || '';
|
|
98
|
+
if (argv.includes('--mcp') || mode === 'mcp') {
|
|
99
|
+
// Respond only AFTER reading a JSON-RPC line from stdin; the harness asserts
|
|
100
|
+
// stdout is byte-exact (the stub added no preamble).
|
|
101
|
+
process.stdin.resume();
|
|
102
|
+
process.stdin.once('data', () => {
|
|
103
|
+
process.stdout.write(JSON.stringify({ jsonrpc: '2.0', id: 1, result: { e2e: 'mcp-ok', v: VERSION } }) + '\\n');
|
|
104
|
+
process.exit(0);
|
|
105
|
+
});
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
if (mode === 'job-child') {
|
|
109
|
+
const child = spawn(process.execPath, ['-e', 'setInterval(()=>{}, 1e9)'], { stdio: 'ignore', detached: false });
|
|
110
|
+
if (process.env.E2E_CHILD_PID_FILE) fs.writeFileSync(process.env.E2E_CHILD_PID_FILE, String(child.pid));
|
|
111
|
+
marker({ kind: 'job-child', childPid: child.pid });
|
|
112
|
+
setInterval(() => {}, 1e9); // linger until the stub (and its job) is killed
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
if (mode === 'launcher-detached') {
|
|
116
|
+
// Stand-in for spawnTrayAndExit(): spawn a DETACHED grandchild that must
|
|
117
|
+
// outlive node AND the stub, then return so node exits immediately.
|
|
118
|
+
const child = spawn(process.execPath, ['-e', 'setInterval(()=>{}, 1e9)'], { stdio: 'ignore', detached: true });
|
|
119
|
+
child.unref();
|
|
120
|
+
if (process.env.E2E_CHILD_PID_FILE) fs.writeFileSync(process.env.E2E_CHILD_PID_FILE, String(child.pid));
|
|
121
|
+
marker({ kind: 'launcher-detached', childPid: child.pid });
|
|
122
|
+
return; // node exits; the detached grandchild must survive
|
|
123
|
+
}
|
|
124
|
+
if (argv.includes('--version') || argv.includes('-v')) { marker({ kind: 'version' }); process.stdout.write(VERSION + '\\n'); return; }
|
|
125
|
+
marker({ kind: 'run' }); process.stdout.write(VERSION + '\\n');
|
|
126
|
+
}
|
|
127
|
+
if (require.main === module) { start(process.argv.slice(2)).catch((e) => { console.error(e); process.exit(1); }); }
|
|
128
|
+
module.exports = { start };
|
|
129
|
+
`;
|
|
130
|
+
writeFileSync(join(stage, 'entry.js'), entry);
|
|
131
|
+
writeFileSync(join(stage, '.payload-version'), version);
|
|
132
|
+
return buildDeterministicTarGz(stage);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// A deliberately malicious tar.gz for the extractor test. typeflag '0' (regular)
|
|
136
|
+
// by default, or '2' (symlink) with a linkname, so we can exercise the stub's
|
|
137
|
+
// rejection of traversal/absolute/drive/UNC/symlink entries.
|
|
138
|
+
function evilTarGz(entryName, { typeflag = '0', linkname = '' } = {}) {
|
|
139
|
+
const zlib = require('zlib');
|
|
140
|
+
const BLOCK = 512;
|
|
141
|
+
const header = Buffer.alloc(BLOCK);
|
|
142
|
+
const data = typeflag === '2' ? Buffer.alloc(0) : Buffer.from('pwned');
|
|
143
|
+
header.write(entryName, 0, 100, 'utf8');
|
|
144
|
+
header.write('0000644', 100, 8); header.write('\0', 107, 1);
|
|
145
|
+
header.write('0000000', 108, 8); header.write('\0', 115, 1);
|
|
146
|
+
header.write('0000000', 116, 8); header.write('\0', 123, 1);
|
|
147
|
+
header.write(data.length.toString(8).padStart(11, '0'), 124, 12); header.write(' ', 135, 1);
|
|
148
|
+
header.write('00000000000', 136, 12); header.write(' ', 147, 1);
|
|
149
|
+
for (let i = 148; i < 156; i++) header[i] = 0x20;
|
|
150
|
+
header.write(typeflag, 156, 1);
|
|
151
|
+
if (linkname) header.write(linkname, 157, 100, 'utf8');
|
|
152
|
+
header.write('ustar\0', 257, 6); header.write('00', 263, 2);
|
|
153
|
+
let sum = 0; for (let i = 0; i < BLOCK; i++) sum += header[i];
|
|
154
|
+
header.write(sum.toString(8).padStart(6, '0'), 148, 6); header[154] = 0; header[155] = 0x20;
|
|
155
|
+
const pad = Buffer.alloc((BLOCK - (data.length % BLOCK)) % BLOCK);
|
|
156
|
+
return zlib.gzipSync(Buffer.concat([header, data, pad, Buffer.alloc(BLOCK * 2)]), { mtime: 0 });
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// ── per-test CDN ───────────────────────────────────────────────────────────
|
|
160
|
+
// A test "world": isolated home/appdata + a local HTTP CDN serving artifacts.
|
|
161
|
+
class World {
|
|
162
|
+
constructor(label) {
|
|
163
|
+
this.label = label;
|
|
164
|
+
this.dir = freshTmp(label);
|
|
165
|
+
this.home = join(this.dir, 'home'); mkdirSync(this.home, { recursive: true });
|
|
166
|
+
this.appdata = join(this.dir, 'appdata'); mkdirSync(this.appdata, { recursive: true });
|
|
167
|
+
this.cdnDir = join(this.dir, 'cdn'); mkdirSync(this.cdnDir, { recursive: true });
|
|
168
|
+
this.files = new Map(); // name -> Buffer
|
|
169
|
+
this.server = null; this.port = 0;
|
|
170
|
+
}
|
|
171
|
+
put(name, buf) { this.files.set(name, buf); writeFileSync(join(this.cdnDir, name), buf); return name; }
|
|
172
|
+
async listen() {
|
|
173
|
+
this.server = createServer((req, res) => {
|
|
174
|
+
const name = decodeURIComponent(req.url.split('?')[0].replace(/^\//, ''));
|
|
175
|
+
const buf = this.files.get(name);
|
|
176
|
+
if (!buf) { res.writeHead(404); res.end('no'); return; }
|
|
177
|
+
res.writeHead(200, { 'content-type': 'application/octet-stream', 'content-length': buf.length });
|
|
178
|
+
res.end(buf);
|
|
179
|
+
});
|
|
180
|
+
await new Promise((r) => this.server.listen(0, '127.0.0.1', r));
|
|
181
|
+
this.port = this.server.address().port;
|
|
182
|
+
}
|
|
183
|
+
url(name) { return `http://127.0.0.1:${this.port}/${name}`; }
|
|
184
|
+
manifestUrl() { return this.url('bridge-version.json'); }
|
|
185
|
+
close() { if (this.server) this.server.close(); }
|
|
186
|
+
|
|
187
|
+
// Publish a node + payload + signed manifest. opts overrides manifest fields.
|
|
188
|
+
publish({ payloadVersion = PAYLOAD_VERSION, nodeVersion = REAL_NODE.version, nodeTarball, payloadTarball, override = {}, tamperAfterSign = null } = {}) {
|
|
189
|
+
const nodeBuf = nodeTarball || readFileSync(REAL_NODE.path);
|
|
190
|
+
const payloadBuf = payloadTarball || syntheticPayloadTarGz(payloadVersion);
|
|
191
|
+
const nodeName = `node-win-x64-v${nodeVersion}.tar.gz`;
|
|
192
|
+
const payloadName = `bridge-payload-v${payloadVersion}.tar.gz`;
|
|
193
|
+
this.put(nodeName, nodeBuf);
|
|
194
|
+
this.put(`${nodeName.replace(/\.tar\.gz$/, '')}.sig`, override.badNodeSig ? Buffer.alloc(64) : sign(nodeBuf));
|
|
195
|
+
this.put(payloadName, payloadBuf);
|
|
196
|
+
this.put(`${payloadName.replace(/\.tar\.gz$/, '')}.sig`, override.badPayloadSig ? Buffer.alloc(64) : sign(payloadBuf));
|
|
197
|
+
|
|
198
|
+
const fields = {
|
|
199
|
+
version: payloadVersion,
|
|
200
|
+
payloadUrl: this.url(payloadName),
|
|
201
|
+
signatureUrl: this.url(`${payloadName.replace(/\.tar\.gz$/, '')}.sig`),
|
|
202
|
+
sha256: override.payloadSha || sha256(payloadBuf),
|
|
203
|
+
schemaVersion: '2',
|
|
204
|
+
nodeUrl: this.url(nodeName),
|
|
205
|
+
nodeSignatureUrl: this.url(`${nodeName.replace(/\.tar\.gz$/, '')}.sig`),
|
|
206
|
+
nodeSha256: override.nodeSha || sha256(nodeBuf),
|
|
207
|
+
nodeVersion,
|
|
208
|
+
nodeAbi: NODE_PIN.abi,
|
|
209
|
+
platform: override.platform || 'win32',
|
|
210
|
+
arch: override.arch || 'x64',
|
|
211
|
+
publishedAt: '2026-06-04T00:00:00.000Z',
|
|
212
|
+
};
|
|
213
|
+
for (const k of Object.keys(override)) {
|
|
214
|
+
if (!['badNodeSig', 'badPayloadSig', 'payloadSha', 'nodeSha', 'platform', 'arch', 'dropField'].includes(k)) fields[k] = override[k];
|
|
215
|
+
}
|
|
216
|
+
if (override.dropField) delete fields[override.dropField];
|
|
217
|
+
fields.manifestSignature = signManifest(fields, PRIV);
|
|
218
|
+
if (tamperAfterSign) tamperAfterSign(fields); // mutate a signed field, keep old sig
|
|
219
|
+
this.put('bridge-version.json', Buffer.from(JSON.stringify(fields, null, 2)));
|
|
220
|
+
return { fields, nodeName, payloadName, nodeBuf, payloadBuf };
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
baseEnv(extra = {}) {
|
|
224
|
+
return {
|
|
225
|
+
...process.env,
|
|
226
|
+
USERPROFILE: this.home,
|
|
227
|
+
APPDATA: this.appdata,
|
|
228
|
+
EMPIR3_BRIDGE_VERSION_URL: this.manifestUrl(),
|
|
229
|
+
...extra,
|
|
230
|
+
};
|
|
231
|
+
}
|
|
232
|
+
bridgeHome() { return join(this.home, '.empir3-bridge'); }
|
|
233
|
+
stableExe() { return join(this.appdata, 'Empir3', 'Empir3Setup.exe'); }
|
|
234
|
+
pointer() { return join(this.appdata, 'Empir3', 'bridge-bootstrap.json'); }
|
|
235
|
+
payloadActive() { try { return readFileSync(join(this.bridgeHome(), 'payload', '.version'), 'utf8').trim(); } catch { return ''; } }
|
|
236
|
+
nodeActive() { try { return readFileSync(join(this.bridgeHome(), 'node', '.version'), 'utf8').trim(); } catch { return ''; } }
|
|
237
|
+
marker() { const f = join(this.dir, 'marker.json'); return existsSync(f) ? JSON.parse(readFileSync(f, 'utf8')) : null; }
|
|
238
|
+
markerFile() { return join(this.dir, 'marker.json'); }
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// IMPORTANT: async (spawn, not spawnSync). The test CDN runs in THIS process's
|
|
242
|
+
// event loop — spawnSync would block it and the stub's manifest fetch would
|
|
243
|
+
// time out. Returns Buffers for stdout/stderr.
|
|
244
|
+
function execAsync(cmd, args, { env, input, timeout = 60000 } = {}) {
|
|
245
|
+
return new Promise((res) => {
|
|
246
|
+
const c = spawn(cmd, args, { env, windowsHide: true });
|
|
247
|
+
const out = []; const err = [];
|
|
248
|
+
let timer = null;
|
|
249
|
+
let done = false;
|
|
250
|
+
const finish = (status) => { if (done) return; done = true; if (timer) clearTimeout(timer); res({ status, stdout: Buffer.concat(out), stderr: Buffer.concat(err) }); };
|
|
251
|
+
c.stdout.on('data', (d) => out.push(d));
|
|
252
|
+
c.stderr.on('data', (d) => err.push(d));
|
|
253
|
+
c.on('error', (e) => { err.push(Buffer.from(String(e))); finish(1); });
|
|
254
|
+
c.on('close', (code) => finish(code == null ? 1 : code));
|
|
255
|
+
if (timeout) timer = setTimeout(() => { try { spawnSync('taskkill', ['/F', '/T', '/PID', String(c.pid)], { windowsHide: true }); } catch {} c.kill('SIGKILL'); finish(124); }, timeout);
|
|
256
|
+
if (input != null) { c.stdin.write(input); }
|
|
257
|
+
c.stdin.end();
|
|
258
|
+
});
|
|
259
|
+
}
|
|
260
|
+
function runExe(args, env, opts = {}) { return execAsync(EXE, args, { env, ...opts }); }
|
|
261
|
+
|
|
262
|
+
// ── test registry ────────────────────────────────────────────────────────
|
|
263
|
+
const tests = [];
|
|
264
|
+
const test = (n, fn) => tests.push({ n, fn });
|
|
265
|
+
function assert(cond, msg) { if (!cond) throw new Error(msg || 'assertion failed'); }
|
|
266
|
+
|
|
267
|
+
// 1. --bootstrap-version / --bootstrap-pubkey (native, no net)
|
|
268
|
+
test('1: --bootstrap-version + --bootstrap-pubkey', async () => {
|
|
269
|
+
const w = new World('t1');
|
|
270
|
+
assert(/^\d+\.\d+\.\d+$/.test(BOOT_VERSION), `BOOT_VERSION parsed from main.go: ${BOOT_VERSION}`);
|
|
271
|
+
const v = await runExe(['--bootstrap-version'], w.baseEnv());
|
|
272
|
+
assert(v.status === 0 && v.stdout.toString().includes(BOOT_VERSION), `bootstrap-version (want ${BOOT_VERSION}, got ${v.stdout})`);
|
|
273
|
+
const k = await runExe(['--bootstrap-pubkey'], w.baseEnv());
|
|
274
|
+
assert(k.status === 0 && k.stdout.toString().trim() === PUB_HEX, 'bootstrap-pubkey matches pub json');
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
// 3 + 5: cold start installs node+payload, spawns node entry.js, env + stable
|
|
278
|
+
// registration reference the stable exe (never node.exe).
|
|
279
|
+
test('3+5: cold start install, spawn, stable-exe registration', async () => {
|
|
280
|
+
const w = new World('t3'); await w.listen(); w.publish({});
|
|
281
|
+
const r = await runExe(['--daemon-real'], w.baseEnv({ E2E_MARKER_FILE: w.markerFile(), E2E_MODE: 'run' }));
|
|
282
|
+
w.close();
|
|
283
|
+
assert(r.status === 0, `exit ${r.status} stderr=${r.stderr}`);
|
|
284
|
+
assert(r.stdout.toString().trim() === PAYLOAD_VERSION, `stdout=${r.stdout}`);
|
|
285
|
+
assert(w.payloadActive() === PAYLOAD_VERSION, 'payload .version');
|
|
286
|
+
assert(w.nodeActive() === REAL_NODE.version, 'node .version');
|
|
287
|
+
const m = w.marker();
|
|
288
|
+
assert(m && m.kind === 'run', 'marker written');
|
|
289
|
+
assert(/node\.exe$/i.test(m.execPath), 'payload ran under node.exe');
|
|
290
|
+
assert(m.bootstrapExe.toLowerCase() === w.stableExe().toLowerCase(), `EMPIR3_BOOTSTRAP_EXE should be stable exe, got ${m.bootstrapExe}`);
|
|
291
|
+
assert(!/node\.exe$/i.test(m.bootstrapExe), 'bootstrap exe must never be node.exe');
|
|
292
|
+
assert(existsSync(w.stableExe()), 'stable exe copied');
|
|
293
|
+
const ptr = JSON.parse(readFileSync(w.pointer(), 'utf8'));
|
|
294
|
+
assert(ptr.bootstrapPath.toLowerCase() === w.stableExe().toLowerCase(), 'pointer → stable exe');
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
// 2 + 12: tampered manifest refused (no cached runtime → fail closed).
|
|
298
|
+
test('2+12: tampered manifest refused', async () => {
|
|
299
|
+
const w = new World('t2'); await w.listen();
|
|
300
|
+
w.publish({ tamperAfterSign: (f) => { f.version = '9.9.9'; } });
|
|
301
|
+
const r = await runExe(['--daemon-real'], w.baseEnv({ E2E_MODE: 'run' }));
|
|
302
|
+
w.close();
|
|
303
|
+
assert(r.status !== 0, 'must refuse tampered manifest');
|
|
304
|
+
assert(r.stdout.toString() === '', 'no stdout on refusal');
|
|
305
|
+
assert(/SIGNATURE INVALID|refus/i.test(r.stderr.toString()), `stderr should explain: ${r.stderr}`);
|
|
306
|
+
assert(!existsSync(join(w.bridgeHome(), 'payload', '.version')), 'nothing extracted');
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
// 12b + 14: anti-downgrade (older payload kept) + node-only update.
|
|
310
|
+
test('12b+14: anti-downgrade payload + node-only update', async () => {
|
|
311
|
+
const w = new World('t14'); await w.listen();
|
|
312
|
+
// Cold install at a high payload version.
|
|
313
|
+
w.publish({ payloadVersion: '9.9.9' });
|
|
314
|
+
let r = await runExe(['--daemon-real'], w.baseEnv({ E2E_MARKER_FILE: w.markerFile(), E2E_MODE: 'run' }));
|
|
315
|
+
assert(r.status === 0 && w.payloadActive() === '9.9.9', 'cold install 9.9.9');
|
|
316
|
+
// Now publish an OLDER payload version → must keep cached 9.9.9.
|
|
317
|
+
w.publish({ payloadVersion: '0.0.1' });
|
|
318
|
+
r = await runExe(['--daemon-real'], w.baseEnv({ E2E_MARKER_FILE: w.markerFile(), E2E_MODE: 'run' }));
|
|
319
|
+
assert(r.status === 0, `downgrade run exit ${r.status} ${r.stderr}`);
|
|
320
|
+
assert(w.payloadActive() === '9.9.9', `anti-downgrade: kept ${w.payloadActive()}`);
|
|
321
|
+
assert(!existsSync(join(w.bridgeHome(), 'payload', '0.0.1')), 'older payload not extracted');
|
|
322
|
+
w.close();
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
// 13: bad-artifact matrix — each variant refused with a clear error.
|
|
326
|
+
for (const [name, opts] of [
|
|
327
|
+
['bad payload sig', { override: { badPayloadSig: true } }],
|
|
328
|
+
['bad node sig', { override: { badNodeSig: true } }],
|
|
329
|
+
['wrong payload sha', { override: { payloadSha: 'deadbeef'.repeat(8) } }],
|
|
330
|
+
['wrong arch', { override: { arch: 'arm64' } }],
|
|
331
|
+
['wrong platform', { override: { platform: 'linux' } }],
|
|
332
|
+
['missing nodeUrl', { override: { dropField: 'nodeUrl' } }],
|
|
333
|
+
['missing nodeSha256', { override: { dropField: 'nodeSha256' } }],
|
|
334
|
+
['missing version', { override: { dropField: 'version' } }],
|
|
335
|
+
['missing sha256', { override: { dropField: 'sha256' } }],
|
|
336
|
+
['missing signatureUrl', { override: { dropField: 'signatureUrl' } }],
|
|
337
|
+
]) {
|
|
338
|
+
test(`13: bad artifact refused — ${name}`, async () => {
|
|
339
|
+
const w = new World('t13'); await w.listen(); w.publish(opts);
|
|
340
|
+
const r = await runExe(['--daemon-real'], w.baseEnv({ E2E_MODE: 'run' }));
|
|
341
|
+
w.close();
|
|
342
|
+
assert(r.status !== 0, `must refuse (${name})`);
|
|
343
|
+
assert(r.stdout.toString() === '', `no stdout (${name})`);
|
|
344
|
+
assert(w.payloadActive() === '' || w.nodeActive() === '' || !existsSync(join(w.bridgeHome(), 'payload', PAYLOAD_VERSION)),
|
|
345
|
+
`nothing fully installed (${name})`);
|
|
346
|
+
});
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
// 8: malicious tars rejected by the hardened extractor — traversal, absolute,
|
|
350
|
+
// drive letter, UNC, and symlink variants. None may write outside the cache.
|
|
351
|
+
for (const [name, evil] of [
|
|
352
|
+
['parent traversal', () => evilTarGz('../escape.txt')],
|
|
353
|
+
['deep traversal', () => evilTarGz('a/b/../../../escape.txt')],
|
|
354
|
+
['absolute path', () => evilTarGz('/tmp/escape.txt')],
|
|
355
|
+
['drive letter', () => evilTarGz('C:/escape.txt')],
|
|
356
|
+
['UNC path', () => evilTarGz('//host/share/escape.txt')],
|
|
357
|
+
['symlink', () => evilTarGz('link', { typeflag: '2', linkname: 'C:/Windows/System32' })],
|
|
358
|
+
]) {
|
|
359
|
+
test(`8: malicious tar rejected — ${name}`, async () => {
|
|
360
|
+
const w = new World('t8'); await w.listen();
|
|
361
|
+
w.publish({ payloadTarball: evil() });
|
|
362
|
+
const r = await runExe(['--daemon-real'], w.baseEnv({ E2E_MODE: 'run' }));
|
|
363
|
+
w.close();
|
|
364
|
+
assert(r.status !== 0, `must reject malicious tar (${name})`);
|
|
365
|
+
// No escape artifact anywhere outside the (rejected) cache extraction.
|
|
366
|
+
for (const p of [join(w.home, 'escape.txt'), join(w.dir, 'escape.txt'), join(w.bridgeHome(), 'escape.txt'), join(w.home, 'tmp', 'escape.txt')]) {
|
|
367
|
+
assert(!existsSync(p), `no escape file at ${p} (${name})`);
|
|
368
|
+
}
|
|
369
|
+
});
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
// 15: offline — cached runtime runs with no network; cold + offline fails clean.
|
|
373
|
+
test('15: offline with cache runs; offline cold fails clean', async () => {
|
|
374
|
+
const w = new World('t15'); await w.listen(); w.publish({});
|
|
375
|
+
let r = await runExe(['--daemon-real'], w.baseEnv({ E2E_MARKER_FILE: w.markerFile(), E2E_MODE: 'run' }));
|
|
376
|
+
assert(r.status === 0, 'warm install');
|
|
377
|
+
w.close(); // CDN down now
|
|
378
|
+
r = await runExe(['--daemon-real'], w.baseEnv({ E2E_MARKER_FILE: w.markerFile(), E2E_MODE: 'run' }));
|
|
379
|
+
assert(r.status === 0 && r.stdout.toString().trim() === PAYLOAD_VERSION, `offline-with-cache should run: ${r.stderr}`);
|
|
380
|
+
// Cold + offline → clean nonzero.
|
|
381
|
+
const w2 = new World('t15b'); // never listened → connection refused
|
|
382
|
+
const r2 = await runExe(['--daemon-real'], { ...process.env, USERPROFILE: w2.home, APPDATA: w2.appdata, EMPIR3_BRIDGE_VERSION_URL: 'http://127.0.0.1:1/bridge-version.json', E2E_MODE: 'run' });
|
|
383
|
+
assert(r2.status !== 0 && /manifest fetch failed and no cached runtime/i.test(r2.stderr.toString()), `cold offline msg: ${r2.stderr}`);
|
|
384
|
+
});
|
|
385
|
+
|
|
386
|
+
// 4: --mcp stdout discipline — zero stub bytes; JSON-RPC round-trips.
|
|
387
|
+
test('4: --mcp clean stdout + JSON-RPC round-trip', async () => {
|
|
388
|
+
const w = new World('t4'); await w.listen(); w.publish({});
|
|
389
|
+
// Warm the cache first (mcp does no network/install).
|
|
390
|
+
await runExe(['--daemon-real'], w.baseEnv({ E2E_MARKER_FILE: w.markerFile(), E2E_MODE: 'run' }));
|
|
391
|
+
const init = JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'initialize', params: {} }) + '\n';
|
|
392
|
+
const r = await runExe(['--mcp'], w.baseEnv({ E2E_MODE: 'mcp' }), { input: init });
|
|
393
|
+
w.close();
|
|
394
|
+
const out = r.stdout.toString();
|
|
395
|
+
assert(r.status === 0, `mcp exit ${r.status} ${r.stderr}`);
|
|
396
|
+
const lines = out.split('\n').filter(Boolean);
|
|
397
|
+
assert(lines.length === 1, `exactly one stdout line (no stub preamble), got ${JSON.stringify(out)}`);
|
|
398
|
+
const resp = JSON.parse(lines[0]);
|
|
399
|
+
assert(resp.result && resp.result.e2e === 'mcp-ok', 'JSON-RPC response round-trips');
|
|
400
|
+
});
|
|
401
|
+
|
|
402
|
+
// 4b: the REAL mcp shim (bundle-mcp-server.js via the real payload entry.js)
|
|
403
|
+
// must emit ZERO stdout at startup — any module-load/connect logging must go to
|
|
404
|
+
// stderr or it corrupts the JSON-RPC stream. Run the packed node against the
|
|
405
|
+
// real payload with no daemon; startup stdout must stay empty.
|
|
406
|
+
test('4b: real mcp shim startup stdout is clean', async () => {
|
|
407
|
+
const realPayload = `bridge-payload-v${PAYLOAD_VERSION}.tar.gz`;
|
|
408
|
+
if (!existsSync(join(DIST, realPayload))) { console.log(` (skip: ${realPayload} not in dist)`); return; }
|
|
409
|
+
const w = new World('t4b');
|
|
410
|
+
const { extractTarGz } = require('../build/tar-util.js');
|
|
411
|
+
const payDir = join(w.dir, 'payload'); extractTarGz(join(DIST, realPayload), payDir);
|
|
412
|
+
const nodeDir = join(w.dir, 'node'); extractTarGz(REAL_NODE.path, nodeDir);
|
|
413
|
+
const nodeExe = join(nodeDir, 'node.exe');
|
|
414
|
+
// No daemon is running → the shim will fail to connect, but its diagnostics
|
|
415
|
+
// must be on stderr. Give it a short window; assert stdout stayed empty.
|
|
416
|
+
const env = { ...process.env, USERPROFILE: w.home, APPDATA: w.appdata, EMPIR3_BRIDGE_PAYLOAD_DIR: payDir, EMPIR3_BRIDGE_BOOTSTRAP_VERSION: BOOT_VERSION, EMPIR3_BOOTSTRAP_EXE: w.stableExe() };
|
|
417
|
+
const r = await execAsync(nodeExe, [join(payDir, 'entry.js'), '--mcp'], { env, timeout: 6000, input: '' });
|
|
418
|
+
w.close();
|
|
419
|
+
assert(r.stdout.toString() === '', `real mcp shim wrote to stdout at startup: ${JSON.stringify(r.stdout.toString().slice(0, 300))}`);
|
|
420
|
+
});
|
|
421
|
+
|
|
422
|
+
// 16: --mcp with no cached runtime → nonzero, stdout empty, error on stderr.
|
|
423
|
+
test('16: --mcp no runtime → nonzero, empty stdout', async () => {
|
|
424
|
+
const w = new World('t16'); await w.listen(); w.publish({});
|
|
425
|
+
const r = await runExe(['--mcp'], w.baseEnv({ E2E_MODE: 'mcp' }), { input: '{}\n' });
|
|
426
|
+
w.close();
|
|
427
|
+
assert(r.status !== 0, 'must fail without cached runtime');
|
|
428
|
+
assert(r.stdout.toString() === '', `stdout must be empty, got ${r.stdout}`);
|
|
429
|
+
assert(/requires an installed runtime/i.test(r.stderr.toString()), `stderr msg: ${r.stderr}`);
|
|
430
|
+
});
|
|
431
|
+
|
|
432
|
+
// 11: concurrent cold starts — exactly one installs, all healthy.
|
|
433
|
+
test('11: concurrent cold starts race the lock', async () => {
|
|
434
|
+
const w = new World('t11'); await w.listen(); w.publish({});
|
|
435
|
+
const N = 4;
|
|
436
|
+
const procs = [];
|
|
437
|
+
for (let i = 0; i < N; i++) {
|
|
438
|
+
procs.push(new Promise((res) => {
|
|
439
|
+
const env = w.baseEnv({ E2E_MARKER_FILE: join(w.dir, `marker-${i}.json`), E2E_MODE: 'run' });
|
|
440
|
+
const c = spawn(EXE, ['--daemon-real'], { env, windowsHide: true });
|
|
441
|
+
let err = '';
|
|
442
|
+
c.stderr.on('data', (d) => { err += d; });
|
|
443
|
+
c.on('close', (code) => res({ code, err }));
|
|
444
|
+
}));
|
|
445
|
+
}
|
|
446
|
+
const results = await Promise.all(procs);
|
|
447
|
+
w.close();
|
|
448
|
+
assert(results.every((r) => r.code === 0), `all healthy: ${JSON.stringify(results.map((r) => r.code))}`);
|
|
449
|
+
assert(w.payloadActive() === PAYLOAD_VERSION && w.nodeActive() === REAL_NODE.version, 'cache valid after race');
|
|
450
|
+
// Exactly one extracted dir each for payload + node (no duplicate/corrupt).
|
|
451
|
+
const pdirs = readdirSync(join(w.bridgeHome(), 'payload')).filter((n) => !n.startsWith('.'));
|
|
452
|
+
assert(pdirs.length === 1, `one payload dir, got ${pdirs}`);
|
|
453
|
+
});
|
|
454
|
+
|
|
455
|
+
// 6: Job Object — killing the stub tears down the node grandchild.
|
|
456
|
+
test('6: job object kills node child on stub death', async () => {
|
|
457
|
+
const w = new World('t6'); await w.listen(); w.publish({});
|
|
458
|
+
// Warm cache so the daemon-real path doesn't race download while we kill it.
|
|
459
|
+
await runExe(['--daemon-real'], w.baseEnv({ E2E_MARKER_FILE: w.markerFile(), E2E_MODE: 'run' }));
|
|
460
|
+
const childPidFile = join(w.dir, 'child.pid');
|
|
461
|
+
const env = w.baseEnv({ E2E_MODE: 'job-child', E2E_CHILD_PID_FILE: childPidFile, E2E_MARKER_FILE: w.markerFile() });
|
|
462
|
+
const stub = spawn(EXE, ['--daemon-real'], { env, windowsHide: true });
|
|
463
|
+
// Wait for the grandchild pid to appear.
|
|
464
|
+
const start = Date.now();
|
|
465
|
+
while (!existsSync(childPidFile) && Date.now() - start < 20000) await new Promise((r) => setTimeout(r, 200));
|
|
466
|
+
assert(existsSync(childPidFile), 'grandchild pid file appeared');
|
|
467
|
+
const childPid = parseInt(readFileSync(childPidFile, 'utf8'), 10);
|
|
468
|
+
assert(isPidAlive(childPid), 'grandchild alive before kill');
|
|
469
|
+
// Kill the stub (NOT /T) — the kill-on-close job must take the child down.
|
|
470
|
+
spawnSync('taskkill', ['/F', '/PID', String(stub.pid)], { windowsHide: true });
|
|
471
|
+
const t = Date.now();
|
|
472
|
+
while (isPidAlive(childPid) && Date.now() - t < 15000) await new Promise((r) => setTimeout(r, 200));
|
|
473
|
+
w.close();
|
|
474
|
+
assert(!isPidAlive(childPid), 'grandchild must die with the stub (job object)');
|
|
475
|
+
});
|
|
476
|
+
|
|
477
|
+
// 6b: launcher path (--daemon / no-args) must NOT kill-on-close the spawned
|
|
478
|
+
// process tree — the detached tray (a grandchild) has to SURVIVE after node and
|
|
479
|
+
// the Go stub exit. This is the bug that shipped in 0.3.0: the kill-on-close Job
|
|
480
|
+
// Object tore the tray down the instant the stub exited.
|
|
481
|
+
test('6b: launcher path leaves the detached tray alive after stub exits', async () => {
|
|
482
|
+
const w = new World('t6b'); await w.listen(); w.publish({});
|
|
483
|
+
await runExe(['--daemon-real'], w.baseEnv({ E2E_MARKER_FILE: w.markerFile(), E2E_MODE: 'run' })); // warm cache
|
|
484
|
+
const childPidFile = join(w.dir, 'tray.pid');
|
|
485
|
+
const r = await runExe(['--daemon'], w.baseEnv({ E2E_MODE: 'launcher-detached', E2E_CHILD_PID_FILE: childPidFile }));
|
|
486
|
+
assert(r.status === 0, `--daemon exit ${r.status}: ${r.stderr}`);
|
|
487
|
+
assert(existsSync(childPidFile), 'grandchild (tray stand-in) pid recorded');
|
|
488
|
+
const pid = parseInt(readFileSync(childPidFile, 'utf8'), 10);
|
|
489
|
+
await new Promise((res) => setTimeout(res, 1500)); // guard against a delayed job-kill
|
|
490
|
+
const alive = isPidAlive(pid);
|
|
491
|
+
if (alive) spawnSync('taskkill', ['/F', '/PID', String(pid)], { windowsHide: true });
|
|
492
|
+
w.close();
|
|
493
|
+
assert(alive, 'detached tray stand-in must SURVIVE the stub exit (no kill-on-close job on --daemon)');
|
|
494
|
+
});
|
|
495
|
+
|
|
496
|
+
// 7 + 19: native uninstall under test mode (no HKCU writes, no dialog, no net).
|
|
497
|
+
test('7+19: native uninstall (test mode) clears trees', async () => {
|
|
498
|
+
const w = new World('t7'); await w.listen(); w.publish({});
|
|
499
|
+
await runExe(['--daemon-real'], w.baseEnv({ E2E_MARKER_FILE: w.markerFile(), E2E_MODE: 'run' }));
|
|
500
|
+
w.close();
|
|
501
|
+
assert(existsSync(w.bridgeHome()), 'precondition: bridge home exists');
|
|
502
|
+
// Point version URL at a dead port to prove uninstall does NO network.
|
|
503
|
+
const r = await runExe(['--uninstall'], { ...w.baseEnv({ EMPIR3_UNINSTALL_TEST: '1' }), EMPIR3_BRIDGE_VERSION_URL: 'http://127.0.0.1:1/x.json' }, { timeout: 30000 });
|
|
504
|
+
assert(r.status === 0, `uninstall exit ${r.status} ${r.stderr}`);
|
|
505
|
+
assert(!existsSync(w.bridgeHome()), 'bridge home removed');
|
|
506
|
+
assert(!existsSync(join(w.appdata, 'Empir3')), 'appdata/Empir3 removed');
|
|
507
|
+
});
|
|
508
|
+
|
|
509
|
+
// 10: backward compat — the REAL legacy bootstrap.js consumes the NEW manifest.
|
|
510
|
+
test('10: legacy bootstrap.js consumes new manifest', async () => {
|
|
511
|
+
const w = new World('t10'); await w.listen(); w.publish({});
|
|
512
|
+
const env = { ...process.env, USERPROFILE: w.home, APPDATA: w.appdata, EMPIR3_BRIDGE_VERSION_URL: w.manifestUrl(), E2E_MARKER_FILE: w.markerFile(), E2E_MODE: 'run' };
|
|
513
|
+
// Async (CDN runs in this event loop). The legacy bootstrap require()s the
|
|
514
|
+
// payload entry.js in-process and forwards argv.
|
|
515
|
+
const r = await execAsync(process.execPath, [LEGACY_BOOTSTRAP, '--version'], { env, timeout: 60000 });
|
|
516
|
+
w.close();
|
|
517
|
+
assert(r.status === 0, `legacy bootstrap exit ${r.status}: ${r.stderr}`);
|
|
518
|
+
assert(r.stdout.toString().includes(PAYLOAD_VERSION), `legacy printed payload version: ${r.stdout}`);
|
|
519
|
+
// It ignored the new fields and still verified + extracted the payload.
|
|
520
|
+
assert(existsSync(join(w.bridgeHome(), 'payload', PAYLOAD_VERSION, 'entry.js')), 'legacy extracted payload');
|
|
521
|
+
});
|
|
522
|
+
|
|
523
|
+
// 20: canonicalization fixtures (Node side mirrors the Go golden tests).
|
|
524
|
+
test('20: canonicalization — order-independent, mutation-sensitive, < > & safe', async () => {
|
|
525
|
+
const a = canonicalizeManifest({ version: '0.3.0', arch: 'x64', nodeUrl: 'u' }).toString();
|
|
526
|
+
const b = canonicalizeManifest({ nodeUrl: 'u', version: '0.3.0', arch: 'x64' }).toString();
|
|
527
|
+
assert(a === b, 'key order must not change canonical bytes');
|
|
528
|
+
assert(canonicalizeManifest({ u: 'a&b<c>d' }).toString() === '{"u":"a&b<c>d"}', '< > & not escaped');
|
|
529
|
+
assert(canonicalizeManifest({ x: '1', manifestSignature: 'SIG' }).toString() === '{"x":"1"}', 'signature excluded');
|
|
530
|
+
// Sign then mutate any field → verify must fail (mirrors Go).
|
|
531
|
+
const f = { version: '0.3.0', payloadUrl: 'p', signatureUrl: 's', sha256: 'h', nodeUrl: 'n', nodeSignatureUrl: 'ns', nodeSha256: 'nh', nodeVersion: '24.13.0', platform: 'win32', arch: 'x64' };
|
|
532
|
+
f.manifestSignature = signManifest(f, PRIV);
|
|
533
|
+
const { verifyManifestBytes } = require('../build/manifest-canonical.js');
|
|
534
|
+
assert(verifyManifestBytes(Buffer.from(JSON.stringify(f)), PUB_HEX), 'valid manifest verifies');
|
|
535
|
+
const mutated = { ...f, sha256: 'h2' };
|
|
536
|
+
assert(!verifyManifestBytes(Buffer.from(JSON.stringify(mutated)), PUB_HEX), 'mutation breaks verification');
|
|
537
|
+
});
|
|
538
|
+
|
|
539
|
+
// 21: resolver fail-closed under plain node.exe (no env, no pointer, no stable).
|
|
540
|
+
test('21: resolveBootstrapExe fail-closed under plain node', async () => {
|
|
541
|
+
const w = new World('t21');
|
|
542
|
+
// Probe the REAL resolver under plain node.exe (no env/pointer/stable, isSea
|
|
543
|
+
// false). Use a .cts require probe placed in ROOT so the relative specifier
|
|
544
|
+
// resolves against ROOT and tsx's CJS interop exposes the named export.
|
|
545
|
+
const scriptFile = join(ROOT, '.e2e-resolver-probe.cts');
|
|
546
|
+
writeFileSync(scriptFile, "const { resolveBootstrapExe } = require('./src/bootstrap-exe.ts');\nconst r = resolveBootstrapExe();\nprocess.stdout.write(r === null ? 'NULL' : String(r));\n");
|
|
547
|
+
const env = { ...process.env, USERPROFILE: w.home, APPDATA: w.appdata };
|
|
548
|
+
delete env.EMPIR3_BOOTSTRAP_EXE;
|
|
549
|
+
try {
|
|
550
|
+
const r = spawnSync('npx', ['tsx', scriptFile], { env, encoding: 'utf8', shell: true, cwd: ROOT, timeout: 90000 });
|
|
551
|
+
assert(r.stdout.trim() === 'NULL', `resolver must return null (got ${JSON.stringify(r.stdout)} / ${r.stderr})`);
|
|
552
|
+
} finally {
|
|
553
|
+
try { rmSync(scriptFile, { force: true }); } catch {}
|
|
554
|
+
}
|
|
555
|
+
});
|
|
556
|
+
|
|
557
|
+
// 18: an OLDER stub must not overwrite a NEWER installed stable stub.
|
|
558
|
+
test('18: older stub defers to newer stable (version guard)', async () => {
|
|
559
|
+
const w = new World('t18'); await w.listen(); w.publish({});
|
|
560
|
+
// Build a NEWER stub (v9.9.9) into the stable path by copying the package to
|
|
561
|
+
// a temp dir, bumping the version const, and `go build`-ing it.
|
|
562
|
+
const newer = buildVersionedStub('9.9.9');
|
|
563
|
+
mkdirSync(join(w.appdata, 'Empir3'), { recursive: true });
|
|
564
|
+
copyFileSync(newer, w.stableExe());
|
|
565
|
+
// Run the OLDER real stub (2.0.0). It should detect stable 9.9.9 > 2.0.0 and
|
|
566
|
+
// re-exec the stable one (which then installs + runs), NOT overwrite it.
|
|
567
|
+
const before = sha256(readFileSync(w.stableExe()));
|
|
568
|
+
const r = await runExe(['--daemon-real'], w.baseEnv({ E2E_MARKER_FILE: w.markerFile(), E2E_MODE: 'run' }));
|
|
569
|
+
w.close();
|
|
570
|
+
assert(r.status === 0, `handoff exit ${r.status}: ${r.stderr}`);
|
|
571
|
+
const after = sha256(readFileSync(w.stableExe()));
|
|
572
|
+
assert(before === after, 'older stub must NOT overwrite newer stable');
|
|
573
|
+
assert(/newer than this stub|handing off/i.test(r.stderr.toString()), `should log handoff: ${r.stderr}`);
|
|
574
|
+
});
|
|
575
|
+
|
|
576
|
+
// Build a stub with a patched BootstrapVersion into a temp exe. Returns path.
|
|
577
|
+
function buildVersionedStub(version) {
|
|
578
|
+
const src = join(ROOT, 'build', 'bootstrap-go');
|
|
579
|
+
const tmp = freshTmp('go-' + version);
|
|
580
|
+
for (const f of readdirSync(src)) {
|
|
581
|
+
if (statSync(join(src, f)).isFile()) copyFileSync(join(src, f), join(tmp, f));
|
|
582
|
+
}
|
|
583
|
+
// Patch the const in the copy.
|
|
584
|
+
const mainPath = join(tmp, 'main.go');
|
|
585
|
+
const patched = readFileSync(mainPath, 'utf8').replace(/const BootstrapVersion = "[^"]*"/, `const BootstrapVersion = "${version}"`);
|
|
586
|
+
writeFileSync(mainPath, patched);
|
|
587
|
+
const out = join(tmp, 'Empir3Setup.exe');
|
|
588
|
+
const env = { ...process.env, GOOS: 'windows', GOARCH: 'amd64', GOPROXY: 'off', GOTOOLCHAIN: 'local', CGO_ENABLED: '0' };
|
|
589
|
+
execFileSync('go', ['build', '-trimpath', '-ldflags', '-s -w', '-o', out, '.'], { cwd: tmp, env });
|
|
590
|
+
return out;
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
function isPidAlive(pid) {
|
|
594
|
+
const r = spawnSync('tasklist', ['/FI', `PID eq ${pid}`, '/NH'], { encoding: 'utf8', windowsHide: true });
|
|
595
|
+
return r.status === 0 && r.stdout.includes(String(pid));
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
// 9: node-pty ABI smoke under the pinned node (uses the REAL payload + node).
|
|
599
|
+
test('9: node-pty ABI smoke under pinned node', async () => {
|
|
600
|
+
// The build already smokes this; re-assert here against the published node
|
|
601
|
+
// tarball to keep the harness self-contained.
|
|
602
|
+
const w = new World('t9');
|
|
603
|
+
const nodeDir = join(w.dir, 'node');
|
|
604
|
+
const { extractTarGz } = require('../build/tar-util.js');
|
|
605
|
+
extractTarGz(REAL_NODE.path, nodeDir);
|
|
606
|
+
// Target the CURRENT payload (dist may also hold stale older tarballs).
|
|
607
|
+
const realPayload = `bridge-payload-v${PAYLOAD_VERSION}.tar.gz`;
|
|
608
|
+
if (!existsSync(join(DIST, realPayload))) { console.log(` (skip: ${realPayload} not in dist)`); return; }
|
|
609
|
+
const payDir = join(w.dir, 'payload');
|
|
610
|
+
extractTarGz(join(DIST, realPayload), payDir);
|
|
611
|
+
const nodeExe = join(nodeDir, 'node.exe');
|
|
612
|
+
const v = execFileSync(nodeExe, ['-p', 'process.versions.modules'], { encoding: 'utf8' }).trim();
|
|
613
|
+
assert(v === NODE_PIN.abi, `node ABI ${v} != pin ${NODE_PIN.abi}`);
|
|
614
|
+
// Run a script FILE inside the payload dir (not `-e`): bare `require('node-pty')`
|
|
615
|
+
// resolves against the script's own node_modules, exactly like the daemon.
|
|
616
|
+
const probe = join(payDir, '_ptyprobe.js');
|
|
617
|
+
writeFileSync(probe, "require('node-pty'); process.stderr.write('node-pty ok');");
|
|
618
|
+
execFileSync(nodeExe, [probe], { cwd: payDir });
|
|
619
|
+
});
|
|
620
|
+
|
|
621
|
+
// 17: full tray/daemon GUI e2e — covered by the manual fresh-machine install.
|
|
622
|
+
test('17: full tray+daemon e2e (manual)', async () => {
|
|
623
|
+
console.log(' SKIP: needs a real tray window + daemon; covered by the fresh-machine install/uninstall in the release step.');
|
|
624
|
+
});
|
|
625
|
+
|
|
626
|
+
// ── runner ─────────────────────────────────────────────────────────────────
|
|
627
|
+
async function main() {
|
|
628
|
+
if (!existsSync(EXE)) { console.error(`No exe at ${EXE}. Run \`node build/build.js\` first.`); process.exit(1); }
|
|
629
|
+
console.log(`Bootstrap e2e — exe ${basename(EXE)}, payload v${PAYLOAD_VERSION}, node v${REAL_NODE.version}\n`);
|
|
630
|
+
let pass = 0; let fail = 0;
|
|
631
|
+
const only = process.argv[2]; // optional substring filter
|
|
632
|
+
for (const t of tests) {
|
|
633
|
+
if (only && !t.n.includes(only)) continue;
|
|
634
|
+
process.stdout.write(`• ${t.n} … `);
|
|
635
|
+
try {
|
|
636
|
+
await t.fn();
|
|
637
|
+
console.log('PASS');
|
|
638
|
+
pass++;
|
|
639
|
+
} catch (e) {
|
|
640
|
+
console.log('FAIL');
|
|
641
|
+
console.log(` ${e.message}`);
|
|
642
|
+
fail++;
|
|
643
|
+
} finally {
|
|
644
|
+
cleanupAll();
|
|
645
|
+
}
|
|
646
|
+
}
|
|
647
|
+
console.log(`\n${pass} passed, ${fail} failed`);
|
|
648
|
+
process.exit(fail ? 1 : 0);
|
|
649
|
+
}
|
|
650
|
+
main().catch((e) => { console.error(e); cleanupAll(); process.exit(1); });
|