@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,118 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { readFileSync } from 'node:fs';
|
|
3
|
+
import { dirname, resolve } from 'node:path';
|
|
4
|
+
import { fileURLToPath } from 'node:url';
|
|
5
|
+
|
|
6
|
+
const root = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
|
7
|
+
const serverSource = readFileSync(resolve(root, 'src/server.ts'), 'utf8');
|
|
8
|
+
const bridgeUrl = process.env.BRIDGE_SMOKE_URL || '';
|
|
9
|
+
|
|
10
|
+
const staticNeedles = [
|
|
11
|
+
'desktop:app',
|
|
12
|
+
'desktop:clipboard',
|
|
13
|
+
'desktop:execute',
|
|
14
|
+
'desktop:notify',
|
|
15
|
+
'desktop:file',
|
|
16
|
+
'desktop:file:pull',
|
|
17
|
+
'desktop:project:file',
|
|
18
|
+
'desktop:sync:push',
|
|
19
|
+
'desktop:capabilities',
|
|
20
|
+
'desktop:sysinfo',
|
|
21
|
+
'desktop:window',
|
|
22
|
+
'desktop:gui',
|
|
23
|
+
'desktop:agent-browser',
|
|
24
|
+
'desktop:browse',
|
|
25
|
+
'click_ref',
|
|
26
|
+
'type_ref',
|
|
27
|
+
'click_selector',
|
|
28
|
+
'type_selector',
|
|
29
|
+
'read_chat',
|
|
30
|
+
'recordings',
|
|
31
|
+
'desktop_cursor_position',
|
|
32
|
+
];
|
|
33
|
+
|
|
34
|
+
const failures = [];
|
|
35
|
+
for (const needle of staticNeedles) {
|
|
36
|
+
if (!serverSource.includes(needle)) failures.push(`missing static surface: ${needle}`);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
async function postCommand(cmd) {
|
|
40
|
+
const res = await fetch(`${bridgeUrl}/api/command`, {
|
|
41
|
+
method: 'POST',
|
|
42
|
+
headers: { 'Content-Type': 'application/json' },
|
|
43
|
+
body: JSON.stringify(cmd),
|
|
44
|
+
});
|
|
45
|
+
const body = await res.json().catch(() => ({}));
|
|
46
|
+
if (!res.ok || !body.ok) {
|
|
47
|
+
return { ok: false, status: res.status, body };
|
|
48
|
+
}
|
|
49
|
+
return { ok: true, result: body.result };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
if (bridgeUrl) {
|
|
53
|
+
const requireCommand = async (label, cmd) => {
|
|
54
|
+
const r = await postCommand(cmd);
|
|
55
|
+
const text = JSON.stringify(r);
|
|
56
|
+
if (!r.ok) failures.push(`${label}: HTTP command failed ${text}`);
|
|
57
|
+
else if (/Unknown command|Unsupported desktop message|Unsupported .* action/i.test(text)) failures.push(`${label}: unsupported ${text}`);
|
|
58
|
+
return r.ok ? r.result : null;
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
const liveCommands = [
|
|
62
|
+
{ label: 'capabilities quick', cmd: { type: 'desktop:capabilities:quick' } },
|
|
63
|
+
{ label: 'capabilities check_cli', cmd: { type: 'desktop:capabilities:check_cli', name: 'node' } },
|
|
64
|
+
{ label: 'sysinfo overview', cmd: { type: 'desktop:sysinfo:overview' } },
|
|
65
|
+
{ label: 'sysinfo battery', cmd: { type: 'desktop:sysinfo:battery' } },
|
|
66
|
+
{ label: 'sysinfo installed', cmd: { type: 'desktop:sysinfo:installed' } },
|
|
67
|
+
{ label: 'window list', cmd: { type: 'desktop:window:list' } },
|
|
68
|
+
{ label: 'window active', cmd: { type: 'desktop:window:active' } },
|
|
69
|
+
{ label: 'gui monitors', cmd: { type: 'desktop:gui:monitors' } },
|
|
70
|
+
{ label: 'gui position', cmd: { type: 'desktop:gui:position' } },
|
|
71
|
+
{ label: 'gui screensize', cmd: { type: 'desktop:gui:screensize' } },
|
|
72
|
+
{ label: 'app is_running', cmd: { type: 'desktop:app:is_running', name: 'explorer' } },
|
|
73
|
+
{ label: 'browser status alias', cmd: { type: 'desktop:browse:status' } },
|
|
74
|
+
{ label: 'browser recordings alias', cmd: { type: 'desktop:agent-browser:recordings' } },
|
|
75
|
+
{ label: 'mcp cursor position', cmd: { type: 'desktop_cursor_position' } },
|
|
76
|
+
];
|
|
77
|
+
|
|
78
|
+
for (const { label, cmd } of liveCommands) {
|
|
79
|
+
await requireCommand(label, cmd);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
if (process.env.BRIDGE_SMOKE_BROWSER === '1') {
|
|
83
|
+
const html = '<!doctype html><html><body><label>Name <input id="name" aria-label="Name"></label><button id="go">Go</button><output id="out"></output><script>document.getElementById("go").onclick=()=>{document.getElementById("out").textContent="clicked:"+document.getElementById("name").value}</script></body></html>';
|
|
84
|
+
await requireCommand('browser navigate alias', { type: 'desktop:browse:navigate', url: `data:text/html;charset=utf-8,${encodeURIComponent(html)}` });
|
|
85
|
+
await new Promise(r => setTimeout(r, 700));
|
|
86
|
+
await requireCommand('browser type_selector alias', { type: 'desktop:browse:type_selector', selector: '#name', text: 'Vincent selector' });
|
|
87
|
+
await requireCommand('browser click_selector alias', { type: 'desktop:browse:click_selector', selector: '#go' });
|
|
88
|
+
const selectorResult = await requireCommand('browser selector evaluate', { type: 'desktop:browse:evaluate', script: 'document.getElementById("out").textContent' });
|
|
89
|
+
if (!String(selectorResult?.result || '').includes('Vincent selector')) failures.push(`browser selector round trip failed: ${JSON.stringify(selectorResult)}`);
|
|
90
|
+
|
|
91
|
+
const screenshot = await requireCommand('browser screenshot alias', { type: 'desktop:browse:screenshot', quality: 60 });
|
|
92
|
+
if (!screenshot?.base64 || screenshot?.mimeType !== 'image/jpeg' || Number(screenshot?.bytes || 0) < 1000) {
|
|
93
|
+
failures.push(`browser screenshot shape invalid: ${JSON.stringify({ mimeType: screenshot?.mimeType, bytes: screenshot?.bytes })}`);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
await requireCommand('browser ref navigate alias', { type: 'desktop:browse:navigate', url: `data:text/html;charset=utf-8,${encodeURIComponent(html)}` });
|
|
97
|
+
await new Promise(r => setTimeout(r, 700));
|
|
98
|
+
const snap = await requireCommand('browser snapshot alias', { type: 'desktop:browse:snapshot', filter: 'all', format: 'json' });
|
|
99
|
+
const elements = Array.isArray(snap?.snapshot) ? snap.snapshot : (snap?.snapshot?.elements || snap?.snapshot?.tree || snap?.snapshot?.nodes || []);
|
|
100
|
+
const input = elements.find(e => e.role === 'input') || elements.find(e => /Name/i.test(`${e.name || ''} ${e.role || ''}`));
|
|
101
|
+
const button = elements.find(e => e.role === 'button' && /Go/i.test(`${e.name || ''}`)) || elements.find(e => /Go/i.test(`${e.name || ''}`));
|
|
102
|
+
if (!input?.ref || !button?.ref) failures.push(`browser refs missing: ${JSON.stringify(elements.slice(0, 5))}`);
|
|
103
|
+
else {
|
|
104
|
+
await requireCommand('browser type_ref alias', { type: 'desktop:browse:type_ref', ref: input.ref, text: 'Vincent ref' });
|
|
105
|
+
await requireCommand('browser click_ref alias', { type: 'desktop:browse:click_ref', ref: button.ref });
|
|
106
|
+
const refResult = await requireCommand('browser ref evaluate', { type: 'desktop:browse:evaluate', script: 'document.getElementById("out").textContent' });
|
|
107
|
+
if (!String(refResult?.result || '').includes('Vincent ref')) failures.push(`browser ref round trip failed: ${JSON.stringify(refResult)}`);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
if (failures.length) {
|
|
113
|
+
console.error('Companion surface check failed:');
|
|
114
|
+
for (const failure of failures) console.error(`- ${failure}`);
|
|
115
|
+
process.exit(1);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
console.log(`Companion surface check passed${bridgeUrl ? ` against ${bridgeUrl}` : ' (static)'}.`);
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
// Reverse of splice-welcome.mjs: regenerate scripts/welcome-body.txt FROM the
|
|
2
|
+
// current getWelcomeHtml() body in src/server.ts. Use this to re-sync the
|
|
3
|
+
// template after the function was edited directly in server.ts, so a later
|
|
4
|
+
// `node scripts/splice-welcome.mjs` is a no-op instead of clobbering live code.
|
|
5
|
+
//
|
|
6
|
+
// Boundary contract MUST mirror splice-welcome.mjs exactly:
|
|
7
|
+
// spliced server.ts contains "function getWelcomeHtml(apiBase = '') {\n" + body + "\n}"
|
|
8
|
+
// => body = src.slice(bodyStart, stopAt - 2) (strip the leading "\n" after "{"
|
|
9
|
+
// and the trailing "\n}" the splicer adds).
|
|
10
|
+
|
|
11
|
+
import { readFileSync, writeFileSync } from 'node:fs';
|
|
12
|
+
import { fileURLToPath } from 'node:url';
|
|
13
|
+
import { dirname, join } from 'node:path';
|
|
14
|
+
|
|
15
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
16
|
+
const ROOT = join(here, '..');
|
|
17
|
+
const SRV = join(ROOT, 'src', 'server.ts');
|
|
18
|
+
const BODY = join(here, 'welcome-body.txt');
|
|
19
|
+
|
|
20
|
+
const src = readFileSync(SRV, 'utf8');
|
|
21
|
+
|
|
22
|
+
const startMarker = "function getWelcomeHtml(apiBase = '') {";
|
|
23
|
+
const startIdx = src.indexOf(startMarker);
|
|
24
|
+
if (startIdx === -1) throw new Error('start marker not found in src/server.ts');
|
|
25
|
+
|
|
26
|
+
// Identical brace/string/comment walk to splice-welcome.mjs.
|
|
27
|
+
let i = startIdx;
|
|
28
|
+
let depth = 0;
|
|
29
|
+
let inSingle = false, inDouble = false, inBacktick = false, inLine = false, inBlock = false;
|
|
30
|
+
let prevCh = '';
|
|
31
|
+
let stopAt = -1;
|
|
32
|
+
for (; i < src.length; i++) {
|
|
33
|
+
const c = src[i];
|
|
34
|
+
if (inLine) { if (c === '\n') inLine = false; prevCh = c; continue; }
|
|
35
|
+
if (inBlock) { if (prevCh === '*' && c === '/') inBlock = false; prevCh = c; continue; }
|
|
36
|
+
if (!inSingle && !inDouble && !inBacktick) {
|
|
37
|
+
if (c === '/' && src[i+1] === '/') { inLine = true; i++; prevCh = ''; continue; }
|
|
38
|
+
if (c === '/' && src[i+1] === '*') { inBlock = true; i++; prevCh = ''; continue; }
|
|
39
|
+
}
|
|
40
|
+
if (!inDouble && !inBacktick && c === "'" && prevCh !== '\\') { inSingle = !inSingle; prevCh = c; continue; }
|
|
41
|
+
if (!inSingle && !inBacktick && c === '"' && prevCh !== '\\') { inDouble = !inDouble; prevCh = c; continue; }
|
|
42
|
+
if (!inSingle && !inDouble && c === '`' && prevCh !== '\\') { inBacktick = !inBacktick; prevCh = c; continue; }
|
|
43
|
+
if (inSingle || inDouble || inBacktick) { prevCh = c; continue; }
|
|
44
|
+
if (c === '{') depth++;
|
|
45
|
+
else if (c === '}') { depth--; if (depth === 0) { stopAt = i + 1; break; } }
|
|
46
|
+
prevCh = c;
|
|
47
|
+
}
|
|
48
|
+
if (stopAt === -1) throw new Error('matching closing brace not found');
|
|
49
|
+
|
|
50
|
+
// Skip the newline sequence right after the "{" (CRLF or LF).
|
|
51
|
+
let bodyStart = startIdx + startMarker.length;
|
|
52
|
+
if (src[bodyStart] === '\r') bodyStart++;
|
|
53
|
+
if (src[bodyStart] === '\n') bodyStart++;
|
|
54
|
+
else throw new Error(`expected newline right after function signature, got ${JSON.stringify(src.slice(startIdx + startMarker.length, startIdx + startMarker.length + 2))}`);
|
|
55
|
+
|
|
56
|
+
// The closing "}" is at stopAt-1; trim the newline sequence (\r?\n) before it.
|
|
57
|
+
let bodyEnd = stopAt - 1; // index of '}'
|
|
58
|
+
if (src[bodyEnd - 1] === '\n') bodyEnd--;
|
|
59
|
+
if (src[bodyEnd - 1] === '\r') bodyEnd--;
|
|
60
|
+
|
|
61
|
+
const body = src.slice(bodyStart, bodyEnd);
|
|
62
|
+
writeFileSync(BODY, body);
|
|
63
|
+
const lines = (body.match(/\n/g) || []).length + 1;
|
|
64
|
+
console.log(`Extracted getWelcomeHtml body -> welcome-body.txt (${lines} lines, ${body.length} bytes)`);
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
// Focused live check for the lent-GitHub-CLI execution + enforcement layer that
|
|
2
|
+
// the new empir3-channel route (handleEmpir3Message: github:probe / github:exec)
|
|
3
|
+
// delegates to. Exercises the REAL handlers against REAL gh on this machine.
|
|
4
|
+
// Read-only gh commands only. Run: npx tsx scripts/gh-route-handler-check.mjs
|
|
5
|
+
import * as ghNs from '../src/handlers/github-cli.ts';
|
|
6
|
+
const gh = ghNs.default ?? ghNs;
|
|
7
|
+
const { probeGithubCli, githubExec, defaultGhScopes } = gh;
|
|
8
|
+
|
|
9
|
+
const ON = defaultGhScopes(); // read/pr/issue/repo/release on; workflow/admin/api_write off
|
|
10
|
+
let pass = 0, fail = 0;
|
|
11
|
+
const ok = (c, msg, extra) => { (c ? pass++ : fail++); console.log(`${c ? 'PASS' : 'FAIL'} ${msg}${extra ? ` → ${extra}` : ''}`); };
|
|
12
|
+
|
|
13
|
+
console.log('— probe —');
|
|
14
|
+
const p = await probeGithubCli(true, ON);
|
|
15
|
+
ok(p.available === true, 'probe: gh available', `path=${p.path}`);
|
|
16
|
+
ok(p.authenticated === true, 'probe: authenticated', `account=${p.account}`);
|
|
17
|
+
ok(typeof p.account === 'string' && p.account.length > 0, 'probe: active account parsed', p.account);
|
|
18
|
+
ok(p.device_opted_in === true && !!p.scopes, 'probe: opt-in + scope matrix surfaced', JSON.stringify(p.scopes));
|
|
19
|
+
|
|
20
|
+
console.log('\n— exec: read-scope commands (real gh) —');
|
|
21
|
+
const ver = await githubExec({ args: ['--version'], scopes: ON });
|
|
22
|
+
ok(ver.success === true && /gh version/i.test(ver.stdout || ''), 'exec gh --version', (ver.stdout || '').split('\n')[0]);
|
|
23
|
+
ok(ver.scope === 'read' && ver.exitCode === 0, 'exec --version classified read, exit 0');
|
|
24
|
+
|
|
25
|
+
const auth = await githubExec({ args: ['auth', 'status'], scopes: ON });
|
|
26
|
+
ok(auth.success === true && auth.scope === 'read', 'exec gh auth status (read)', `exit ${auth.exitCode}`);
|
|
27
|
+
|
|
28
|
+
const repos = await githubExec({ args: ['repo', 'list', '--limit', '3'], scopes: ON });
|
|
29
|
+
ok(repos.success === true && repos.scope === 'read', 'exec gh repo list --limit 3 (real API call)', `exit ${repos.exitCode}`);
|
|
30
|
+
console.log(' repo list head:', (repos.stdout || repos.stderr || '').split('\n').slice(0, 3).join(' | ').slice(0, 160));
|
|
31
|
+
|
|
32
|
+
console.log('\n— enforcement: scope gate + hard-blocks —');
|
|
33
|
+
const secret = await githubExec({ args: ['secret', 'list'], scopes: ON }); // admin scope, default OFF
|
|
34
|
+
ok(secret.success === false && secret.stage === 'scope_disabled' && secret.scope === 'admin',
|
|
35
|
+
'exec gh secret list refused (scope_disabled: admin)', `stage=${secret.stage} scope=${secret.scope}`);
|
|
36
|
+
|
|
37
|
+
const wf = await githubExec({ args: ['workflow', 'run', 'ci.yml'], scopes: ON }); // workflow scope, default OFF
|
|
38
|
+
ok(wf.success === false && wf.stage === 'scope_disabled' && wf.scope === 'workflow',
|
|
39
|
+
'exec gh workflow run refused (scope_disabled: workflow)', `scope=${wf.scope}`);
|
|
40
|
+
|
|
41
|
+
const tok = await githubExec({ args: ['auth', 'token'], scopes: ON });
|
|
42
|
+
ok(tok.success === false && tok.stage === 'blocked', 'exec gh auth token HARD-BLOCKED', tok.error?.slice(0, 60));
|
|
43
|
+
|
|
44
|
+
const ext = await githubExec({ args: ['extension', 'install', 'x/y'], scopes: ON });
|
|
45
|
+
ok(ext.success === false && ext.stage === 'blocked', 'exec gh extension install HARD-BLOCKED');
|
|
46
|
+
|
|
47
|
+
const bogus = await githubExec({ args: ['frobnicate'], scopes: ON });
|
|
48
|
+
ok(bogus.success === false && bogus.stage === 'blocked', 'exec unknown top-level cmd default-DENY');
|
|
49
|
+
|
|
50
|
+
console.log('\n— scope-enabled passes when toggled on —');
|
|
51
|
+
const adminOn = { ...ON, admin: true };
|
|
52
|
+
const secret2 = await githubExec({ args: ['secret', 'list', '--repo', `${p.account}/nonexistent-repo-xyz`], scopes: adminOn });
|
|
53
|
+
// With admin enabled the scope gate passes; gh itself may cli_error on a bogus repo — that's the right layer.
|
|
54
|
+
ok(secret2.stage !== 'scope_disabled', 'admin scope enabled → passes scope gate (reaches gh)', `stage=${secret2.stage ?? 'ok'}`);
|
|
55
|
+
|
|
56
|
+
console.log(`\n${fail === 0 ? '✅' : '❌'} ${pass} passed, ${fail} failed`);
|
|
57
|
+
process.exit(fail === 0 ? 0 : 1);
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
// Wire-level smoke for the empir3-channel GitHub route.
|
|
2
|
+
//
|
|
3
|
+
// Spins an isolated bridge server (tsx src/server.ts) on an alt port pointed at
|
|
4
|
+
// a LOCAL mock empir3 WS server (this file). The mock drives github:probe +
|
|
5
|
+
// github:exec exactly as empir3-server would, and verifies the bridge's
|
|
6
|
+
// handleEmpir3Message route replies (github:probe:result / github:exec:result)
|
|
7
|
+
// with real gh. Does NOT touch the installed daemon, prod, or Chrome.
|
|
8
|
+
//
|
|
9
|
+
// Run from the bridge repo: node scripts/gh-wire-test.mjs
|
|
10
|
+
import { WebSocketServer } from 'ws';
|
|
11
|
+
import { spawn, execSync } from 'node:child_process';
|
|
12
|
+
|
|
13
|
+
const WS_PORT = 4599;
|
|
14
|
+
const PW_PORT = 3199;
|
|
15
|
+
let pass = 0, fail = 0, child = null, done = false;
|
|
16
|
+
const ok = (c, m, x) => { (c ? pass++ : fail++); console.log(`${c ? 'PASS' : 'FAIL'} ${m}${x ? ` → ${x}` : ''}`); };
|
|
17
|
+
|
|
18
|
+
const wss = new WebSocketServer({ host: '127.0.0.1', port: WS_PORT });
|
|
19
|
+
console.log(`[mock-empir3] listening on ws://127.0.0.1:${WS_PORT}`);
|
|
20
|
+
|
|
21
|
+
function cleanup(code) {
|
|
22
|
+
if (done) return; done = true;
|
|
23
|
+
try { if (child?.pid) execSync(`taskkill /pid ${child.pid} /T /F`, { stdio: 'ignore' }); } catch {}
|
|
24
|
+
try { wss.close(); } catch {}
|
|
25
|
+
console.log(`\n${fail === 0 ? '✅' : '❌'} wire route: ${pass} passed, ${fail} failed`);
|
|
26
|
+
setTimeout(() => process.exit(code ?? (fail === 0 ? 0 : 1)), 300);
|
|
27
|
+
}
|
|
28
|
+
const hardTimer = setTimeout(() => { console.log('[mock-empir3] overall timeout'); fail++; cleanup(1); }, 60_000);
|
|
29
|
+
|
|
30
|
+
wss.on('connection', (ws, req) => {
|
|
31
|
+
console.log(`[mock-empir3] bridge connected: ${req.url}`);
|
|
32
|
+
const waiters = new Map(); // id -> resolve
|
|
33
|
+
const reply = (type, id) => new Promise((res) => { waiters.set(`${type}:${id}`, res); });
|
|
34
|
+
|
|
35
|
+
ws.on('message', (data) => {
|
|
36
|
+
let msg; try { msg = JSON.parse(data.toString()); } catch { return; }
|
|
37
|
+
const p = msg.payload || {};
|
|
38
|
+
// Resolve any waiter keyed by replytype:id
|
|
39
|
+
if (msg.type && p.id != null) {
|
|
40
|
+
const w = waiters.get(`${msg.type}:${p.id}`);
|
|
41
|
+
if (w) { waiters.delete(`${msg.type}:${p.id}`); w(p); }
|
|
42
|
+
}
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
const send = (type, payload) => ws.send(JSON.stringify({ type, payload }));
|
|
46
|
+
|
|
47
|
+
(async () => {
|
|
48
|
+
await new Promise(r => setTimeout(r, 1500)); // let the bridge finish its init announces
|
|
49
|
+
|
|
50
|
+
// 1) probe
|
|
51
|
+
const probeP = reply('github:probe:result', 'p1');
|
|
52
|
+
send('github:probe', { id: 'p1' });
|
|
53
|
+
const probe = await Promise.race([probeP, new Promise(r => setTimeout(() => r(null), 8000))]);
|
|
54
|
+
if (!probe) { ok(false, 'github:probe round-trip (no reply)'); }
|
|
55
|
+
else {
|
|
56
|
+
ok(probe.available === true, 'github:probe:result available', `path=${probe.path}`);
|
|
57
|
+
ok(probe.authenticated === true && !!probe.account, 'github:probe:result authed', `account=${probe.account}`);
|
|
58
|
+
ok(probe.device_opted_in === true, 'github:probe:result device_opted_in (lend ON)', String(probe.device_opted_in));
|
|
59
|
+
ok(!!probe.scopes && probe.scopes.read === true, 'github:probe:result scope matrix', JSON.stringify(probe.scopes));
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// 2) exec: gh --version (read)
|
|
63
|
+
const verP = reply('github:exec:result', 'e1');
|
|
64
|
+
send('github:exec', { id: 'e1', args: ['--version'], timeout_sec: 60 });
|
|
65
|
+
const ver = await Promise.race([verP, new Promise(r => setTimeout(() => r(null), 15000))]);
|
|
66
|
+
ok(ver && ver.success === true && /gh version/i.test(ver.stdout || ''), 'github:exec gh --version round-trip', ver ? (ver.stdout || '').split('\n')[0] : 'no reply');
|
|
67
|
+
ok(ver && ver.scope === 'read' && ver.exitCode === 0, 'github:exec --version → read, exit 0');
|
|
68
|
+
|
|
69
|
+
// 3) exec: real API call (read)
|
|
70
|
+
const repoP = reply('github:exec:result', 'e2');
|
|
71
|
+
send('github:exec', { id: 'e2', args: ['repo', 'list', '--limit', '3'], timeout_sec: 60 });
|
|
72
|
+
const repo = await Promise.race([repoP, new Promise(r => setTimeout(() => r(null), 20000))]);
|
|
73
|
+
ok(repo && repo.success === true && repo.scope === 'read', 'github:exec gh repo list (real API) round-trip', repo ? `exit ${repo.exitCode}` : 'no reply');
|
|
74
|
+
if (repo?.stdout) console.log(' repo list head:', repo.stdout.split('\n').slice(0, 2).join(' | ').slice(0, 140));
|
|
75
|
+
|
|
76
|
+
// 4) exec: scope_disabled (admin off) — comes back as a normal result, success:false
|
|
77
|
+
const secP = reply('github:exec:result', 'e3');
|
|
78
|
+
send('github:exec', { id: 'e3', args: ['secret', 'list'], timeout_sec: 60 });
|
|
79
|
+
const sec = await Promise.race([secP, new Promise(r => setTimeout(() => r(null), 10000))]);
|
|
80
|
+
ok(sec && sec.success === false && sec.stage === 'scope_disabled' && sec.scope === 'admin',
|
|
81
|
+
'github:exec secret list → scope_disabled:admin', sec ? `stage=${sec.stage}` : 'no reply');
|
|
82
|
+
|
|
83
|
+
// 5) exec: hard-blocked
|
|
84
|
+
const tokP = reply('github:exec:result', 'e4');
|
|
85
|
+
send('github:exec', { id: 'e4', args: ['auth', 'token'], timeout_sec: 60 });
|
|
86
|
+
const tok = await Promise.race([tokP, new Promise(r => setTimeout(() => r(null), 10000))]);
|
|
87
|
+
ok(tok && tok.success === false && tok.stage === 'blocked', 'github:exec auth token → blocked', tok ? tok.error?.slice(0, 50) : 'no reply');
|
|
88
|
+
|
|
89
|
+
clearTimeout(hardTimer);
|
|
90
|
+
cleanup();
|
|
91
|
+
})().catch((e) => { console.error('[mock-empir3] driver error:', e); fail++; cleanup(1); });
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
// ── spawn the isolated bridge server pointed at the mock ──
|
|
95
|
+
const env = {
|
|
96
|
+
...process.env,
|
|
97
|
+
PW_PORT: String(PW_PORT), // avoid the daemon's :3006
|
|
98
|
+
EMPIR3_BRIDGE_PORT: '9967', // avoid the daemon's :9867
|
|
99
|
+
EMPIR3_WS_URL: `ws://127.0.0.1:${WS_PORT}`, // override → connect to mock, NOT prod
|
|
100
|
+
EMPIR3_AUTH_TOKEN: 'gh-wire-test-token',
|
|
101
|
+
EMPIR3_SERVER: `http://127.0.0.1:${PW_PORT}`, // keep version-manifest fetch off prod
|
|
102
|
+
};
|
|
103
|
+
console.log('[mock-empir3] spawning isolated bridge: npx tsx src/server.ts (PW_PORT=' + PW_PORT + ')');
|
|
104
|
+
child = spawn('npx', ['tsx', 'src/server.ts'], { env, cwd: process.cwd(), shell: true });
|
|
105
|
+
child.stdout.on('data', d => process.stdout.write(`[bridge] ${d}`));
|
|
106
|
+
child.stderr.on('data', d => process.stderr.write(`[bridge:err] ${d}`));
|
|
107
|
+
child.on('exit', (c) => { if (!done) { console.log(`[bridge] exited early code=${c}`); fail++; cleanup(1); } });
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { existsSync, readFileSync, statSync } from 'fs';
|
|
3
|
+
import { createHash } from 'crypto';
|
|
4
|
+
import { basename, join, resolve } from 'path';
|
|
5
|
+
import { spawnSync } from 'child_process';
|
|
6
|
+
import { fileURLToPath } from 'url';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Publish the bridge download artifacts in a STAGED order so a fresh Go stub can
|
|
10
|
+
* never observe a manifest that points at not-yet-live artifacts, and the public
|
|
11
|
+
* Empir3Setup.exe never reads a manifest before it is live:
|
|
12
|
+
*
|
|
13
|
+
* 1. node + payload tarballs (+ their .sig) ── the manifest points at these
|
|
14
|
+
* 2. bridge-version.json ── now points at #1, all live
|
|
15
|
+
* 3. Empir3Setup.exe ── only after its manifest is live
|
|
16
|
+
*
|
|
17
|
+
* The manifest signature is EMBEDDED (single file, atomic swap) so there is no
|
|
18
|
+
* manifest/.sig race — but artifact-before-manifest-before-exe still matters.
|
|
19
|
+
* After each hop we verify on the SERVER (sha256sum, authoritative — no
|
|
20
|
+
* Cloudflare cache in the way) before proceeding.
|
|
21
|
+
*/
|
|
22
|
+
const root = resolve(fileURLToPath(new URL('..', import.meta.url)));
|
|
23
|
+
const dist = join(root, 'build', 'dist');
|
|
24
|
+
const server = process.env.EMPIR3_DOWNLOAD_HOST;
|
|
25
|
+
const remoteDir = process.env.EMPIR3_DOWNLOAD_DIR;
|
|
26
|
+
const publicBase = process.env.EMPIR3_PAYLOAD_PUBLIC_URL_BASE || 'https://app.empir3.com/downloads';
|
|
27
|
+
const dryRun = process.argv.includes('--dry-run');
|
|
28
|
+
// The deploy target is intentionally NOT hardcoded (this is a public repo). Set
|
|
29
|
+
// both env vars before a real publish; --dry-run can run without them.
|
|
30
|
+
if (!dryRun && (!server || !remoteDir)) {
|
|
31
|
+
fail('set EMPIR3_DOWNLOAD_HOST (e.g. user@host) and EMPIR3_DOWNLOAD_DIR (e.g. /var/www/app/downloads) — the publish target is not stored in the repo');
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function fail(message) {
|
|
35
|
+
console.error(`[publish-downloads] ${message}`);
|
|
36
|
+
process.exit(1);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function run(cmd, args, { capture = false } = {}) {
|
|
40
|
+
const pretty = [cmd, ...args].join(' ');
|
|
41
|
+
console.log(`[publish-downloads] ${pretty}`);
|
|
42
|
+
if (dryRun) return '';
|
|
43
|
+
const result = spawnSync(cmd, args, { stdio: capture ? ['ignore', 'pipe', 'inherit'] : 'inherit', shell: false, encoding: 'utf8' });
|
|
44
|
+
if (result.status !== 0) fail(`command failed: ${pretty}`);
|
|
45
|
+
return capture ? (result.stdout || '') : '';
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function sha256OfFile(p) {
|
|
49
|
+
return createHash('sha256').update(readFileSync(p)).digest('hex');
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function requireArtifact(name) {
|
|
53
|
+
const p = join(dist, name);
|
|
54
|
+
if (!existsSync(p) || !statSync(p).isFile()) fail(`required artifact missing: ${name} (run npm run build:windows)`);
|
|
55
|
+
return p;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Upload each file ATOMICALLY: scp to a temp name, verify the temp's sha256 on
|
|
59
|
+
// the server (authoritative — bypasses Cloudflare), then `mv` into place. The
|
|
60
|
+
// rename is atomic on the same filesystem, so a fresh client can never observe
|
|
61
|
+
// a partially-written file at the final name.
|
|
62
|
+
function uploadAndVerify(label, files) {
|
|
63
|
+
console.log(`\n[publish-downloads] === ${label} ===`);
|
|
64
|
+
for (const f of files) {
|
|
65
|
+
const name = basename(f.path);
|
|
66
|
+
const tmp = `${remoteDir}/${name}.uploading`;
|
|
67
|
+
const final = `${remoteDir}/${name}`;
|
|
68
|
+
run('scp', [f.path, `${server}:${tmp}`]);
|
|
69
|
+
if (dryRun) continue;
|
|
70
|
+
const out = run('ssh', [server, `sha256sum '${tmp}'`], { capture: true });
|
|
71
|
+
const remoteSha = (out.trim().split(/\s+/)[0] || '').toLowerCase();
|
|
72
|
+
if (remoteSha !== f.sha) {
|
|
73
|
+
run('ssh', [server, `rm -f '${tmp}'`]);
|
|
74
|
+
fail(`server sha mismatch for ${name}: local ${f.sha} != remote ${remoteSha}`);
|
|
75
|
+
}
|
|
76
|
+
run('ssh', [server, `mv -f '${tmp}' '${final}'`]); // atomic swap into place
|
|
77
|
+
console.log(`[publish-downloads] ✓ ${name} on server (sha ok, atomic mv)`);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Verify a fresh client can actually fetch the artifact at the EXACT URL it will
|
|
82
|
+
// use, with the right bytes. The artifact URLs carry a cache-bust query, so this
|
|
83
|
+
// bypasses any Cloudflare cache and reflects origin.
|
|
84
|
+
function publicShaCheck(url, wantSha) {
|
|
85
|
+
if (dryRun) return;
|
|
86
|
+
const out = run('ssh', [server, `curl -fsSL '${url}' | sha256sum`], { capture: true });
|
|
87
|
+
const got = (out.trim().split(/\s+/)[0] || '').toLowerCase();
|
|
88
|
+
if (got !== wantSha) fail(`public fetch sha mismatch for ${url}: got ${got}, want ${wantSha}`);
|
|
89
|
+
console.log(`[publish-downloads] ✓ public ${url.split('/').pop()} (sha ok)`);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Like publicShaCheck but retries — for a FIXED-name URL (bridge-version.json)
|
|
93
|
+
// that Cloudflare may cache briefly. Blocks until the public URL returns the
|
|
94
|
+
// expected bytes, or aborts with a purge hint.
|
|
95
|
+
function publicShaCheckRetry(url, wantSha, { tries = 10, delayMs = 3000 } = {}) {
|
|
96
|
+
if (dryRun) return;
|
|
97
|
+
for (let i = 1; i <= tries; i++) {
|
|
98
|
+
const out = run('ssh', [server, `curl -fsSL '${url}' | sha256sum`], { capture: true });
|
|
99
|
+
const got = (out.trim().split(/\s+/)[0] || '').toLowerCase();
|
|
100
|
+
if (got === wantSha) { console.log(`[publish-downloads] ✓ public ${url.split('/').pop()} reflects new bytes (try ${i})`); return; }
|
|
101
|
+
console.log(`[publish-downloads] … public ${url.split('/').pop()} still stale (try ${i}/${tries}); got ${got.slice(0, 12)}…`);
|
|
102
|
+
if (i < tries) Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, delayMs); // block delayMs
|
|
103
|
+
}
|
|
104
|
+
fail(`public ${url} never returned the new sha (${wantSha}). Purge the Cloudflare cache for that URL, then re-run — the exe was NOT published, so nothing is half-live.`);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function publicGet200(name) {
|
|
108
|
+
if (dryRun) return;
|
|
109
|
+
run('ssh', [server, `curl -fsS -o /dev/null -w '%{http_code}' '${publicBase}/${name}' | grep -q 200 || (echo 'not 200' && exit 1)`]);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
if (!existsSync(dist)) fail(`missing dist directory: ${dist}. Run npm run build:windows first.`);
|
|
113
|
+
|
|
114
|
+
const manifestPath = join(dist, 'bridge-version.json');
|
|
115
|
+
if (!existsSync(manifestPath)) fail('required artifact missing: bridge-version.json');
|
|
116
|
+
const manifest = JSON.parse(readFileSync(manifestPath, 'utf-8'));
|
|
117
|
+
if (!manifest.version) fail('bridge-version.json is missing version');
|
|
118
|
+
if (!manifest.nodeVersion) fail('bridge-version.json is missing nodeVersion (rebuild with the Go-bootstrapper build.js)');
|
|
119
|
+
if (!manifest.manifestSignature) fail('bridge-version.json is missing manifestSignature (rebuild)');
|
|
120
|
+
|
|
121
|
+
const payloadTar = `bridge-payload-v${manifest.version}.tar.gz`;
|
|
122
|
+
const payloadSig = `bridge-payload-v${manifest.version}.sig`;
|
|
123
|
+
const nodeTar = `node-win-x64-v${manifest.nodeVersion}.tar.gz`;
|
|
124
|
+
const nodeSig = `node-win-x64-v${manifest.nodeVersion}.sig`;
|
|
125
|
+
|
|
126
|
+
const f = (name, sha) => ({ path: requireArtifact(name), sha });
|
|
127
|
+
|
|
128
|
+
// sha of tarballs comes from the (signed) manifest; sigs/exe/manifest are
|
|
129
|
+
// verified by local-computed sha against the server copy.
|
|
130
|
+
const payloadTarFile = f(payloadTar, manifest.sha256.toLowerCase());
|
|
131
|
+
const nodeTarFile = f(nodeTar, manifest.nodeSha256.toLowerCase());
|
|
132
|
+
const payloadSigFile = f(payloadSig, sha256OfFile(requireArtifact(payloadSig)));
|
|
133
|
+
const nodeSigFile = f(nodeSig, sha256OfFile(requireArtifact(nodeSig)));
|
|
134
|
+
const manifestFile = f('bridge-version.json', sha256OfFile(manifestPath));
|
|
135
|
+
const exeFile = f('Empir3Setup.exe', sha256OfFile(requireArtifact('Empir3Setup.exe')));
|
|
136
|
+
|
|
137
|
+
// Local sanity: the tarballs on disk must match the manifest's declared shas.
|
|
138
|
+
if (sha256OfFile(payloadTarFile.path) !== payloadTarFile.sha) fail('local payload tarball sha != manifest.sha256 — rebuild');
|
|
139
|
+
if (sha256OfFile(nodeTarFile.path) !== nodeTarFile.sha) fail('local node tarball sha != manifest.nodeSha256 — rebuild');
|
|
140
|
+
|
|
141
|
+
console.log(`[publish-downloads] publishing bridge v${manifest.version} (node v${manifest.nodeVersion}) to ${server}:${remoteDir}`);
|
|
142
|
+
run('ssh', [server, `mkdir -p '${remoteDir}'`]);
|
|
143
|
+
|
|
144
|
+
// 1. Artifacts FIRST (the manifest will point at these).
|
|
145
|
+
uploadAndVerify('Stage 1: node + payload artifacts (+ sigs)',
|
|
146
|
+
[nodeTarFile, nodeSigFile, payloadTarFile, payloadSigFile]);
|
|
147
|
+
|
|
148
|
+
// 1b. Confirm a fresh stub can fetch the EXACT signed URLs (payload/node tarballs
|
|
149
|
+
// AND their .sig — the stub fetches all four). These carry a unique ?v=&t= query,
|
|
150
|
+
// so Cloudflare can't serve a stale copy.
|
|
151
|
+
console.log('\n[publish-downloads] === Verify stub-visible artifact URLs ===');
|
|
152
|
+
publicShaCheck(manifest.nodeUrl, manifest.nodeSha256.toLowerCase());
|
|
153
|
+
publicShaCheck(manifest.payloadUrl, manifest.sha256.toLowerCase());
|
|
154
|
+
publicShaCheck(manifest.nodeSignatureUrl, sha256OfFile(nodeSigFile.path));
|
|
155
|
+
publicShaCheck(manifest.signatureUrl, sha256OfFile(payloadSigFile.path));
|
|
156
|
+
|
|
157
|
+
// 2. Manifest NEXT (now everything it references is live).
|
|
158
|
+
uploadAndVerify('Stage 2: signed manifest', [manifestFile]);
|
|
159
|
+
|
|
160
|
+
// 2b. CRITICAL ORDERING GATE: the public, fixed-name manifest URL must already
|
|
161
|
+
// return the NEW bytes BEFORE we publish the exe. The exe is the trigger — if it
|
|
162
|
+
// went live while bridge-version.json was still the stale (possibly pre-Go,
|
|
163
|
+
// nodeUrl-less) manifest, a fresh stub would read the wrong manifest. This
|
|
164
|
+
// fixed-name URL CAN be Cloudflare-cached, so retry briefly; if it never matches,
|
|
165
|
+
// abort and tell the operator to purge the CF cache for bridge-version.json.
|
|
166
|
+
console.log('\n[publish-downloads] === Gate: public manifest must reflect new bytes before exe ===');
|
|
167
|
+
publicShaCheckRetry(`${publicBase}/bridge-version.json`, manifestFile.sha, { tries: 10, delayMs: 3000 });
|
|
168
|
+
|
|
169
|
+
// 3. Empir3Setup.exe LAST (only after the manifest it reads is confirmed live).
|
|
170
|
+
uploadAndVerify('Stage 3: Empir3Setup.exe', [exeFile]);
|
|
171
|
+
|
|
172
|
+
// Final reachability of the exe (a stale-cached old exe still works since it
|
|
173
|
+
// re-reconciles, so a 200 is sufficient here).
|
|
174
|
+
publicGet200('Empir3Setup.exe');
|
|
175
|
+
|
|
176
|
+
console.log('\n[publish-downloads] done');
|
|
177
|
+
console.log(` Installer: ${publicBase}/Empir3Setup.exe`);
|
|
178
|
+
console.log(` Manifest: ${publicBase}/bridge-version.json`);
|
|
179
|
+
console.log(` Node: ${publicBase}/${nodeTar}`);
|
|
180
|
+
console.log(` Payload: ${publicBase}/${payloadTar}`);
|