@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.
Files changed (62) hide show
  1. package/CHANGELOG.md +1531 -0
  2. package/CODE_OF_CONDUCT.md +9 -0
  3. package/CONTRIBUTING.md +75 -0
  4. package/LICENSE +21 -0
  5. package/README.md +464 -0
  6. package/SECURITY.md +130 -0
  7. package/assets/accuracy-lab.html +2639 -0
  8. package/assets/api-clis-real.jpg +0 -0
  9. package/assets/bridge-console-hero.jpg +0 -0
  10. package/assets/browser-privacy.svg +151 -0
  11. package/assets/demo-orchestration.svg +74 -0
  12. package/assets/desktop-select-region.jpg +0 -0
  13. package/assets/in-page-chat.gif +0 -0
  14. package/assets/orchestration-hero.svg +126 -0
  15. package/assets/social-preview.png +0 -0
  16. package/assets/zara-accent.png +0 -0
  17. package/build/bootstrap.js +548 -0
  18. package/build/build.js +680 -0
  19. package/build/payload-entry.js +649 -0
  20. package/build/payload-signing-pub.json +7 -0
  21. package/docs/AGENT_GUIDE.md +259 -0
  22. package/docs/RELEASE.md +106 -0
  23. package/docs/SAFETY.md +112 -0
  24. package/docs/TESTING.md +181 -0
  25. package/installer/server.js +231 -0
  26. package/installer/ui/app.js +278 -0
  27. package/installer/ui/index.html +24 -0
  28. package/installer/ui/styles.css +146 -0
  29. package/package.json +95 -0
  30. package/scripts/bootstrap-e2e.mjs +650 -0
  31. package/scripts/certify-bridge.mjs +636 -0
  32. package/scripts/check-companion-surface.mjs +118 -0
  33. package/scripts/extract-welcome.mjs +64 -0
  34. package/scripts/gh-route-handler-check.mjs +57 -0
  35. package/scripts/gh-wire-test.mjs +107 -0
  36. package/scripts/publish-downloads.mjs +180 -0
  37. package/scripts/smoke-all-tools.mjs +509 -0
  38. package/scripts/smoke-live-bridge.mjs +696 -0
  39. package/scripts/splice-welcome.mjs +63 -0
  40. package/scripts/welcome-body.txt +2733 -0
  41. package/src/anthropic-client.ts +192 -0
  42. package/src/bootstrap-exe.ts +69 -0
  43. package/src/bridge.ts +2444 -0
  44. package/src/chat.ts +345 -0
  45. package/src/cli-runner.ts +239 -0
  46. package/src/cli.ts +649 -0
  47. package/src/config.ts +199 -0
  48. package/src/desktop-overlay.ps1 +121 -0
  49. package/src/executable-resolver.ts +330 -0
  50. package/src/handlers/agy-imagegen.ts +179 -0
  51. package/src/handlers/github-cli.ts +399 -0
  52. package/src/handlers/higgsfield-cli.ts +783 -0
  53. package/src/launch.js +337 -0
  54. package/src/mcp-server.ts +1265 -0
  55. package/src/pair-claim.ts +218 -0
  56. package/src/payload-daemon.ts +168 -0
  57. package/src/server.ts +21036 -0
  58. package/src/tool-defaults.ts +230 -0
  59. package/src/update-check.js +136 -0
  60. package/tray/build.py +76 -0
  61. package/tray/requirements.txt +2 -0
  62. package/tray/tray.py +1843 -0
