@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,548 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Empir3 Bridge — bootstrapper (stable SEA entry).
|
|
4
|
+
*
|
|
5
|
+
* This is the part of Empir3Setup.exe that should NEVER change after the
|
|
6
|
+
* first signed release. Its only job is to fetch, verify, cache, and
|
|
7
|
+
* execute a remote-hosted "payload" tarball that contains the actual
|
|
8
|
+
* bridge code (daemon, installer, etc.).
|
|
9
|
+
*
|
|
10
|
+
* Why split things this way:
|
|
11
|
+
* - Empir3Setup.exe is an unsigned binary today and a code-signed binary
|
|
12
|
+
* once we wire Azure Trusted Signing. Each rebuild produces a new
|
|
13
|
+
* hash, which throws away SmartScreen reputation. By keeping the
|
|
14
|
+
* bootstrapper code small and frozen, the exe hash stays stable and
|
|
15
|
+
* reputation accumulates. New code ships as payload updates.
|
|
16
|
+
*
|
|
17
|
+
* - Mirrors how Chrome / VSCode / Electron / many CLIs do auto-updates:
|
|
18
|
+
* a small native bootstrapper validates a signed manifest + payload
|
|
19
|
+
* and hands off to it.
|
|
20
|
+
*
|
|
21
|
+
* Trust model:
|
|
22
|
+
* - The bootstrapper holds an Ed25519 PUBLIC key (compiled in below).
|
|
23
|
+
* - The build machine holds the matching PRIVATE key
|
|
24
|
+
* (`bridge/build/payload-signing-key.pem`, gitignored, treated as a
|
|
25
|
+
* credential — see `bridge/build/payload-signing-pub.json` for the
|
|
26
|
+
* public half + how to rotate).
|
|
27
|
+
* - Every payload is a tarball + a detached Ed25519 signature over its
|
|
28
|
+
* bytes. The bootstrapper refuses to run a payload whose signature
|
|
29
|
+
* doesn't verify against the embedded pubkey. If the signing key is
|
|
30
|
+
* ever compromised, rotation requires shipping a new bootstrapper
|
|
31
|
+
* exe (which IS a signing event under SmartScreen / Azure Trusted
|
|
32
|
+
* Signing — same shape as Chrome's stage-2 root rotation).
|
|
33
|
+
*
|
|
34
|
+
* Update flow at runtime:
|
|
35
|
+
* 1. GET https://app.empir3.com/downloads/bridge-version.json
|
|
36
|
+
* → { version, payloadUrl, signatureUrl, sha256 }
|
|
37
|
+
* 2. Compare server `version` to the locally cached payload version
|
|
38
|
+
* stored at `~/.empir3-bridge/payload/.version`.
|
|
39
|
+
* 3. If newer (or no cached payload), download tarball + signature.
|
|
40
|
+
* 4. Verify signature with `crypto.verify(null, tarballBuf, pubKey, sig)`
|
|
41
|
+
* (Ed25519 — Node 20+ accepts a null algorithm).
|
|
42
|
+
* 5. Verify sha256 matches the manifest.
|
|
43
|
+
* 6. Extract tarball into `~/.empir3-bridge/payload/<version>/`.
|
|
44
|
+
* 7. Atomically swap `.version` to the new value.
|
|
45
|
+
* 8. require() `payload/<version>/entry.js` and forward argv.
|
|
46
|
+
*
|
|
47
|
+
* Failure modes:
|
|
48
|
+
* - Server unreachable → run last-known-good cached payload.
|
|
49
|
+
* - Signature fails → keep cached payload, log loudly, refuse update.
|
|
50
|
+
* - sha256 mismatch → same as above.
|
|
51
|
+
* - No cached payload AND
|
|
52
|
+
* no network → exit with a clear "first-run requires
|
|
53
|
+
* internet" message.
|
|
54
|
+
* - Payload entry.js throws → propagate as fatal error to the exe caller
|
|
55
|
+
* (the payload is responsible for its own
|
|
56
|
+
* error handling internally).
|
|
57
|
+
*
|
|
58
|
+
* Bootstrapper-only concerns (NOT delegated to the payload):
|
|
59
|
+
* - Hard-coded pubkey, version-check URL, payload cache layout.
|
|
60
|
+
* - The `--bootstrap-version` and `--bootstrap-pubkey` debug flags.
|
|
61
|
+
*
|
|
62
|
+
* Everything else — `--daemon`, `--uninstall`, `--version`, `--help`,
|
|
63
|
+
* autostart, force-install policy, asset extraction, Koba installer UI,
|
|
64
|
+
* relay client, CDP handlers — lives inside the payload's entry.js.
|
|
65
|
+
*/
|
|
66
|
+
const crypto = require('crypto');
|
|
67
|
+
const fs = require('fs');
|
|
68
|
+
const https = require('https');
|
|
69
|
+
const http = require('http');
|
|
70
|
+
const os = require('os');
|
|
71
|
+
const path = require('path');
|
|
72
|
+
const Module = require('module');
|
|
73
|
+
const { spawnSync } = require('child_process');
|
|
74
|
+
|
|
75
|
+
// ─── Compile-time constants ────────────────────────────────────────────
|
|
76
|
+
|
|
77
|
+
const BOOTSTRAP_VERSION = '1.1.0';
|
|
78
|
+
|
|
79
|
+
// Ed25519 public key (32 raw bytes, hex). Generated once with
|
|
80
|
+
// `node bridge/build/build.js --gen-key` (which writes the matching
|
|
81
|
+
// private key to bridge/build/payload-signing-key.pem). The public half
|
|
82
|
+
// is also committed at bridge/build/payload-signing-pub.json for audit.
|
|
83
|
+
const PAYLOAD_PUBKEY_HEX = 'a0813b51654fcb6026c0cfc9d0f367c8535b96c94b52c9fff15d2fe59f7cd68a';
|
|
84
|
+
|
|
85
|
+
// The version manifest lives next to the payload tarball + signature on
|
|
86
|
+
// the public CDN. All four URLs end up resolved relative to this base.
|
|
87
|
+
const VERSION_URL = process.env.EMPIR3_BRIDGE_VERSION_URL
|
|
88
|
+
|| 'https://app.empir3.com/downloads/bridge-version.json';
|
|
89
|
+
|
|
90
|
+
// Where the bootstrapper caches payloads it has fetched + verified.
|
|
91
|
+
// ~/.empir3-bridge/payload/.version ← currently active version
|
|
92
|
+
// ~/.empir3-bridge/payload/<version>/ ← extracted tree
|
|
93
|
+
// ~/.empir3-bridge/payload/<version>.tar.gz ← retained for re-verify
|
|
94
|
+
// ~/.empir3-bridge/payload/<version>.sig ← retained for re-verify
|
|
95
|
+
const HOME = os.homedir();
|
|
96
|
+
const BRIDGE_HOME = path.join(HOME, '.empir3-bridge');
|
|
97
|
+
const PAYLOAD_ROOT = path.join(BRIDGE_HOME, 'payload');
|
|
98
|
+
const ACTIVE_VERSION_FILE = path.join(PAYLOAD_ROOT, '.version');
|
|
99
|
+
|
|
100
|
+
// Install footprint the bootstrapper can tear down WITHOUT the payload. The
|
|
101
|
+
// payload (payload-entry.js) owns the canonical uninstall; these mirror its
|
|
102
|
+
// constants and power the no-cached-payload fallback only. Keep in sync.
|
|
103
|
+
const APPDATA_ROAMING = process.env.APPDATA || path.join(HOME, 'AppData', 'Roaming');
|
|
104
|
+
const APPDATA_DIR = path.join(APPDATA_ROAMING, 'Empir3');
|
|
105
|
+
const AUTOSTART_KEY = 'HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Run';
|
|
106
|
+
const AUTOSTART_VALUE_NAME = 'Empir3Bridge';
|
|
107
|
+
const FORCELIST_KEY = 'HKCU\\Software\\Policies\\Google\\Chrome\\ExtensionInstallForcelist';
|
|
108
|
+
const EXTENSION_ID = 'gbigofjjgcpjkffhlfepjdglabhngeii';
|
|
109
|
+
const START_MENU_LNK = path.join(APPDATA_ROAMING, 'Microsoft', 'Windows', 'Start Menu', 'Programs', 'Empir3', 'Empir3.lnk');
|
|
110
|
+
|
|
111
|
+
// Network timeout for the initial version probe. Short — we fall back to
|
|
112
|
+
// the cached payload very fast so a flaky network doesn't slow the daemon
|
|
113
|
+
// or installer launch.
|
|
114
|
+
const VERSION_PROBE_TIMEOUT_MS = 5_000;
|
|
115
|
+
const PAYLOAD_DOWNLOAD_TIMEOUT_MS = 60_000;
|
|
116
|
+
|
|
117
|
+
// ─── Public-key import (DER SPKI from raw 32 bytes) ────────────────────
|
|
118
|
+
|
|
119
|
+
// Node's crypto.verify wants a KeyObject. Wrap our 32 raw bytes in the
|
|
120
|
+
// fixed Ed25519 SPKI prefix:
|
|
121
|
+
// 30 2a 30 05 06 03 2b 65 70 03 21 00 <32 bytes pubkey>
|
|
122
|
+
const ED25519_SPKI_PREFIX = Buffer.from('302a300506032b6570032100', 'hex');
|
|
123
|
+
function loadPubKey() {
|
|
124
|
+
const raw = Buffer.from(PAYLOAD_PUBKEY_HEX, 'hex');
|
|
125
|
+
if (raw.length !== 32) throw new Error(`Bad PAYLOAD_PUBKEY_HEX length: ${raw.length}`);
|
|
126
|
+
const spki = Buffer.concat([ED25519_SPKI_PREFIX, raw]);
|
|
127
|
+
return crypto.createPublicKey({ key: spki, format: 'der', type: 'spki' });
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// ─── Tiny HTTPS GET helpers (no external deps) ─────────────────────────
|
|
131
|
+
|
|
132
|
+
function fetchBuffer(url, timeoutMs) {
|
|
133
|
+
return new Promise((resolve, reject) => {
|
|
134
|
+
const u = new URL(url);
|
|
135
|
+
const lib = u.protocol === 'http:' ? http : https;
|
|
136
|
+
const req = lib.get(url, { timeout: timeoutMs, headers: { 'User-Agent': `empir3-bootstrap/${BOOTSTRAP_VERSION}` } }, (res) => {
|
|
137
|
+
// Follow one redirect (CDN failover etc.).
|
|
138
|
+
if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
|
|
139
|
+
res.resume();
|
|
140
|
+
const next = new URL(res.headers.location, url).toString();
|
|
141
|
+
return resolve(fetchBuffer(next, timeoutMs));
|
|
142
|
+
}
|
|
143
|
+
if (res.statusCode !== 200) {
|
|
144
|
+
res.resume();
|
|
145
|
+
return reject(new Error(`HTTP ${res.statusCode} for ${url}`));
|
|
146
|
+
}
|
|
147
|
+
const chunks = [];
|
|
148
|
+
res.on('data', (c) => chunks.push(c));
|
|
149
|
+
res.on('end', () => resolve(Buffer.concat(chunks)));
|
|
150
|
+
res.on('error', reject);
|
|
151
|
+
});
|
|
152
|
+
req.on('error', reject);
|
|
153
|
+
req.on('timeout', () => { req.destroy(new Error(`timeout fetching ${url}`)); });
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
async function fetchJson(url, timeoutMs) {
|
|
158
|
+
const buf = await fetchBuffer(url, timeoutMs);
|
|
159
|
+
return JSON.parse(buf.toString('utf8'));
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// ─── Payload cache helpers ─────────────────────────────────────────────
|
|
163
|
+
|
|
164
|
+
function readActiveVersion() {
|
|
165
|
+
try { return fs.readFileSync(ACTIVE_VERSION_FILE, 'utf8').trim(); } catch { return null; }
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function payloadDir(version) {
|
|
169
|
+
return path.join(PAYLOAD_ROOT, version);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function payloadIsExtracted(version) {
|
|
173
|
+
if (!version) return false;
|
|
174
|
+
return fs.existsSync(path.join(payloadDir(version), 'entry.js'));
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function compareVersions(a, b) {
|
|
178
|
+
const left = String(a || '').replace(/^v/i, '').split('.').map((part) => Number.parseInt(part, 10));
|
|
179
|
+
const right = String(b || '').replace(/^v/i, '').split('.').map((part) => Number.parseInt(part, 10));
|
|
180
|
+
const len = Math.max(left.length, right.length, 3);
|
|
181
|
+
for (let i = 0; i < len; i += 1) {
|
|
182
|
+
const l = Number.isFinite(left[i]) ? left[i] : 0;
|
|
183
|
+
const r = Number.isFinite(right[i]) ? right[i] : 0;
|
|
184
|
+
if (l !== r) return l > r ? 1 : -1;
|
|
185
|
+
}
|
|
186
|
+
return 0;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function writeActiveVersion(version) {
|
|
190
|
+
fs.mkdirSync(PAYLOAD_ROOT, { recursive: true });
|
|
191
|
+
// Atomic-ish: write to .new then rename.
|
|
192
|
+
const tmp = `${ACTIVE_VERSION_FILE}.new`;
|
|
193
|
+
fs.writeFileSync(tmp, version);
|
|
194
|
+
fs.renameSync(tmp, ACTIVE_VERSION_FILE);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// ─── Tar extraction (built-in, no npm deps) ────────────────────────────
|
|
198
|
+
|
|
199
|
+
// Minimal POSIX-tar extractor. Handles regular files + directories +
|
|
200
|
+
// long names (GNU 'L' headers). Inputs are .tar.gz buffers; we gunzip via
|
|
201
|
+
// zlib (built-in) into the raw tar stream then parse blocks of 512.
|
|
202
|
+
//
|
|
203
|
+
// Deliberately built in-process (no `tar` npm dep, no shelling out to
|
|
204
|
+
// bsdtar) so the bootstrapper is fully self-contained inside the SEA
|
|
205
|
+
// blob. ~80 lines.
|
|
206
|
+
async function extractTarGz(tarGzBuffer, destDir) {
|
|
207
|
+
const zlib = require('zlib');
|
|
208
|
+
const raw = await new Promise((resolve, reject) => {
|
|
209
|
+
zlib.gunzip(tarGzBuffer, (err, out) => err ? reject(err) : resolve(out));
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
fs.mkdirSync(destDir, { recursive: true });
|
|
213
|
+
|
|
214
|
+
const BLOCK = 512;
|
|
215
|
+
let pos = 0;
|
|
216
|
+
let pendingLongName = null;
|
|
217
|
+
|
|
218
|
+
while (pos + BLOCK <= raw.length) {
|
|
219
|
+
const header = raw.slice(pos, pos + BLOCK);
|
|
220
|
+
// Empty block(s) at the end mark EOF.
|
|
221
|
+
if (header.every((b) => b === 0)) { pos += BLOCK; continue; }
|
|
222
|
+
|
|
223
|
+
// Parse name (100), size (12), typeflag (1).
|
|
224
|
+
let name = header.slice(0, 100).toString('utf8').replace(/\0.*$/, '');
|
|
225
|
+
const sizeOctal = header.slice(124, 136).toString('utf8').replace(/\0.*$/, '').trim();
|
|
226
|
+
const size = parseInt(sizeOctal, 8) || 0;
|
|
227
|
+
const typeflag = String.fromCharCode(header[156]);
|
|
228
|
+
const prefix = header.slice(345, 500).toString('utf8').replace(/\0.*$/, '');
|
|
229
|
+
|
|
230
|
+
if (prefix && name) name = prefix + '/' + name;
|
|
231
|
+
if (pendingLongName) { name = pendingLongName; pendingLongName = null; }
|
|
232
|
+
|
|
233
|
+
pos += BLOCK;
|
|
234
|
+
const dataEnd = pos + size;
|
|
235
|
+
const data = raw.slice(pos, dataEnd);
|
|
236
|
+
pos = dataEnd + ((BLOCK - (size % BLOCK)) % BLOCK);
|
|
237
|
+
|
|
238
|
+
if (!name) continue;
|
|
239
|
+
|
|
240
|
+
if (typeflag === 'L') {
|
|
241
|
+
// GNU long-name header — name of the NEXT entry is in this block's data.
|
|
242
|
+
pendingLongName = data.toString('utf8').replace(/\0+$/, '');
|
|
243
|
+
continue;
|
|
244
|
+
}
|
|
245
|
+
if (typeflag === 'x' || typeflag === 'g') {
|
|
246
|
+
// pax extended headers — ignore, we don't need their metadata.
|
|
247
|
+
continue;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// Sanitize: strip leading slashes, reject path traversal.
|
|
251
|
+
const safeRel = name.replace(/^\.?\/+/, '');
|
|
252
|
+
if (safeRel.includes('..')) throw new Error(`Refusing tar entry with .. in path: ${name}`);
|
|
253
|
+
const out = path.join(destDir, safeRel);
|
|
254
|
+
if (!out.startsWith(destDir)) throw new Error(`Tar entry escapes dest dir: ${name}`);
|
|
255
|
+
|
|
256
|
+
if (typeflag === '5' || name.endsWith('/')) {
|
|
257
|
+
fs.mkdirSync(out, { recursive: true });
|
|
258
|
+
} else if (typeflag === '0' || typeflag === '' || typeflag === '\0') {
|
|
259
|
+
fs.mkdirSync(path.dirname(out), { recursive: true });
|
|
260
|
+
fs.writeFileSync(out, data);
|
|
261
|
+
}
|
|
262
|
+
// We ignore symlinks ('2'), hardlinks ('1'), block/char devices, etc.
|
|
263
|
+
// Our payload tarballs only ship regular files + dirs (build script
|
|
264
|
+
// enforces this).
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// ─── Main update + dispatch flow ───────────────────────────────────────
|
|
269
|
+
|
|
270
|
+
function log(msg) { console.log(`[empir3-bootstrap] ${msg}`); }
|
|
271
|
+
function warn(msg) { console.warn(`[empir3-bootstrap] WARN: ${msg}`); }
|
|
272
|
+
function err(msg) { console.error(`[empir3-bootstrap] ERROR: ${msg}`); }
|
|
273
|
+
|
|
274
|
+
async function tryUpdate() {
|
|
275
|
+
let manifest;
|
|
276
|
+
try {
|
|
277
|
+
manifest = await fetchJson(VERSION_URL, VERSION_PROBE_TIMEOUT_MS);
|
|
278
|
+
} catch (e) {
|
|
279
|
+
warn(`version probe failed (${e.message}); using cached payload if any.`);
|
|
280
|
+
return null;
|
|
281
|
+
}
|
|
282
|
+
if (!manifest || typeof manifest.version !== 'string' || !manifest.payloadUrl || !manifest.signatureUrl) {
|
|
283
|
+
warn('version manifest malformed; using cached payload if any.');
|
|
284
|
+
return null;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
const active = readActiveVersion();
|
|
288
|
+
if (payloadIsExtracted(active) && compareVersions(active, manifest.version) > 0) {
|
|
289
|
+
warn(`active payload v${active} is newer than manifest v${manifest.version}; keeping cached payload.`);
|
|
290
|
+
return active;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
if (active === manifest.version && payloadIsExtracted(active)) {
|
|
294
|
+
return active; // already on latest
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
if (payloadIsExtracted(manifest.version)) {
|
|
298
|
+
writeActiveVersion(manifest.version);
|
|
299
|
+
log(`using existing payload v${manifest.version}`);
|
|
300
|
+
return manifest.version;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
log(`fetching payload v${manifest.version} (have: ${active || 'none'})`);
|
|
304
|
+
let tarball, signature;
|
|
305
|
+
try {
|
|
306
|
+
[tarball, signature] = await Promise.all([
|
|
307
|
+
fetchBuffer(manifest.payloadUrl, PAYLOAD_DOWNLOAD_TIMEOUT_MS),
|
|
308
|
+
fetchBuffer(manifest.signatureUrl, PAYLOAD_DOWNLOAD_TIMEOUT_MS),
|
|
309
|
+
]);
|
|
310
|
+
} catch (e) {
|
|
311
|
+
warn(`payload download failed (${e.message}); using cached payload if any.`);
|
|
312
|
+
return null;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
// Verify Ed25519 signature over tarball bytes.
|
|
316
|
+
let pubKey;
|
|
317
|
+
try { pubKey = loadPubKey(); }
|
|
318
|
+
catch (e) { err(`embedded pubkey is corrupt: ${e.message}`); return null; }
|
|
319
|
+
|
|
320
|
+
let valid = false;
|
|
321
|
+
try { valid = crypto.verify(null, tarball, pubKey, signature); }
|
|
322
|
+
catch (e) { err(`signature verify threw: ${e.message}`); return null; }
|
|
323
|
+
if (!valid) {
|
|
324
|
+
err(`SIGNATURE INVALID for payload v${manifest.version} — refusing to install. Keeping cached payload.`);
|
|
325
|
+
return null;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
// Verify sha256 (defence-in-depth — catches a benign CDN swap).
|
|
329
|
+
if (manifest.sha256) {
|
|
330
|
+
const got = crypto.createHash('sha256').update(tarball).digest('hex');
|
|
331
|
+
if (got !== manifest.sha256) {
|
|
332
|
+
err(`sha256 mismatch (got ${got}, expected ${manifest.sha256}) — refusing to install.`);
|
|
333
|
+
return null;
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
// Extract into a fresh dir; only flip .version once it's intact.
|
|
338
|
+
const destDir = payloadDir(manifest.version);
|
|
339
|
+
try {
|
|
340
|
+
fs.rmSync(destDir, { recursive: true, force: true });
|
|
341
|
+
await extractTarGz(tarball, destDir);
|
|
342
|
+
if (!fs.existsSync(path.join(destDir, 'entry.js'))) {
|
|
343
|
+
throw new Error('payload missing entry.js after extraction');
|
|
344
|
+
}
|
|
345
|
+
// Persist the verified tar + sig alongside (lets ops re-verify
|
|
346
|
+
// off-machine without redownload, and lets uninstall clear them).
|
|
347
|
+
fs.writeFileSync(path.join(PAYLOAD_ROOT, `${manifest.version}.tar.gz`), tarball);
|
|
348
|
+
fs.writeFileSync(path.join(PAYLOAD_ROOT, `${manifest.version}.sig`), signature);
|
|
349
|
+
writeActiveVersion(manifest.version);
|
|
350
|
+
log(`installed payload v${manifest.version}`);
|
|
351
|
+
return manifest.version;
|
|
352
|
+
} catch (e) {
|
|
353
|
+
err(`payload install failed: ${e.message}`);
|
|
354
|
+
try { fs.rmSync(destDir, { recursive: true, force: true }); } catch {}
|
|
355
|
+
return null;
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
function dispatchToPayload(version) {
|
|
360
|
+
const entry = path.join(payloadDir(version), 'entry.js');
|
|
361
|
+
if (!fs.existsSync(entry)) {
|
|
362
|
+
err(`payload entry.js missing at ${entry}`);
|
|
363
|
+
process.exit(70); // EX_SOFTWARE
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
// Tell the payload where it lives + what bootstrap loaded it. The
|
|
367
|
+
// payload uses these to find its bundled-asset siblings (installer-ui/)
|
|
368
|
+
// without needing SEA-asset extraction.
|
|
369
|
+
process.env.EMPIR3_BRIDGE_PAYLOAD_DIR = payloadDir(version);
|
|
370
|
+
process.env.EMPIR3_BRIDGE_PAYLOAD_VERSION = version;
|
|
371
|
+
process.env.EMPIR3_BRIDGE_BOOTSTRAP_VERSION = BOOTSTRAP_VERSION;
|
|
372
|
+
|
|
373
|
+
// Give the payload a clean require() that resolves against its own
|
|
374
|
+
// extracted tree (so any internal `require('./foo')` works).
|
|
375
|
+
const payloadRequire = Module.createRequire(entry);
|
|
376
|
+
const payload = payloadRequire(entry);
|
|
377
|
+
|
|
378
|
+
// Payload contract: export an async start(argv) function. argv is the
|
|
379
|
+
// forwarded CLI args (excluding node + script).
|
|
380
|
+
if (typeof payload.start !== 'function') {
|
|
381
|
+
err('payload entry.js does not export start(argv)');
|
|
382
|
+
process.exit(70);
|
|
383
|
+
}
|
|
384
|
+
return payload.start(process.argv.slice(2));
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
// ─── Uninstall (network-free) ──────────────────────────────────────────
|
|
388
|
+
//
|
|
389
|
+
// Uninstall is a teardown: it must never depend on the network or on
|
|
390
|
+
// fetching a fresh payload — downloading the very thing we're about to
|
|
391
|
+
// delete is nonsensical and would fail offline. main() dispatches to the
|
|
392
|
+
// cached payload's canonical uninstall when one is extracted; this native
|
|
393
|
+
// fallback runs only when no usable payload is on disk (corrupt / partial
|
|
394
|
+
// install), so a broken payload can never leave the user unable to remove
|
|
395
|
+
// the bridge. It mirrors payload-entry.js uninstall() — keep them aligned.
|
|
396
|
+
|
|
397
|
+
function showUninstallDoneDialog(steps) {
|
|
398
|
+
if (process.platform !== 'win32') return;
|
|
399
|
+
try {
|
|
400
|
+
const body =
|
|
401
|
+
'Empir3 Bridge has been uninstalled.\n\n' +
|
|
402
|
+
`${steps} item(s) were removed. You can delete Empir3Setup.exe whenever you like.\n\n` +
|
|
403
|
+
'If Chrome is open, the helper extension disappears the next time you restart it.';
|
|
404
|
+
const b64 = Buffer.from(body, 'utf8').toString('base64');
|
|
405
|
+
const ps =
|
|
406
|
+
'Add-Type -AssemblyName System.Windows.Forms | Out-Null; ' +
|
|
407
|
+
'[System.Windows.Forms.MessageBox]::Show(' +
|
|
408
|
+
`[System.Text.Encoding]::UTF8.GetString([System.Convert]::FromBase64String('${b64}')),` +
|
|
409
|
+
"'Empir3 Bridge','OK','Information') | Out-Null";
|
|
410
|
+
spawnSync('powershell', ['-NoProfile', '-NonInteractive', '-STA', '-Command', ps],
|
|
411
|
+
{ stdio: 'ignore', windowsHide: true });
|
|
412
|
+
} catch {}
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
function nativeUninstall() {
|
|
416
|
+
log('uninstalling (native fallback — no cached payload to delegate to)');
|
|
417
|
+
let steps = 0;
|
|
418
|
+
const isWin = process.platform === 'win32';
|
|
419
|
+
|
|
420
|
+
if (isWin) {
|
|
421
|
+
// Kill the tray first so it can't respawn anything we kill on a port.
|
|
422
|
+
const tk = spawnSync('taskkill', ['/F', '/IM', 'Empir3Tray.exe'], { encoding: 'utf8' });
|
|
423
|
+
if (tk.status === 0) { log(' killed Empir3Tray.exe'); steps++; }
|
|
424
|
+
|
|
425
|
+
// Stop any running daemon (anything listening on 3006-3306).
|
|
426
|
+
const ns = spawnSync('netstat', ['-ano'], { encoding: 'utf8' });
|
|
427
|
+
if (ns.stdout) {
|
|
428
|
+
const seen = new Set();
|
|
429
|
+
for (const port of [3006, 3106, 3206, 3306]) {
|
|
430
|
+
for (const line of ns.stdout.split('\n')) {
|
|
431
|
+
if ((line.includes(`127.0.0.1:${port}`) || line.includes(`0.0.0.0:${port}`)) && /LISTENING/.test(line)) {
|
|
432
|
+
const m = line.match(/(\d+)\s*$/);
|
|
433
|
+
if (m) seen.add(m[1]);
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
for (const pid of seen) {
|
|
438
|
+
if (pid === String(process.pid)) continue;
|
|
439
|
+
spawnSync('taskkill', ['/F', '/PID', pid], { stdio: 'ignore' });
|
|
440
|
+
log(` killed bridge daemon pid ${pid}`); steps++;
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
// Autostart entry — critical: without this it relaunches on next login.
|
|
445
|
+
if (spawnSync('reg', ['query', AUTOSTART_KEY, '/v', AUTOSTART_VALUE_NAME], { encoding: 'utf8' }).status === 0) {
|
|
446
|
+
spawnSync('reg', ['delete', AUTOSTART_KEY, '/v', AUTOSTART_VALUE_NAME, '/f'], { stdio: 'ignore' });
|
|
447
|
+
log(' removed Windows autostart'); steps++;
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
// Chrome force-install policy — only the slots that hold OUR extension.
|
|
451
|
+
const q = spawnSync('reg', ['query', FORCELIST_KEY], { encoding: 'utf8' });
|
|
452
|
+
if (q.status === 0 && q.stdout) {
|
|
453
|
+
const re = /\s+(\S+)\s+REG_SZ\s+(.+?)\s*$/gm;
|
|
454
|
+
let m;
|
|
455
|
+
while ((m = re.exec(q.stdout)) !== null) {
|
|
456
|
+
if (m[2].startsWith(EXTENSION_ID + ';')) {
|
|
457
|
+
spawnSync('reg', ['delete', FORCELIST_KEY, '/v', m[1], '/f'], { stdio: 'ignore' });
|
|
458
|
+
log(` removed Chrome force-install policy (slot ${m[1]})`); steps++;
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
// Start Menu shortcut + parent folder (if empty).
|
|
464
|
+
try {
|
|
465
|
+
if (fs.existsSync(START_MENU_LNK)) {
|
|
466
|
+
fs.rmSync(START_MENU_LNK, { force: true });
|
|
467
|
+
log(' removed Start Menu shortcut'); steps++;
|
|
468
|
+
}
|
|
469
|
+
const folder = path.dirname(START_MENU_LNK);
|
|
470
|
+
if (fs.existsSync(folder) && fs.readdirSync(folder).length === 0) fs.rmdirSync(folder);
|
|
471
|
+
} catch {}
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
// Cached payloads + extracted runtime files (the whole ~/.empir3-bridge).
|
|
475
|
+
try {
|
|
476
|
+
fs.rmSync(BRIDGE_HOME, { recursive: true, force: true });
|
|
477
|
+
log(' cleared ~/.empir3-bridge (payloads + runtime files)'); steps++;
|
|
478
|
+
} catch {}
|
|
479
|
+
|
|
480
|
+
// Auth, settings, logs. The running Empir3Setup.exe lives here and can't
|
|
481
|
+
// delete itself — that's expected and documented.
|
|
482
|
+
if (isWin) {
|
|
483
|
+
try {
|
|
484
|
+
if (fs.existsSync(APPDATA_DIR)) {
|
|
485
|
+
fs.rmSync(APPDATA_DIR, { recursive: true, force: true });
|
|
486
|
+
log(' cleared %APPDATA%/Empir3 (auth, settings, logs)'); steps++;
|
|
487
|
+
}
|
|
488
|
+
} catch (e) {
|
|
489
|
+
warn(`could not fully clear %APPDATA%/Empir3: ${e.message}`);
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
log(`uninstall complete (${steps} steps).`);
|
|
494
|
+
log('Note: Empir3Setup.exe can be deleted manually.');
|
|
495
|
+
showUninstallDoneDialog(steps);
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
async function main() {
|
|
499
|
+
const argv = process.argv.slice(2);
|
|
500
|
+
|
|
501
|
+
// Bootstrap-only debug flags. Help/version of the bridge itself live in
|
|
502
|
+
// the payload (so users get the SHIPPED help, not the frozen version
|
|
503
|
+
// baked into the exe).
|
|
504
|
+
if (argv.includes('--bootstrap-version')) {
|
|
505
|
+
console.log(`empir3-bootstrap ${BOOTSTRAP_VERSION}`);
|
|
506
|
+
return;
|
|
507
|
+
}
|
|
508
|
+
if (argv.includes('--bootstrap-pubkey')) {
|
|
509
|
+
console.log(PAYLOAD_PUBKEY_HEX);
|
|
510
|
+
return;
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
// Uninstall runs BEFORE any network/update logic. It tears the install
|
|
514
|
+
// down using only what's already on disk — never downloads (you don't
|
|
515
|
+
// fetch the thing you're about to delete, and it must work offline).
|
|
516
|
+
// Delegate to the cached payload's canonical uninstall when present;
|
|
517
|
+
// otherwise fall back to the self-contained native cleanup.
|
|
518
|
+
if (argv.includes('--uninstall')) {
|
|
519
|
+
const cached = readActiveVersion();
|
|
520
|
+
if (cached && payloadIsExtracted(cached)) {
|
|
521
|
+
log(`uninstalling via cached payload v${cached} (offline)`);
|
|
522
|
+
await dispatchToPayload(cached);
|
|
523
|
+
} else {
|
|
524
|
+
nativeUninstall();
|
|
525
|
+
}
|
|
526
|
+
return;
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
// Try to bring the local payload up to date with the server's manifest.
|
|
530
|
+
// Returns the new active version on success, null on any kind of fail.
|
|
531
|
+
let active = await tryUpdate();
|
|
532
|
+
if (!active) active = readActiveVersion();
|
|
533
|
+
|
|
534
|
+
if (!active || !payloadIsExtracted(active)) {
|
|
535
|
+
err('No usable payload installed and update failed. ' +
|
|
536
|
+
'First-run requires network access to ' + VERSION_URL + '.');
|
|
537
|
+
process.exit(69); // EX_UNAVAILABLE
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
// Hand off. The payload is now fully responsible for everything
|
|
541
|
+
// (--daemon, --uninstall, --version, --help, installer UI, etc.).
|
|
542
|
+
await dispatchToPayload(active);
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
main().catch((e) => {
|
|
546
|
+
err(`fatal: ${e.stack || e.message}`);
|
|
547
|
+
process.exit(1);
|
|
548
|
+
});
|