@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,636 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
|
3
|
+
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
|
|
4
|
+
import { spawn, spawnSync } from 'child_process';
|
|
5
|
+
import { createWriteStream, existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs';
|
|
6
|
+
import { homedir, tmpdir } from 'os';
|
|
7
|
+
import { basename, dirname, join, resolve } from 'path';
|
|
8
|
+
import { fileURLToPath } from 'url';
|
|
9
|
+
import WebSocket from 'ws';
|
|
10
|
+
|
|
11
|
+
const ROOT = resolve(fileURLToPath(new URL('..', import.meta.url)));
|
|
12
|
+
const args = new Set(process.argv.slice(2));
|
|
13
|
+
const getArg = (name, fallback = '') => {
|
|
14
|
+
const prefix = `${name}=`;
|
|
15
|
+
const hit = process.argv.slice(2).find((a) => a.startsWith(prefix));
|
|
16
|
+
return hit ? hit.slice(prefix.length) : fallback;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
const PASSES = Number(getArg('--passes', process.env.BRIDGE_CERT_PASSES || '3'));
|
|
20
|
+
const PROD_SERVER = normalizeServer(getArg('--prod', process.env.BRIDGE_CERT_PROD_SERVER || 'https://app.empir3.com'));
|
|
21
|
+
const DEV_SERVER = normalizeServer(getArg('--dev', process.env.BRIDGE_CERT_DEV_SERVER || 'http://localhost:3005'));
|
|
22
|
+
const INSTALLER_ARG = getArg('--installer', process.env.BRIDGE_CERT_INSTALLER || join(ROOT, 'build', 'dist', 'Empir3Setup.exe'));
|
|
23
|
+
const DOWNLOAD_INSTALLER = args.has('--download-installer');
|
|
24
|
+
const SKIP_INSTALL = args.has('--skip-install');
|
|
25
|
+
const SKIP_VINCENT = args.has('--skip-vincent');
|
|
26
|
+
const OUT_ROOT = resolve(getArg('--out', process.env.BRIDGE_CERT_OUT || join(tmpdir(), 'empir3-bridge-cert', stamp())));
|
|
27
|
+
const BRIDGE = 'http://127.0.0.1:3006';
|
|
28
|
+
const EXPECTED_MCP_TOOLS = [
|
|
29
|
+
'browser_status', 'bridge_reliability_status', 'bridge_reliability_smoke', 'bridge_action_log',
|
|
30
|
+
'bridge_safety_status', 'bridge_revoke_control',
|
|
31
|
+
'browser_navigate', 'browser_click', 'browser_click_ref', 'browser_click_xy',
|
|
32
|
+
'browser_type', 'browser_type_ref', 'browser_press', 'browser_scroll',
|
|
33
|
+
'browser_screenshot', 'desktop_monitors', 'desktop_screenshot', 'desktop_click',
|
|
34
|
+
'desktop_hover', 'desktop_drag', 'browser_snapshot', 'browser_text', 'browser_evaluate',
|
|
35
|
+
'browser_highlight', 'browser_chat', 'browser_read_chat', 'browser_record_start',
|
|
36
|
+
'browser_record_stop', 'browser_play', 'browser_recordings', 'browser_refresh',
|
|
37
|
+
];
|
|
38
|
+
|
|
39
|
+
mkdirSync(OUT_ROOT, { recursive: true });
|
|
40
|
+
|
|
41
|
+
const summary = {
|
|
42
|
+
startedAt: new Date().toISOString(),
|
|
43
|
+
outRoot: OUT_ROOT,
|
|
44
|
+
prodServer: PROD_SERVER,
|
|
45
|
+
devServer: DEV_SERVER,
|
|
46
|
+
installer: INSTALLER_ARG,
|
|
47
|
+
passes: [],
|
|
48
|
+
accounts: [],
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
main().catch((err) => {
|
|
52
|
+
summary.fatal = err?.stack || err?.message || String(err);
|
|
53
|
+
writeJson('summary.json', summary);
|
|
54
|
+
console.error(summary.fatal);
|
|
55
|
+
process.exit(1);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
async function main() {
|
|
59
|
+
console.log(`[cert] evidence: ${OUT_ROOT}`);
|
|
60
|
+
const installer = DOWNLOAD_INSTALLER ? await downloadInstaller() : INSTALLER_ARG;
|
|
61
|
+
if (!existsSync(installer)) throw new Error(`Installer not found: ${installer}`);
|
|
62
|
+
summary.installer = installer;
|
|
63
|
+
summary.installerVersion = runCapture(installer, ['--version']).stdout.trim();
|
|
64
|
+
writeJson('installer-version.json', {
|
|
65
|
+
installer,
|
|
66
|
+
version: summary.installerVersion,
|
|
67
|
+
sha256: runCapture('powershell', ['-NoProfile', '-Command', `(Get-FileHash -Algorithm SHA256 -LiteralPath ${psQuote(installer)}).Hash`]).stdout.trim(),
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
const prodA = await createAccount(PROD_SERVER, 'prod-a');
|
|
71
|
+
const prodB = await createAccount(PROD_SERVER, 'prod-b');
|
|
72
|
+
const devA = await createAccount(DEV_SERVER, 'dev-a');
|
|
73
|
+
summary.accounts = [
|
|
74
|
+
redactAccount(prodA),
|
|
75
|
+
redactAccount(prodB),
|
|
76
|
+
redactAccount(devA),
|
|
77
|
+
];
|
|
78
|
+
writeJson('accounts.redacted.json', summary.accounts);
|
|
79
|
+
writeJson('accounts.private.json', [prodA, prodB, devA]);
|
|
80
|
+
|
|
81
|
+
for (let i = 1; i <= PASSES; i++) {
|
|
82
|
+
const passDir = join(OUT_ROOT, `pass-${i}`);
|
|
83
|
+
mkdirSync(passDir, { recursive: true });
|
|
84
|
+
console.log(`[cert] pass ${i}/${PASSES}`);
|
|
85
|
+
const result = await certifyPass(i, passDir, installer, { prodA, prodB, devA });
|
|
86
|
+
summary.passes.push(result);
|
|
87
|
+
writeJson('summary.json', summary);
|
|
88
|
+
if (!result.ok) throw new Error(`Certification pass ${i} failed; see ${passDir}`);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
summary.completedAt = new Date().toISOString();
|
|
92
|
+
summary.ok = true;
|
|
93
|
+
writeJson('summary.json', summary);
|
|
94
|
+
console.log(`[cert] OK - ${PASSES} pass(es) completed`);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
async function certifyPass(pass, passDir, installer, accounts) {
|
|
98
|
+
const checks = [];
|
|
99
|
+
const evidence = (name, value) => {
|
|
100
|
+
const target = join(passDir, name);
|
|
101
|
+
if (Buffer.isBuffer(value)) writeFileSync(target, value);
|
|
102
|
+
else writeFileSync(target, typeof value === 'string' ? value : JSON.stringify(value, null, 2));
|
|
103
|
+
return target;
|
|
104
|
+
};
|
|
105
|
+
const check = async (name, fn) => {
|
|
106
|
+
const start = Date.now();
|
|
107
|
+
try {
|
|
108
|
+
const detail = await fn();
|
|
109
|
+
checks.push({ name, ok: true, elapsedMs: Date.now() - start, detail: trimDetail(detail) });
|
|
110
|
+
return detail;
|
|
111
|
+
} catch (err) {
|
|
112
|
+
checks.push({ name, ok: false, elapsedMs: Date.now() - start, error: err?.message || String(err) });
|
|
113
|
+
throw err;
|
|
114
|
+
}
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
await check('install-and-launch', async () => {
|
|
118
|
+
if (!SKIP_INSTALL) {
|
|
119
|
+
await stopExistingTray();
|
|
120
|
+
const child = spawn(installer, [], { detached: true, stdio: 'ignore', windowsHide: true });
|
|
121
|
+
child.unref();
|
|
122
|
+
}
|
|
123
|
+
const status = await waitForBridge(90_000);
|
|
124
|
+
evidence('status-after-install.json', status);
|
|
125
|
+
return status;
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
await check('welcome-visual', async () => {
|
|
129
|
+
await bridgeCommand({ action: 'navigate', url: `${BRIDGE}/welcome` });
|
|
130
|
+
await sleep(1200);
|
|
131
|
+
const shot = await getBinary(`${BRIDGE}/api/screenshot?maxWidth=1600`);
|
|
132
|
+
const path = evidence('welcome-9867.jpg', shot);
|
|
133
|
+
const html = await getText(`${BRIDGE}/welcome`);
|
|
134
|
+
assertWelcomeScriptSyntax(html);
|
|
135
|
+
evidence('welcome-3006.html', html);
|
|
136
|
+
const ui = await bridgeCommand({
|
|
137
|
+
type: 'evaluate',
|
|
138
|
+
script: `(() => {
|
|
139
|
+
const before = document.querySelector('#panel-mcp')?.classList.contains('active') === true;
|
|
140
|
+
document.querySelector('button[data-mode="empir3"]')?.click();
|
|
141
|
+
const after = document.querySelector('#panel-empir3')?.classList.contains('active') === true;
|
|
142
|
+
return { selectedServer: typeof selectedServer, before, after };
|
|
143
|
+
})()`,
|
|
144
|
+
});
|
|
145
|
+
if (ui?.result?.selectedServer !== 'function' || !ui?.result?.before || !ui?.result?.after) {
|
|
146
|
+
throw new Error(`Welcome mode UI did not initialize correctly: ${JSON.stringify(ui)}`);
|
|
147
|
+
}
|
|
148
|
+
return { screenshot: path, htmlBytes: html.length, modeUi: ui.result };
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
await check('mcp-mode-full-surface', async () => {
|
|
152
|
+
await postJson(`${BRIDGE}/api/install/sign-out`, {});
|
|
153
|
+
await waitForBridge(60_000);
|
|
154
|
+
const mcp = await postJson(`${BRIDGE}/api/install/claude-code`, {});
|
|
155
|
+
evidence('mcp-config.json', mcp.body);
|
|
156
|
+
const result = await runMcpSmoke(installer, passDir);
|
|
157
|
+
evidence('mcp-smoke.json', result);
|
|
158
|
+
return result;
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
await check('prod-account-a-login-relay', async () => {
|
|
162
|
+
const status = await loginBridge(accounts.prodA);
|
|
163
|
+
const relay = await relaySmoke(accounts.prodA, passDir, `prod-a-pass-${pass}`);
|
|
164
|
+
evidence('prod-a-relay.json', relay);
|
|
165
|
+
return { status, relay };
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
await check('prod-account-logout-login-new-account', async () => {
|
|
169
|
+
await signOutBridge();
|
|
170
|
+
const status = await loginBridge(accounts.prodB);
|
|
171
|
+
const relay = await relaySmoke(accounts.prodB, passDir, `prod-b-pass-${pass}`);
|
|
172
|
+
evidence('prod-b-relay.json', relay);
|
|
173
|
+
return { status, relay };
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
await check('dev-account-login-local-server', async () => {
|
|
177
|
+
await signOutBridge();
|
|
178
|
+
const status = await loginBridge(accounts.devA);
|
|
179
|
+
const relay = await relaySmoke(accounts.devA, passDir, `dev-a-pass-${pass}`);
|
|
180
|
+
evidence('dev-a-relay.json', relay);
|
|
181
|
+
return { status, relay };
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
await check('switch-dev-back-to-production', async () => {
|
|
185
|
+
await signOutBridge();
|
|
186
|
+
const status = await loginBridge(accounts.prodA);
|
|
187
|
+
if (!String(status.serverUrl || '').includes('app.empir3.com')) throw new Error(`Expected production server, got ${status.serverUrl}`);
|
|
188
|
+
return status;
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
if (!SKIP_VINCENT) {
|
|
192
|
+
await check('vincent-direct-control', async () => {
|
|
193
|
+
const vincent = await vincentDirectSmoke(accounts.prodA, passDir, pass);
|
|
194
|
+
evidence('vincent-direct.json', vincent);
|
|
195
|
+
return vincent;
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const ok = checks.every((c) => c.ok);
|
|
200
|
+
const result = { pass, ok, checks, finishedAt: new Date().toISOString(), passDir };
|
|
201
|
+
evidence('pass-summary.json', result);
|
|
202
|
+
return result;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
async function runMcpSmoke(installer, passDir) {
|
|
206
|
+
const transport = new StdioClientTransport({
|
|
207
|
+
command: installer,
|
|
208
|
+
args: ['--mcp'],
|
|
209
|
+
env: { ...process.env, BRIDGE_URL: BRIDGE },
|
|
210
|
+
});
|
|
211
|
+
const client = new Client({ name: 'empir3-bridge-cert', version: '1.0.0' });
|
|
212
|
+
await client.connect(transport);
|
|
213
|
+
const calls = [];
|
|
214
|
+
const call = async (name, args = {}) => {
|
|
215
|
+
const started = Date.now();
|
|
216
|
+
try {
|
|
217
|
+
const result = await client.callTool({ name, arguments: args });
|
|
218
|
+
const text = contentText(result).slice(0, 1000);
|
|
219
|
+
if (result?.isError) {
|
|
220
|
+
throw new Error(`MCP tool ${name} returned error: ${text}`);
|
|
221
|
+
}
|
|
222
|
+
calls.push({ name, ok: true, elapsedMs: Date.now() - started, text });
|
|
223
|
+
return result;
|
|
224
|
+
} catch (err) {
|
|
225
|
+
calls.push({ name, ok: false, elapsedMs: Date.now() - started, error: err?.message || String(err) });
|
|
226
|
+
throw err;
|
|
227
|
+
}
|
|
228
|
+
};
|
|
229
|
+
|
|
230
|
+
const tools = await client.listTools();
|
|
231
|
+
const toolNames = tools.tools.map((t) => t.name).sort();
|
|
232
|
+
const missing = EXPECTED_MCP_TOOLS.filter((name) => !toolNames.includes(name));
|
|
233
|
+
if (missing.length) throw new Error(`MCP missing tools: ${missing.join(', ')}`);
|
|
234
|
+
|
|
235
|
+
const html = `<!doctype html><html><body style="font-family:sans-serif;padding:30px;min-height:1400px">
|
|
236
|
+
<h1>Empir3 Bridge Cert</h1>
|
|
237
|
+
<button id="certButton" onclick="window.certClicked=(window.certClicked||0)+1">Cert Button</button>
|
|
238
|
+
<input id="certInput" aria-label="Cert Input" placeholder="type here">
|
|
239
|
+
<div id="result"></div>
|
|
240
|
+
<script>window.certClicked=0</script>
|
|
241
|
+
</body></html>`;
|
|
242
|
+
await call('browser_status');
|
|
243
|
+
await call('browser_navigate', { url: `data:text/html;charset=utf-8,${encodeURIComponent(html)}` });
|
|
244
|
+
await sleep(1000);
|
|
245
|
+
await call('browser_snapshot', { filter: 'all', format: 'json' });
|
|
246
|
+
await call('browser_text');
|
|
247
|
+
await call('browser_click', { selector: '#certButton' });
|
|
248
|
+
await call('browser_type', { selector: '#certInput', text: 'mcp selector typed' });
|
|
249
|
+
await call('browser_press', { key: 'Tab' });
|
|
250
|
+
await call('browser_click_xy', { x: 95, y: 88 });
|
|
251
|
+
await call('browser_scroll', { y: 400 });
|
|
252
|
+
await call('browser_evaluate', { script: '({clicked: window.certClicked, input: document.querySelector("#certInput")?.value})' });
|
|
253
|
+
await call('browser_highlight', { selector: '#certButton' });
|
|
254
|
+
await call('browser_screenshot');
|
|
255
|
+
await call('browser_chat', { message: `Bridge certification MCP chat ${new Date().toISOString()}` });
|
|
256
|
+
await call('browser_read_chat');
|
|
257
|
+
await call('browser_record_start');
|
|
258
|
+
await call('browser_click', { selector: '#certButton' });
|
|
259
|
+
const stopped = await call('browser_record_stop', { name: `cert-pass-${Date.now()}` });
|
|
260
|
+
await call('browser_recordings');
|
|
261
|
+
await call('browser_refresh');
|
|
262
|
+
await call('desktop_monitors');
|
|
263
|
+
await call('desktop_screenshot', { monitor: 'primary' });
|
|
264
|
+
await call('desktop_hover', { monitor: 'primary', x: 25, y: 25 });
|
|
265
|
+
await call('bridge_reliability_status');
|
|
266
|
+
await call('bridge_reliability_smoke');
|
|
267
|
+
await call('bridge_action_log');
|
|
268
|
+
await call('bridge_safety_status');
|
|
269
|
+
|
|
270
|
+
const screenshot = await getBinary(`${BRIDGE}/api/screenshot?maxWidth=1600`);
|
|
271
|
+
writeFileSync(join(passDir, 'mcp-final-browser.jpg'), screenshot);
|
|
272
|
+
|
|
273
|
+
await client.close();
|
|
274
|
+
return {
|
|
275
|
+
tools: toolNames,
|
|
276
|
+
skippedDestructiveTools: ['bridge_revoke_control', 'desktop_click', 'desktop_drag', 'browser_play'],
|
|
277
|
+
recordStop: contentText(stopped).slice(0, 500),
|
|
278
|
+
calls,
|
|
279
|
+
ok: calls.every((c) => c.ok),
|
|
280
|
+
};
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
async function relaySmoke(account, passDir, label) {
|
|
284
|
+
const wsUrl = account.serverUrl.replace(/^http/, 'ws') + `/ws?token=${encodeURIComponent(account.token)}`;
|
|
285
|
+
const ws = await openWs(wsUrl);
|
|
286
|
+
const results = [];
|
|
287
|
+
try {
|
|
288
|
+
for (const item of [
|
|
289
|
+
['desktop:capabilities', { action: 'quick' }],
|
|
290
|
+
['desktop:sysinfo', { action: 'overview' }],
|
|
291
|
+
['desktop:window', { action: 'list', params: {} }],
|
|
292
|
+
['desktop:gui', { action: 'screenshot', params: { quality: 65 } }],
|
|
293
|
+
['desktop:agent-browser', { action: 'status', params: {} }],
|
|
294
|
+
]) {
|
|
295
|
+
const [type, payload] = item;
|
|
296
|
+
const result = await wsRequest(ws, type, payload, 45_000);
|
|
297
|
+
results.push({
|
|
298
|
+
type,
|
|
299
|
+
success: result.success !== false,
|
|
300
|
+
keys: Object.keys(result).sort(),
|
|
301
|
+
imageBytes: result?.data?.thumbnail ? Buffer.byteLength(result.data.thumbnail, 'base64') : 0,
|
|
302
|
+
detail: trimDetail(result),
|
|
303
|
+
});
|
|
304
|
+
}
|
|
305
|
+
const screenshotResult = results.find((r) => r.type === 'desktop:gui' && r.imageBytes);
|
|
306
|
+
if (!screenshotResult) throw new Error('Relay desktop screenshot returned no image bytes');
|
|
307
|
+
} finally {
|
|
308
|
+
ws.close();
|
|
309
|
+
}
|
|
310
|
+
writeJson(join(passDir, `${label}.relay-summary.json`), results);
|
|
311
|
+
return { ok: results.every((r) => r.success), results };
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
async function vincentDirectSmoke(account, passDir, pass) {
|
|
315
|
+
const dm = await postJson(`${account.serverUrl}/api/projects/dm`, { agentId: 'ceo' }, account.token);
|
|
316
|
+
if (dm.status !== 200 || !dm.body?.project?.id) throw new Error(`DM create failed: ${dm.status} ${JSON.stringify(dm.body)}`);
|
|
317
|
+
const projectId = dm.body.project.id;
|
|
318
|
+
const wsUrl = account.serverUrl.replace(/^http/, 'ws') + `/ws?token=${encodeURIComponent(account.token)}`;
|
|
319
|
+
const ws = await openWs(wsUrl);
|
|
320
|
+
const seen = [];
|
|
321
|
+
const content = [
|
|
322
|
+
`Vincent, this is bridge certification pass ${pass}.`,
|
|
323
|
+
'Use desktop_control exactly once with type "window" and action "list".',
|
|
324
|
+
'Then reply with the number of windows you saw and the phrase BRIDGE_CERT_DIRECT_OK.',
|
|
325
|
+
].join(' ');
|
|
326
|
+
try {
|
|
327
|
+
ws.send(JSON.stringify({
|
|
328
|
+
type: 'chat:send',
|
|
329
|
+
payload: {
|
|
330
|
+
projectId,
|
|
331
|
+
content,
|
|
332
|
+
mentionedAgents: [],
|
|
333
|
+
autonomyLevel: 'unrestricted',
|
|
334
|
+
flowPreset: 'balanced',
|
|
335
|
+
webSearchEnabled: false,
|
|
336
|
+
agentTimeout: 180,
|
|
337
|
+
discussionRounds: 1,
|
|
338
|
+
},
|
|
339
|
+
}));
|
|
340
|
+
const complete = await waitWs(ws, (msg) => {
|
|
341
|
+
seen.push(trimDetail(msg));
|
|
342
|
+
return msg.type === 'chat:complete';
|
|
343
|
+
}, 210_000);
|
|
344
|
+
const text = JSON.stringify(complete);
|
|
345
|
+
writeJson(join(passDir, `vincent-pass-${pass}.events.json`), seen);
|
|
346
|
+
if (/Budget limit reached|can't verify the usage balance|No .*model configured/i.test(text)) {
|
|
347
|
+
throw new Error(`Vincent direct blocked by server/account/provider: ${text.slice(0, 500)}`);
|
|
348
|
+
}
|
|
349
|
+
if (!/BRIDGE_CERT_DIRECT_OK|window/i.test(text)) {
|
|
350
|
+
throw new Error(`Vincent direct completed without expected bridge evidence: ${text.slice(0, 500)}`);
|
|
351
|
+
}
|
|
352
|
+
return { ok: true, projectId, complete: trimDetail(complete), eventCount: seen.length };
|
|
353
|
+
} finally {
|
|
354
|
+
ws.close();
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
async function createAccount(serverUrl, lane) {
|
|
359
|
+
const id = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
360
|
+
const email = `bridge-cert-${lane}-${id}@example.com`;
|
|
361
|
+
const password = `BridgeCert!${Math.random().toString(36).slice(2)}${Date.now()}`;
|
|
362
|
+
const name = `Bridge Cert ${lane}`;
|
|
363
|
+
const res = await postJson(`${serverUrl}/api/auth/register`, { email, password, name });
|
|
364
|
+
if (res.status !== 201 || !res.body?.token) {
|
|
365
|
+
throw new Error(`Register ${lane} failed on ${serverUrl}: ${res.status} ${JSON.stringify(res.body)}`);
|
|
366
|
+
}
|
|
367
|
+
return {
|
|
368
|
+
lane,
|
|
369
|
+
serverUrl,
|
|
370
|
+
email,
|
|
371
|
+
password,
|
|
372
|
+
token: res.body.token,
|
|
373
|
+
user: res.body.user,
|
|
374
|
+
channelId: res.body.channelId,
|
|
375
|
+
};
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
async function loginBridge(account) {
|
|
379
|
+
const r = await postJson(`${BRIDGE}/api/install/empir3-login`, {
|
|
380
|
+
email: account.email,
|
|
381
|
+
password: account.password,
|
|
382
|
+
serverUrl: account.serverUrl,
|
|
383
|
+
});
|
|
384
|
+
if (r.status !== 200 || !r.body?.ok) throw new Error(`Bridge login failed: ${r.status} ${JSON.stringify(r.body)}`);
|
|
385
|
+
const status = await waitForRelay(account.email, account.serverUrl, 90_000);
|
|
386
|
+
return status;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
async function signOutBridge() {
|
|
390
|
+
await postJson(`${BRIDGE}/api/install/sign-out`, {});
|
|
391
|
+
await waitForBridge(60_000);
|
|
392
|
+
const deadline = Date.now() + 60_000;
|
|
393
|
+
while (Date.now() < deadline) {
|
|
394
|
+
const status = await getJson(`${BRIDGE}/api/relay-status`).catch(() => null);
|
|
395
|
+
if (status?.body && !status.body.hasAuth) return status.body;
|
|
396
|
+
await sleep(1000);
|
|
397
|
+
}
|
|
398
|
+
throw new Error('Bridge did not sign out within timeout');
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
async function waitForRelay(email, serverUrl, timeoutMs) {
|
|
402
|
+
const deadline = Date.now() + timeoutMs;
|
|
403
|
+
while (Date.now() < deadline) {
|
|
404
|
+
const res = await getJson(`${BRIDGE}/api/relay-status`).catch(() => null);
|
|
405
|
+
const status = res?.body;
|
|
406
|
+
if (status?.authUser?.email === email && status?.relay?.connected) {
|
|
407
|
+
const got = normalizeServer(status.serverUrl || status.relay?.serverUrl || '');
|
|
408
|
+
if (got === normalizeServer(serverUrl)) return status;
|
|
409
|
+
}
|
|
410
|
+
await sleep(1500);
|
|
411
|
+
}
|
|
412
|
+
throw new Error(`Relay did not connect as ${email} on ${serverUrl}`);
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
async function waitForBridge(timeoutMs) {
|
|
416
|
+
const deadline = Date.now() + timeoutMs;
|
|
417
|
+
let last = null;
|
|
418
|
+
while (Date.now() < deadline) {
|
|
419
|
+
try {
|
|
420
|
+
const res = await getJson(`${BRIDGE}/api/status`);
|
|
421
|
+
if (res.status === 200 && res.body?.running) {
|
|
422
|
+
const bridgeUrl = res.body.bridgeUrl || 'http://localhost:9867';
|
|
423
|
+
const health = await getJson(`${bridgeUrl}/health`);
|
|
424
|
+
if (health.status === 200 && health.body?.status === 'connected') {
|
|
425
|
+
return { ...res.body, bridgeHealth: health.body };
|
|
426
|
+
}
|
|
427
|
+
last = { status: res.body, bridgeHealth: health.body };
|
|
428
|
+
} else {
|
|
429
|
+
last = res.body;
|
|
430
|
+
}
|
|
431
|
+
} catch (err) {
|
|
432
|
+
last = err?.message || String(err);
|
|
433
|
+
}
|
|
434
|
+
await sleep(1000);
|
|
435
|
+
}
|
|
436
|
+
throw new Error(`Bridge did not become healthy: ${JSON.stringify(last)}`);
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
async function stopExistingTray() {
|
|
440
|
+
if (process.platform !== 'win32') return;
|
|
441
|
+
spawnSync('taskkill', ['/F', '/IM', 'Empir3Tray.exe'], { stdio: 'ignore', windowsHide: true });
|
|
442
|
+
spawnSync('powershell', [
|
|
443
|
+
'-NoProfile',
|
|
444
|
+
'-Command',
|
|
445
|
+
'$ports=3006,3106,3206,3306,9867; foreach($p in $ports){ Get-NetTCPConnection -LocalPort $p -State Listen -ErrorAction SilentlyContinue | ForEach-Object { try { Stop-Process -Id $_.OwningProcess -Force -ErrorAction SilentlyContinue } catch {} } }',
|
|
446
|
+
], { stdio: 'ignore', windowsHide: true });
|
|
447
|
+
await sleep(1500);
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
async function downloadInstaller() {
|
|
451
|
+
const target = join(OUT_ROOT, 'Empir3Setup.exe');
|
|
452
|
+
await downloadFile(`${PROD_SERVER}/downloads/Empir3Setup.exe`, target);
|
|
453
|
+
return target;
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
async function downloadFile(url, target) {
|
|
457
|
+
mkdirSync(dirname(target), { recursive: true });
|
|
458
|
+
const res = await fetch(url);
|
|
459
|
+
if (!res.ok || !res.body) throw new Error(`Download failed ${url}: ${res.status}`);
|
|
460
|
+
await new Promise((resolvePromise, reject) => {
|
|
461
|
+
const file = createWriteStream(target);
|
|
462
|
+
res.body.pipeTo(new WritableStream({
|
|
463
|
+
write(chunk) { file.write(Buffer.from(chunk)); },
|
|
464
|
+
close() { file.end(resolvePromise); },
|
|
465
|
+
abort(err) { file.destroy(err); reject(err); },
|
|
466
|
+
})).catch(reject);
|
|
467
|
+
});
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
async function bridgeCommand(cmd) {
|
|
471
|
+
const res = await postJson(`${BRIDGE}/api/command`, cmd);
|
|
472
|
+
if (res.status !== 200 || !res.body?.ok) throw new Error(`Bridge command failed: ${res.status} ${JSON.stringify(res.body)}`);
|
|
473
|
+
return res.body.result;
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
async function getJson(url, token) {
|
|
477
|
+
return requestJson('GET', url, undefined, token);
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
async function postJson(url, body, token) {
|
|
481
|
+
return requestJson('POST', url, body, token);
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
async function requestJson(method, url, body, token) {
|
|
485
|
+
const controller = new AbortController();
|
|
486
|
+
const timer = setTimeout(() => controller.abort(), 30_000);
|
|
487
|
+
try {
|
|
488
|
+
const headers = { 'Content-Type': 'application/json' };
|
|
489
|
+
if (token) headers.Authorization = `Bearer ${token}`;
|
|
490
|
+
const res = await fetch(url, {
|
|
491
|
+
method,
|
|
492
|
+
headers,
|
|
493
|
+
body: body === undefined ? undefined : JSON.stringify(body),
|
|
494
|
+
signal: controller.signal,
|
|
495
|
+
});
|
|
496
|
+
const text = await res.text();
|
|
497
|
+
let parsed = null;
|
|
498
|
+
try { parsed = JSON.parse(text); } catch {}
|
|
499
|
+
return { status: res.status, body: parsed, text };
|
|
500
|
+
} finally {
|
|
501
|
+
clearTimeout(timer);
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
async function getText(url) {
|
|
506
|
+
const res = await fetch(url);
|
|
507
|
+
if (!res.ok) throw new Error(`GET ${url} failed: ${res.status}`);
|
|
508
|
+
return res.text();
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
async function getBinary(url) {
|
|
512
|
+
const res = await fetch(url);
|
|
513
|
+
if (!res.ok) throw new Error(`GET ${url} failed: ${res.status}`);
|
|
514
|
+
return Buffer.from(await res.arrayBuffer());
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
async function openWs(url) {
|
|
518
|
+
return new Promise((resolvePromise, reject) => {
|
|
519
|
+
const ws = new WebSocket(url);
|
|
520
|
+
const timer = setTimeout(() => reject(new Error(`WebSocket timeout: ${url}`)), 20_000);
|
|
521
|
+
ws.on('open', () => { clearTimeout(timer); resolvePromise(ws); });
|
|
522
|
+
ws.on('error', reject);
|
|
523
|
+
});
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
function wsRequest(ws, type, payload, timeoutMs) {
|
|
527
|
+
const id = `cert-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
528
|
+
const resultType = `${type}:result`;
|
|
529
|
+
ws.send(JSON.stringify({ type, payload: { id, ...payload } }));
|
|
530
|
+
return waitWs(ws, (msg) => msg.type === resultType && (msg.payload?.id === id || msg.id === id), timeoutMs)
|
|
531
|
+
.then((msg) => msg.payload || msg);
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
function waitWs(ws, predicate, timeoutMs) {
|
|
535
|
+
return new Promise((resolvePromise, reject) => {
|
|
536
|
+
const timer = setTimeout(() => {
|
|
537
|
+
cleanup();
|
|
538
|
+
reject(new Error(`Timed out waiting for websocket message after ${timeoutMs}ms`));
|
|
539
|
+
}, timeoutMs);
|
|
540
|
+
const onMessage = (data) => {
|
|
541
|
+
let msg = null;
|
|
542
|
+
try { msg = JSON.parse(data.toString()); } catch { return; }
|
|
543
|
+
try {
|
|
544
|
+
if (predicate(msg)) {
|
|
545
|
+
cleanup();
|
|
546
|
+
resolvePromise(msg);
|
|
547
|
+
}
|
|
548
|
+
} catch (err) {
|
|
549
|
+
cleanup();
|
|
550
|
+
reject(err);
|
|
551
|
+
}
|
|
552
|
+
};
|
|
553
|
+
const onError = (err) => {
|
|
554
|
+
cleanup();
|
|
555
|
+
reject(err);
|
|
556
|
+
};
|
|
557
|
+
const cleanup = () => {
|
|
558
|
+
clearTimeout(timer);
|
|
559
|
+
ws.off('message', onMessage);
|
|
560
|
+
ws.off('error', onError);
|
|
561
|
+
};
|
|
562
|
+
ws.on('message', onMessage);
|
|
563
|
+
ws.on('error', onError);
|
|
564
|
+
});
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
function runCapture(command, args) {
|
|
568
|
+
const result = spawnSync(command, args, { encoding: 'utf8', windowsHide: true, timeout: 120_000 });
|
|
569
|
+
if (result.status !== 0) {
|
|
570
|
+
throw new Error(`${basename(command)} ${args.join(' ')} failed: ${result.stderr || result.stdout}`);
|
|
571
|
+
}
|
|
572
|
+
return { stdout: result.stdout || '', stderr: result.stderr || '' };
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
function contentText(result) {
|
|
576
|
+
return (result?.content || []).map((item) => item.text || '').join('\n');
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
function assertWelcomeScriptSyntax(html) {
|
|
580
|
+
const match = String(html || '').match(/<script>([\s\S]*?)<\/script>/i);
|
|
581
|
+
if (!match) throw new Error('Welcome page script block not found');
|
|
582
|
+
try {
|
|
583
|
+
new Function(match[1]);
|
|
584
|
+
} catch (err) {
|
|
585
|
+
throw new Error(`Welcome page script syntax error: ${err?.message || String(err)}`);
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
function trimDetail(value) {
|
|
590
|
+
const text = JSON.stringify(value, (_, v) => {
|
|
591
|
+
if (typeof v === 'string' && v.length > 1400) return `${v.slice(0, 1400)}...(${v.length} chars)`;
|
|
592
|
+
return v;
|
|
593
|
+
});
|
|
594
|
+
try { return JSON.parse(text); } catch { return value; }
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
function redactAccount(account) {
|
|
598
|
+
return {
|
|
599
|
+
lane: account.lane,
|
|
600
|
+
serverUrl: account.serverUrl,
|
|
601
|
+
email: account.email,
|
|
602
|
+
user: account.user,
|
|
603
|
+
channelId: account.channelId,
|
|
604
|
+
};
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
function writeJson(nameOrPath, data) {
|
|
608
|
+
const target = nameOrPath.includes(':') || nameOrPath.startsWith('/') || nameOrPath.includes('\\')
|
|
609
|
+
? nameOrPath
|
|
610
|
+
: join(OUT_ROOT, nameOrPath);
|
|
611
|
+
mkdirSync(dirname(target), { recursive: true });
|
|
612
|
+
writeFileSync(target, JSON.stringify(data, null, 2));
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
function normalizeServer(input) {
|
|
616
|
+
const raw = String(input || '').trim();
|
|
617
|
+
const withProtocol = /^https?:\/\//i.test(raw) ? raw : `https://${raw}`;
|
|
618
|
+
const u = new URL(withProtocol);
|
|
619
|
+
u.pathname = u.pathname.replace(/\/+$/, '');
|
|
620
|
+
if (u.pathname === '/') u.pathname = '';
|
|
621
|
+
u.search = '';
|
|
622
|
+
u.hash = '';
|
|
623
|
+
return u.toString().replace(/\/+$/, '');
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
function stamp() {
|
|
627
|
+
return new Date().toISOString().replace(/[:.]/g, '-');
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
function psQuote(value) {
|
|
631
|
+
return `'${String(value).replace(/'/g, "''")}'`;
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
function sleep(ms) {
|
|
635
|
+
return new Promise((resolvePromise) => setTimeout(resolvePromise, ms));
|
|
636
|
+
}
|