@@ -0,0 +1,218 @@
1
+ /**
2
+ * pair-claim.ts — redeem a PRE-AUTHORIZED Empir3 pairing code on first run.
3
+ *
4
+ * The standard pairing flow (server.ts `startPairPoll`) has the BRIDGE create a
5
+ * session and the user approve it in a browser at `/connect-bridge?code=`. This
6
+ * module is the inverse, used by the install one-liner:
7
+ *
8
+ * Empir3Setup.exe --pair <code>
9
+ *
10
+ * In that flow the user is ALREADY logged into Empir3 (they got the install link
11
+ * from Vincent in chat), so Empir3 pre-authorizes a pairing session for them and
12
+ * bakes the `code` into the command. The bridge just CLAIMS it on first boot —
13
+ * no second login, no browser round-trip.
14
+ *
15
+ * Deliberately self-contained:
16
+ * - It runs in the bootstrapper / first-run context BEFORE the daemon
17
+ * (server.ts) is listening. Importing server.ts would boot the whole bridge
18
+ * and bind ports, so this module re-implements the small slice it needs.
19
+ * - It writes `bridge-auth.json` in the EXACT shape and location server.ts
20
+ * reads (see server.ts `saveBridgeAuth` / `AUTH_FILE` / `BridgeAuth`). Keep
21
+ * these in sync if the auth schema changes.
22
+ *
23
+ * Contract with Empir3 (must match `startPairPoll` in server.ts):
24
+ * GET <server>/api/auth/pairing-sessions/<code>
25
+ * 200 { status: 'pending' } → keep polling
26
+ * 200 { status: 'claimed', token, userId, email,
27
+ * name, role, channelId, serverUrl, wsUrl } → write auth, done
28
+ * 404 → expired / unknown code
29
+ *
30
+ * Never hangs the install: bounded poll, then a graceful give-up so first-run
31
+ * falls through to the normal (interactive) pairing path.
32
+ */
33
+
34
+ import { mkdirSync, writeFileSync } from 'fs';
35
+ import { homedir } from 'os';
36
+ import { join } from 'path';
37
+ import { request as httpRequest } from 'http';
38
+ import { request as httpsRequest } from 'https';
39
+
40
+ const DEFAULT_EMPIR3_SERVER = 'https://app.empir3.com';
41
+ const LOCAL_DEV_EMPIR3_SERVER = 'http://localhost:3005';
42
+
43
+ // Mirror server.ts: %APPDATA%\Empir3 on Windows, ~/.empir3/Empir3 elsewhere.
44
+ const SETTINGS_DIR = join(process.env.APPDATA || join(homedir(), '.empir3'), 'Empir3');
45
+ const AUTH_FILE = join(SETTINGS_DIR, 'bridge-auth.json');
46
+
47
+ export interface ClaimResult {
48
+ ok: boolean;
49
+ status: 'claimed' | 'expired' | 'timed_out' | 'invalid' | 'error';
50
+ reason?: string;
51
+ user?: { id?: string; email?: string };
52
+ authFile?: string;
53
+ }
54
+
55
+ type ClaimOptions = {
56
+ serverUrl?: string;
57
+ tries?: number;
58
+ intervalMs?: number;
59
+ log?: (msg: string) => void;
60
+ };
61
+
62
+ // ─── server-url helpers (minimal mirror of server.ts) ───────────────────────
63
+
64
+ function normalizeServer(input?: string | null): string {
65
+ const raw = String(input || '').trim();
66
+ if (!raw) return DEFAULT_EMPIR3_SERVER;
67
+ const withProtocol = /^https?:\/\//i.test(raw)
68
+ ? raw
69
+ : (/^(localhost|127\.0\.0\.1|\[::1\])(?::|\/|$)/i.test(raw) ? `http://${raw}` : `https://${raw}`);
70
+ try {
71
+ const u = new URL(withProtocol);
72
+ u.pathname = u.pathname.replace(/\/+$/, '');
73
+ if (u.pathname === '/') u.pathname = '';
74
+ u.search = '';
75
+ u.hash = '';
76
+ return u.toString().replace(/\/+$/, '');
77
+ } catch {
78
+ return DEFAULT_EMPIR3_SERVER;
79
+ }
80
+ }
81
+
82
+ function classifyServer(serverUrl?: string | null): 'production' | 'local-dev' | 'custom' {
83
+ const normalized = normalizeServer(serverUrl);
84
+ let host = '';
85
+ try { host = new URL(normalized).host.toLowerCase(); } catch { /* keep '' */ }
86
+ if (normalized === DEFAULT_EMPIR3_SERVER || host === 'app.empir3.com') return 'production';
87
+ if (normalized === LOCAL_DEV_EMPIR3_SERVER || host === 'localhost:3005' || host === '127.0.0.1:3005') return 'local-dev';
88
+ return 'custom';
89
+ }
90
+
91
+ function defaultWsUrl(serverUrl: string): string {
92
+ try {
93
+ const u = new URL(normalizeServer(serverUrl));
94
+ u.protocol = u.protocol === 'http:' ? 'ws:' : 'wss:';
95
+ u.pathname = '/ws';
96
+ u.search = '';
97
+ u.hash = '';
98
+ return u.toString();
99
+ } catch {
100
+ return 'wss://app.empir3.com/ws';
101
+ }
102
+ }
103
+
104
+ function normalizeWsUrl(wsUrl: string | undefined | null, serverUrl: string): string {
105
+ const fallback = defaultWsUrl(serverUrl);
106
+ if (!wsUrl) return fallback;
107
+ try {
108
+ const u = new URL(wsUrl);
109
+ if (u.pathname.replace(/\/+$/, '') === '/relay') return fallback;
110
+ return u.toString();
111
+ } catch {
112
+ return fallback;
113
+ }
114
+ }
115
+
116
+ function getJson(urlStr: string, timeoutMs: number): Promise<{ status: number; body: any }> {
117
+ return new Promise((resolvePromise, reject) => {
118
+ let u: URL;
119
+ try { u = new URL(urlStr); } catch (e) { reject(e); return; }
120
+ const lib = u.protocol === 'https:' ? httpsRequest : httpRequest;
121
+ const req = lib({
122
+ hostname: u.hostname,
123
+ port: u.port || (u.protocol === 'https:' ? 443 : 80),
124
+ path: u.pathname + (u.search || ''),
125
+ method: 'GET',
126
+ headers: { 'User-Agent': 'empir3-bridge-pair', Accept: 'application/json' },
127
+ }, (response) => {
128
+ let chunks = '';
129
+ response.on('data', (c) => { chunks += c; });
130
+ response.on('end', () => {
131
+ let parsed: any = null;
132
+ try { parsed = JSON.parse(chunks); } catch { /* leave null */ }
133
+ resolvePromise({ status: response.statusCode || 0, body: parsed });
134
+ });
135
+ });
136
+ req.on('error', reject);
137
+ req.setTimeout(timeoutMs, () => req.destroy(new Error('request timed out')));
138
+ req.end();
139
+ });
140
+ }
141
+
142
+ const sleep = (ms: number) => new Promise<void>((r) => setTimeout(r, ms));
143
+
144
+ /** A pairing code is opaque but should look sane before we put it in a URL. */
145
+ function looksLikeCode(code: string): boolean {
146
+ return /^[A-Za-z0-9._-]{6,128}$/.test(code);
147
+ }
148
+
149
+ /**
150
+ * Claim a pre-authorized pairing code and persist bridge-auth.json.
151
+ *
152
+ * Bounded: at most `tries` polls (default 10) spaced `intervalMs` (default
153
+ * 1500ms) apart — ~15s worst case. A pre-authorized session normally returns
154
+ * `claimed` on the first poll; the loop only exists to absorb a brief
155
+ * server-side propagation delay. Returns a structured result; never throws for
156
+ * an expected outcome (expired / timeout / bad code).
157
+ */
158
+ export async function claimPairingCode(code: string, opts: ClaimOptions = {}): Promise<ClaimResult> {
159
+ const log = opts.log || (() => {});
160
+ const trimmed = String(code || '').trim();
161
+ if (!looksLikeCode(trimmed)) {
162
+ return { ok: false, status: 'invalid', reason: 'pairing code missing or malformed' };
163
+ }
164
+
165
+ const serverUrl = normalizeServer(opts.serverUrl || process.env.EMPIR3_SERVER || DEFAULT_EMPIR3_SERVER);
166
+ const tries = Math.max(1, opts.tries ?? 10);
167
+ const intervalMs = Math.max(250, opts.intervalMs ?? 1500);
168
+ const sessionUrl = `${serverUrl}/api/auth/pairing-sessions/${encodeURIComponent(trimmed)}`;
169
+
170
+ let lastReason = 'no response';
171
+ for (let attempt = 1; attempt <= tries; attempt++) {
172
+ try {
173
+ const r = await getJson(sessionUrl, 5000);
174
+
175
+ if (r.status === 404) {
176
+ log(`pairing code expired or unknown (404) after ${attempt} attempt(s)`);
177
+ return { ok: false, status: 'expired', reason: 'code expired or unknown' };
178
+ }
179
+
180
+ const status = r.body?.status;
181
+
182
+ if (status === 'claimed' && r.body?.token) {
183
+ const sUrl = normalizeServer(r.body.serverUrl || serverUrl);
184
+ const auth = {
185
+ legacyToken: r.body.token as string,
186
+ user: {
187
+ id: r.body.userId,
188
+ email: r.body.email,
189
+ name: r.body.name,
190
+ role: r.body.role,
191
+ },
192
+ channelId: r.body.channelId || null,
193
+ serverUrl: sUrl,
194
+ wsUrl: normalizeWsUrl(r.body.wsUrl || r.body.relayUrl, sUrl),
195
+ environment: classifyServer(sUrl),
196
+ };
197
+ mkdirSync(SETTINGS_DIR, { recursive: true });
198
+ writeFileSync(AUTH_FILE, JSON.stringify(auth, null, 2));
199
+ log(`paired as ${auth.user.email || auth.user.id || 'unknown user'} → ${AUTH_FILE}`);
200
+ return { ok: true, status: 'claimed', user: { id: auth.user.id, email: auth.user.email }, authFile: AUTH_FILE };
201
+ }
202
+
203
+ if (status === 'pending') {
204
+ lastReason = 'session still pending authorization';
205
+ } else {
206
+ lastReason = r.body?.error || `unexpected response (HTTP ${r.status}${status ? `, status=${status}` : ''})`;
207
+ }
208
+ log(`attempt ${attempt}/${tries}: ${lastReason}`);
209
+ } catch (e: any) {
210
+ lastReason = e?.message || String(e);
211
+ log(`attempt ${attempt}/${tries} errored: ${lastReason}`);
212
+ }
213
+
214
+ if (attempt < tries) await sleep(intervalMs);
215
+ }
216
+
217
+ return { ok: false, status: 'timed_out', reason: lastReason };
218
+ }
@@ -0,0 +1,168 @@
1
+ import { existsSync, mkdirSync, writeFileSync } from 'fs';
2
+ import { randomBytes } from 'crypto';
3
+ import { execSync } from 'child_process';
4
+ import { homedir } from 'os';
5
+ import { join } from 'path';
6
+
7
+ const PAYLOAD_DIR = process.env.EMPIR3_BRIDGE_PAYLOAD_DIR || __dirname;
8
+ const BRIDGE_BUNDLE = join(PAYLOAD_DIR, 'bundle-bridge.js');
9
+ const SERVER_BUNDLE = join(PAYLOAD_DIR, 'bundle-server.js');
10
+
11
+ // ── Single-instance guard ───────────────────────────────────────────
12
+ // This daemon binds the bridge + wrapper ports in-process. Only ONE bridge
13
+ // can own them, so any predecessor still holding a port must be reaped before
14
+ // we bind — otherwise a second daemon launched while a stale/wedged one is
15
+ // still up collides (EADDRINUSE, zombie Chrome, "CDP direct timeout"). A new
16
+ // daemon launch always means "replace whatever is there", so we reap then bind.
17
+ // (Callers only launch a fresh daemon when they want one — the MCP server
18
+ // reuses a daemon that already answers /api/status instead of relaunching.)
19
+
20
+ /** PIDs LISTENING on a TCP port (Windows). */
21
+ function listenerPids(port: number): number[] {
22
+ if (process.platform !== 'win32') return [];
23
+ try {
24
+ const out = execSync('netstat -ano -p tcp', { encoding: 'utf-8' });
25
+ const pids = new Set<number>();
26
+ for (const line of out.split('\n')) {
27
+ const parts = line.trim().split(/\s+/);
28
+ // Proto LocalAddress ForeignAddress State PID
29
+ if (parts.length >= 5 && /^LISTENING$/i.test(parts[3]) && parts[1].endsWith(`:${port}`)) {
30
+ const pid = Number(parts[4]);
31
+ if (pid > 0) pids.add(pid);
32
+ }
33
+ }
34
+ return [...pids];
35
+ } catch {
36
+ return [];
37
+ }
38
+ }
39
+
40
+ /** Image name for a PID (e.g. "node.exe"), lowercased. */
41
+ function processName(pid: number): string {
42
+ try {
43
+ const out = execSync(`tasklist /FI "PID eq ${pid}" /FO CSV /NH`, { encoding: 'utf-8' });
44
+ const m = out.match(/^"([^"]+)"/);
45
+ return m ? m[1].toLowerCase() : '';
46
+ } catch {
47
+ return '';
48
+ }
49
+ }
50
+
51
+ function killPid(pid: number): void {
52
+ try { execSync(`taskkill /PID ${pid} /F /T`, { stdio: 'ignore' }); } catch {}
53
+ }
54
+
55
+ /**
56
+ * Reap any predecessor bridge holding our ports so we can bind a fresh one.
57
+ * Only kills node.exe (a bridge daemon) and chrome.exe (its driven Chrome) —
58
+ * never an unrelated app that merely happens to use the port.
59
+ */
60
+ function reapPredecessors(bridgePort: number, wrapperPort: number, cdpPort: number): void {
61
+ if (process.platform !== 'win32') return;
62
+ const pids = new Set<number>([
63
+ ...listenerPids(bridgePort),
64
+ ...listenerPids(wrapperPort),
65
+ ...listenerPids(cdpPort),
66
+ ]);
67
+ pids.delete(process.pid);
68
+ let reaped = 0;
69
+ for (const pid of pids) {
70
+ const name = processName(pid);
71
+ if (name === 'node.exe' || name === 'chrome.exe') {
72
+ console.log(`[empir3-bridge] reaping predecessor ${name} PID ${pid} (held bridge port)`);
73
+ killPid(pid);
74
+ reaped++;
75
+ } else if (name) {
76
+ console.log(`[empir3-bridge] port held by ${name} PID ${pid} — leaving it alone`);
77
+ }
78
+ }
79
+ if (reaped > 0) {
80
+ // Give the OS a moment to release the ports (TIME_WAIT) before we bind.
81
+ const until = Date.now() + 1500;
82
+ while (Date.now() < until) { /* brief spin so binding doesn't race the kill */ }
83
+ }
84
+ }
85
+
86
+ function ensureBridgeNonce(): string {
87
+ const nonce = process.env.EMPIR3_BRIDGE_NONCE || randomBytes(8).toString('hex');
88
+ process.env.EMPIR3_BRIDGE_NONCE = nonce;
89
+
90
+ try {
91
+ const dir = join(homedir(), '.empir3-bridge');
92
+ mkdirSync(dir, { recursive: true });
93
+ writeFileSync(join(dir, 'nonce'), nonce, 'utf-8');
94
+ } catch (e: any) {
95
+ console.warn(`[empir3-bridge] failed to write bridge nonce: ${e?.message || e}`);
96
+ }
97
+
98
+ return nonce;
99
+ }
100
+
101
+ function wait(ms: number): Promise<void> {
102
+ return new Promise(resolve => setTimeout(resolve, ms));
103
+ }
104
+
105
+ async function waitFor(
106
+ url: string,
107
+ label: string,
108
+ maxWaitMs: number,
109
+ isReady: (body: any) => boolean = () => true,
110
+ ): Promise<void> {
111
+ const start = Date.now();
112
+ while (Date.now() - start < maxWaitMs) {
113
+ try {
114
+ const res = await fetch(url);
115
+ if (res.ok) {
116
+ const text = await res.text();
117
+ let body: any = null;
118
+ try { body = text ? JSON.parse(text) : null; } catch {}
119
+ if (isReady(body)) return;
120
+ }
121
+ } catch {}
122
+ await wait(500);
123
+ }
124
+ throw new Error(`${label} did not become ready at ${url}`);
125
+ }
126
+
127
+ function loadBundle(script: string, label: string): void {
128
+ if (!existsSync(script)) {
129
+ throw new Error(`Missing ${label} bundle: ${script}`);
130
+ }
131
+ console.log(`[empir3-bridge] loading ${label}: ${script}`);
132
+ require(script);
133
+ }
134
+
135
+ export async function start() {
136
+ const bridgePort = Number(process.env.EMPIR3_BRIDGE_PORT || process.env.EMPIR3_BRIDGE_HTTP_PORT || 9867);
137
+ const wrapperPort = Number(process.env.EMPIR3_PW_PORT || process.env.PW_PORT || 3006);
138
+ const cdpPort = Number(process.env.CDP_PORT || 9222);
139
+ process.env.EMPIR3_BRIDGE_PORT = String(bridgePort);
140
+ process.env.BRIDGE_PORT = String(bridgePort);
141
+ process.env.PW_PORT = String(wrapperPort);
142
+ const nonce = ensureBridgeNonce();
143
+
144
+ console.log(`[empir3-bridge] starting payload runtime v${process.env.EMPIR3_BRIDGE_PAYLOAD_VERSION || 'dev'} nonce=${nonce.slice(0, 6)}...`);
145
+
146
+ // Replace any stale/wedged predecessor holding our ports before we bind.
147
+ reapPredecessors(bridgePort, wrapperPort, cdpPort);
148
+
149
+ loadBundle(BRIDGE_BUNDLE, 'cdp bridge');
150
+ await waitFor(
151
+ `http://127.0.0.1:${bridgePort}/health`,
152
+ 'CDP bridge HTTP server',
153
+ 30_000,
154
+ (body) => body?.port === bridgePort && typeof body?.status === 'string',
155
+ );
156
+
157
+ loadBundle(SERVER_BUNDLE, 'http wrapper');
158
+ await waitFor(`http://127.0.0.1:${wrapperPort}/api/status`, 'HTTP wrapper', 30_000);
159
+
160
+ await new Promise<void>(() => {});
161
+ }
162
+
163
+ if (require.main === module) {
164
+ start().catch((e) => {
165
+ console.error('[empir3-bridge] payload runtime failed:', e?.stack || e?.message || e);
166
+ process.exit(1);
167
+ });
168
+ }