@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,179 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Antigravity (`agy`) image generation over the empir3 channel.
|
|
3
|
+
*
|
|
4
|
+
* Unlike Higgsfield's purpose-built media CLI (`higgsfield generate create …`
|
|
5
|
+
* → deterministic bytes), agy is an agentic coding CLI whose Nano Banana Pro 2
|
|
6
|
+
* model generates images during a `--print` turn and WRITES them to a file.
|
|
7
|
+
* So the contract is FILE-BASED, not stdout-based: agy 1.0.3's print mode does
|
|
8
|
+
* not flush stdout in a non-TTY (verified), but it DOES write the requested
|
|
9
|
+
* output file and exit 0. We spawn agy with a prompt telling it to save the
|
|
10
|
+
* image into an empty temp working dir, poll that dir for an image file to
|
|
11
|
+
* appear and stabilize, then read the bytes back.
|
|
12
|
+
*
|
|
13
|
+
* Wire contract (mirrors higgsfield:cli:gen):
|
|
14
|
+
* server → bridge: agy:cli:gen {id, prompt, timeout_sec}
|
|
15
|
+
* bridge → server: agy:cli:gen:progress {id, status} (optional)
|
|
16
|
+
* agy:cli:gen:done {id, exit_code, mime_type, bytes_base64, duration_ms}
|
|
17
|
+
* agy:cli:gen:error {id, stage, error}
|
|
18
|
+
*
|
|
19
|
+
* No `--dangerously-skip-permissions`: a live test confirmed plain `-p` mode
|
|
20
|
+
* runs agy's built-in imagegen and writes the file without a permission prompt
|
|
21
|
+
* (least-privilege; we never want a lent CLI auto-approving arbitrary tools).
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import { spawn } from 'child_process';
|
|
25
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, statSync, rmSync } from 'fs';
|
|
26
|
+
import { join } from 'path';
|
|
27
|
+
import { homedir } from 'os';
|
|
28
|
+
import { randomUUID } from 'crypto';
|
|
29
|
+
|
|
30
|
+
const ARTIFACT_DIR = join(homedir(), '.empir3-bridge', 'artifacts', 'agy');
|
|
31
|
+
const DEFAULT_TIMEOUT_MS = 5 * 60 * 1000;
|
|
32
|
+
const HARD_CAP_MS = 20 * 60 * 1000;
|
|
33
|
+
const MIN_TIMEOUT_MS = 30 * 1000;
|
|
34
|
+
const POLL_INTERVAL_MS = 1000;
|
|
35
|
+
const STABLE_CHECKS = 2; // file size unchanged across N polls ⇒ finished writing
|
|
36
|
+
|
|
37
|
+
// Same resolution as AGY_PTY_CLI_SPEC.fallbackBinPath in server.ts.
|
|
38
|
+
export function findAgyBinary(): string | null {
|
|
39
|
+
const candidate = process.platform === 'win32'
|
|
40
|
+
? join(process.env.LOCALAPPDATA || join(homedir(), 'AppData', 'Local'), 'agy', 'bin', 'agy.exe')
|
|
41
|
+
: join(homedir(), '.local', 'bin', 'agy');
|
|
42
|
+
return existsSync(candidate) ? candidate : null;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function sniffImageMime(buf: Buffer): string {
|
|
46
|
+
if (buf.length >= 4) {
|
|
47
|
+
if (buf[0] === 0xff && buf[1] === 0xd8 && buf[2] === 0xff) return 'image/jpeg';
|
|
48
|
+
if (buf[0] === 0x89 && buf[1] === 0x50 && buf[2] === 0x4e && buf[3] === 0x47) return 'image/png';
|
|
49
|
+
if (buf[0] === 0x47 && buf[1] === 0x49 && buf[2] === 0x46) return 'image/gif';
|
|
50
|
+
if (buf.length >= 12 && buf[0] === 0x52 && buf[1] === 0x49 && buf[2] === 0x46 && buf[3] === 0x46
|
|
51
|
+
&& buf[8] === 0x57 && buf[9] === 0x45 && buf[10] === 0x42 && buf[11] === 0x50) return 'image/webp';
|
|
52
|
+
}
|
|
53
|
+
return 'image/png';
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// agy may name the file itself (it wrote a `.png` that was actually JPEG in
|
|
57
|
+
// testing), so we don't trust a fixed filename — we scan the (initially empty)
|
|
58
|
+
// work dir for any image file and take the newest.
|
|
59
|
+
function findOutputImage(dir: string): string | null {
|
|
60
|
+
let best: string | null = null;
|
|
61
|
+
let bestM = -1;
|
|
62
|
+
try {
|
|
63
|
+
for (const f of readdirSync(dir)) {
|
|
64
|
+
if (!/\.(png|jpe?g|webp|gif)$/i.test(f)) continue;
|
|
65
|
+
const p = join(dir, f);
|
|
66
|
+
try {
|
|
67
|
+
const m = statSync(p).mtimeMs;
|
|
68
|
+
if (m > bestM) { bestM = m; best = p; }
|
|
69
|
+
} catch { /* file vanished mid-scan */ }
|
|
70
|
+
}
|
|
71
|
+
} catch { /* dir gone */ }
|
|
72
|
+
return best;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export interface AgyGenResult {
|
|
76
|
+
bytes: Buffer;
|
|
77
|
+
mimeType: string;
|
|
78
|
+
durationMs: number;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Flat shape (not a discriminated union) so consumers don't depend on
|
|
82
|
+
// discriminant narrowing, which the bridge tsconfig doesn't apply.
|
|
83
|
+
export interface AgyGenOutcome {
|
|
84
|
+
success: boolean;
|
|
85
|
+
result?: AgyGenResult;
|
|
86
|
+
stage?: string;
|
|
87
|
+
error?: string;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export async function agyGenerateImage(params: { prompt: string; timeoutMs?: number }): Promise<AgyGenOutcome> {
|
|
91
|
+
const prompt = typeof params?.prompt === 'string' ? params.prompt.trim() : '';
|
|
92
|
+
if (!prompt) return { success: false, stage: 'bad_request', error: 'agy imagegen: prompt is required' };
|
|
93
|
+
const bin = findAgyBinary();
|
|
94
|
+
if (!bin) return { success: false, stage: 'not_installed', error: 'agy (Antigravity) CLI not installed' };
|
|
95
|
+
|
|
96
|
+
const timeoutMs = Math.min(Math.max(params.timeoutMs || DEFAULT_TIMEOUT_MS, MIN_TIMEOUT_MS), HARD_CAP_MS);
|
|
97
|
+
mkdirSync(ARTIFACT_DIR, { recursive: true });
|
|
98
|
+
const workDir = join(ARTIFACT_DIR, `gen-${randomUUID()}`);
|
|
99
|
+
mkdirSync(workDir, { recursive: true });
|
|
100
|
+
|
|
101
|
+
const fullPrompt =
|
|
102
|
+
`${prompt}\n\nUse your built-in image generation (Nano Banana) to create this image and SAVE it as a file in the current working directory ` +
|
|
103
|
+
`(e.g. image.png). Output only the saved file path when done. Do not ask any questions.`;
|
|
104
|
+
|
|
105
|
+
const startedAt = Date.now();
|
|
106
|
+
const cleanup = () => { try { rmSync(workDir, { recursive: true, force: true }); } catch { /* ignore */ } };
|
|
107
|
+
|
|
108
|
+
return await new Promise<AgyGenOutcome>((resolve) => {
|
|
109
|
+
let settled = false;
|
|
110
|
+
let lastSize = -1;
|
|
111
|
+
let stableCount = 0;
|
|
112
|
+
|
|
113
|
+
const child = spawn(bin, ['-p', fullPrompt, '--add-dir', workDir], {
|
|
114
|
+
cwd: workDir,
|
|
115
|
+
windowsHide: true,
|
|
116
|
+
stdio: ['ignore', 'ignore', 'ignore'],
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
const done = (r: AgyGenOutcome) => {
|
|
120
|
+
if (settled) return;
|
|
121
|
+
settled = true;
|
|
122
|
+
clearInterval(poller);
|
|
123
|
+
clearTimeout(watchdog);
|
|
124
|
+
try { child.kill(); } catch { /* ignore */ }
|
|
125
|
+
resolve(r);
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
const tryHarvest = (): boolean => {
|
|
129
|
+
const out = findOutputImage(workDir);
|
|
130
|
+
if (!out) return false;
|
|
131
|
+
let size = 0;
|
|
132
|
+
try { size = statSync(out).size; } catch { return false; }
|
|
133
|
+
if (size > 0 && size === lastSize) {
|
|
134
|
+
stableCount++;
|
|
135
|
+
if (stableCount >= STABLE_CHECKS) {
|
|
136
|
+
try {
|
|
137
|
+
const bytes = readFileSync(out);
|
|
138
|
+
const mimeType = sniffImageMime(bytes);
|
|
139
|
+
cleanup();
|
|
140
|
+
done({ success: true, result: { bytes, mimeType, durationMs: Date.now() - startedAt } });
|
|
141
|
+
} catch (e: any) {
|
|
142
|
+
cleanup();
|
|
143
|
+
done({ success: false, stage: 'read', error: e?.message || 'failed to read agy output file' });
|
|
144
|
+
}
|
|
145
|
+
return true;
|
|
146
|
+
}
|
|
147
|
+
} else {
|
|
148
|
+
lastSize = size;
|
|
149
|
+
stableCount = 0;
|
|
150
|
+
}
|
|
151
|
+
return false;
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
const watchdog = setTimeout(() => {
|
|
155
|
+
cleanup();
|
|
156
|
+
done({ success: false, stage: 'timeout', error: `agy imagegen timed out after ${Math.round(timeoutMs / 1000)}s` });
|
|
157
|
+
}, timeoutMs);
|
|
158
|
+
|
|
159
|
+
const poller = setInterval(() => { if (!settled) tryHarvest(); }, POLL_INTERVAL_MS);
|
|
160
|
+
|
|
161
|
+
child.on('error', (e: any) => {
|
|
162
|
+
cleanup();
|
|
163
|
+
done({ success: false, stage: 'spawn', error: e?.message || String(e) });
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
// If agy exits, give the poller a couple cycles to catch a just-written
|
|
167
|
+
// file (stable-size check), then fail if nothing landed.
|
|
168
|
+
child.on('exit', (code) => {
|
|
169
|
+
setTimeout(() => {
|
|
170
|
+
if (settled) return;
|
|
171
|
+
if (!findOutputImage(workDir)) {
|
|
172
|
+
cleanup();
|
|
173
|
+
done({ success: false, stage: 'no_output', error: `agy exited (code=${code}) without writing an image file` });
|
|
174
|
+
}
|
|
175
|
+
// else: poller harvests once the file is size-stable
|
|
176
|
+
}, POLL_INTERVAL_MS * (STABLE_CHECKS + 1));
|
|
177
|
+
});
|
|
178
|
+
});
|
|
179
|
+
}
|
|
@@ -0,0 +1,399 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GitHub CLI handler.
|
|
3
|
+
*
|
|
4
|
+
* Same mental model as cli-runner.ts / higgsfield-cli.ts: the user already
|
|
5
|
+
* authenticated `gh` locally; the bridge is a permission gate + execution
|
|
6
|
+
* surface, NOT a GitHub API client. This lets a REMOTE empir3 team agent
|
|
7
|
+
* (Koba / Vincent) act on GitHub as the user through the user's local gh
|
|
8
|
+
* auth, with NO token handoff — exactly parallel to lending Claude Max.
|
|
9
|
+
*
|
|
10
|
+
* Surface: remote / empir3 only (the `github:exec` relay command). It is
|
|
11
|
+
* deliberately NOT exposed as a local MCP tool — local coding agents already
|
|
12
|
+
* run `gh` straight from the shell, so a local wrapper would add nothing.
|
|
13
|
+
*
|
|
14
|
+
* ── This file is the SAFETY BOUNDARY ──────────────────────────────────────
|
|
15
|
+
* Every `gh` invocation is classified into a *scope* (read / pr / issue /
|
|
16
|
+
* repo / release / workflow / admin / api_write). The scope must be enabled
|
|
17
|
+
* in `settings.githubScopes` or the call is refused. A small set of
|
|
18
|
+
* subcommands are HARD-BLOCKED regardless of scopes because they defeat the
|
|
19
|
+
* permission model itself (token exfil, de-auth, arbitrary-code aliases &
|
|
20
|
+
* extensions). Unrecognized commands default-DENY.
|
|
21
|
+
*
|
|
22
|
+
* The consumer (empir3-server choosing to send `github:exec`) is DORMANT
|
|
23
|
+
* until the server-side routing lands (other repo). Shipping this behind
|
|
24
|
+
* `lendGitHubCli` (default OFF) is safe: nothing can invoke it yet.
|
|
25
|
+
*
|
|
26
|
+
* Spawn is argv-only (shell:false), so shell metacharacters inside a gh
|
|
27
|
+
* argument are literal and cannot inject — the classifier only has to reason
|
|
28
|
+
* about which gh subcommand is being run, never about shell quoting.
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
import { spawn } from 'child_process';
|
|
32
|
+
import { resolveExecutable } from '../executable-resolver.js';
|
|
33
|
+
|
|
34
|
+
const STATUS_TIMEOUT_MS = 10 * 1000;
|
|
35
|
+
const EXEC_TIMEOUT_MS = 90 * 1000;
|
|
36
|
+
const SIGTERM_GRACE_MS = 5000;
|
|
37
|
+
const MAX_BUFFER_BYTES = 4 * 1024 * 1024;
|
|
38
|
+
const MAX_OUTPUT_CHARS = 65536;
|
|
39
|
+
|
|
40
|
+
export type GhScope =
|
|
41
|
+
| 'read' | 'pr' | 'issue' | 'repo' | 'release' | 'workflow' | 'admin' | 'api_write';
|
|
42
|
+
|
|
43
|
+
export const GH_SCOPES: GhScope[] = [
|
|
44
|
+
'read', 'pr', 'issue', 'repo', 'release', 'workflow', 'admin', 'api_write',
|
|
45
|
+
];
|
|
46
|
+
|
|
47
|
+
// Default scope matrix when the lend is first switched on — the
|
|
48
|
+
// "write minus account-destroying" baseline. workflow/admin/api_write are
|
|
49
|
+
// off until the user opts in (they spend CI, touch secrets, or do raw
|
|
50
|
+
// writes). Mirrored by the settings UI defaults.
|
|
51
|
+
export function defaultGhScopes(): Record<GhScope, boolean> {
|
|
52
|
+
return {
|
|
53
|
+
read: true, pr: true, issue: true, repo: true, release: true,
|
|
54
|
+
workflow: false, admin: false, api_write: false,
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function normalizeGhScopes(raw: any): Record<GhScope, boolean> {
|
|
59
|
+
const base = defaultGhScopes();
|
|
60
|
+
if (raw && typeof raw === 'object') {
|
|
61
|
+
for (const s of GH_SCOPES) {
|
|
62
|
+
if (typeof raw[s] === 'boolean') base[s] = raw[s];
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
return base;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// ── Binary discovery ────────────────────────────────────────────
|
|
69
|
+
//
|
|
70
|
+
// Centralized in executable-resolver.ts — an in-process PATH + well-known-dir
|
|
71
|
+
// scan (incl. the GitHub CLI install dir, winget Links/Packages, and a GH_BIN
|
|
72
|
+
// override) that doesn't depend on the daemon's inherited PATH. This avoids the
|
|
73
|
+
// class of bug where a winget-installed `gh` resolved from the user's shell but
|
|
74
|
+
// not from the tray-launched daemon's `where gh`.
|
|
75
|
+
function findGhBinary(): string | null {
|
|
76
|
+
return resolveExecutable('gh');
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// ── Spawn helper ────────────────────────────────────────────────
|
|
80
|
+
interface SpawnResult {
|
|
81
|
+
exitCode: number;
|
|
82
|
+
stdout: string;
|
|
83
|
+
stderr: string;
|
|
84
|
+
elapsedMs: number;
|
|
85
|
+
timedOut: boolean;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function spawnCapture(bin: string, argv: string[], timeoutMs: number): Promise<SpawnResult> {
|
|
89
|
+
return new Promise(resolve => {
|
|
90
|
+
const start = Date.now();
|
|
91
|
+
let child;
|
|
92
|
+
try {
|
|
93
|
+
const isWinShim = process.platform === 'win32' && /\.(cmd|bat)$/i.test(bin);
|
|
94
|
+
if (isWinShim) {
|
|
95
|
+
// Node 18.20+/20.12+ refuse to spawn .cmd directly on Windows
|
|
96
|
+
// (CVE-2024-27980). Wrap via cmd.exe with shell:false so each arg
|
|
97
|
+
// is passed literally.
|
|
98
|
+
child = spawn('cmd.exe', ['/d', '/s', '/c', bin, ...argv], { windowsHide: true });
|
|
99
|
+
} else {
|
|
100
|
+
child = spawn(bin, argv, { windowsHide: true });
|
|
101
|
+
}
|
|
102
|
+
} catch (e: any) {
|
|
103
|
+
resolve({ exitCode: -1, stdout: '', stderr: `spawn failed: ${e?.message || String(e)}`, elapsedMs: Date.now() - start, timedOut: false });
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
let stdout = '';
|
|
108
|
+
let stderr = '';
|
|
109
|
+
let timedOut = false;
|
|
110
|
+
let killed = false;
|
|
111
|
+
let stdoutBytes = 0;
|
|
112
|
+
let stderrBytes = 0;
|
|
113
|
+
|
|
114
|
+
const timer = setTimeout(() => {
|
|
115
|
+
timedOut = true;
|
|
116
|
+
killed = true;
|
|
117
|
+
try { child.kill('SIGTERM'); } catch {}
|
|
118
|
+
setTimeout(() => { try { child.kill('SIGKILL'); } catch {} }, SIGTERM_GRACE_MS);
|
|
119
|
+
}, timeoutMs);
|
|
120
|
+
|
|
121
|
+
child.stdout?.on('data', (chunk: Buffer) => {
|
|
122
|
+
stdoutBytes += chunk.length;
|
|
123
|
+
if (stdoutBytes <= MAX_BUFFER_BYTES) stdout += chunk.toString('utf-8');
|
|
124
|
+
});
|
|
125
|
+
child.stderr?.on('data', (chunk: Buffer) => {
|
|
126
|
+
stderrBytes += chunk.length;
|
|
127
|
+
if (stderrBytes <= MAX_BUFFER_BYTES) stderr += chunk.toString('utf-8');
|
|
128
|
+
});
|
|
129
|
+
child.on('error', (err: any) => {
|
|
130
|
+
stderr += `\n[spawn error] ${err?.message || String(err)}`;
|
|
131
|
+
});
|
|
132
|
+
child.on('close', (code: number | null) => {
|
|
133
|
+
clearTimeout(timer);
|
|
134
|
+
resolve({
|
|
135
|
+
exitCode: killed ? -1 : (code ?? -1),
|
|
136
|
+
stdout,
|
|
137
|
+
stderr,
|
|
138
|
+
elapsedMs: Date.now() - start,
|
|
139
|
+
timedOut,
|
|
140
|
+
});
|
|
141
|
+
});
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function clamp(s: string): string {
|
|
146
|
+
if (s.length <= MAX_OUTPUT_CHARS) return s;
|
|
147
|
+
return `${s.slice(0, MAX_OUTPUT_CHARS)}\n… (truncated, ${s.length} total chars)`;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// ── Argv normalization ──────────────────────────────────────────
|
|
151
|
+
//
|
|
152
|
+
// Accept argv as a string[] (preferred — no ambiguity) or a single command
|
|
153
|
+
// string (tokenized quote-aware). A leading "gh" token is stripped so both
|
|
154
|
+
// ["gh","pr","list"] and ["pr","list"] work. Because the spawn is argv-only
|
|
155
|
+
// (shell:false), quote-tokenization only affects *which* gh subcommand we
|
|
156
|
+
// classify; it can never produce shell injection.
|
|
157
|
+
function toArgv(input: unknown): { argv: string[] | null; error?: string } {
|
|
158
|
+
let argv: string[];
|
|
159
|
+
if (Array.isArray(input)) {
|
|
160
|
+
argv = input.map(x => String(x));
|
|
161
|
+
} else if (typeof input === 'string') {
|
|
162
|
+
argv = tokenize(input);
|
|
163
|
+
} else {
|
|
164
|
+
return { argv: null, error: 'github:exec requires `args` as a string[] (preferred) or a command string' };
|
|
165
|
+
}
|
|
166
|
+
argv = argv.filter(a => a.length > 0);
|
|
167
|
+
if (argv.length && argv[0].toLowerCase() === 'gh') argv = argv.slice(1);
|
|
168
|
+
if (!argv.length) return { argv: null, error: 'github:exec: empty gh command' };
|
|
169
|
+
return { argv };
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function tokenize(cmd: string): string[] {
|
|
173
|
+
const out: string[] = [];
|
|
174
|
+
const re = /"([^"]*)"|'([^']*)'|(\S+)/g;
|
|
175
|
+
let m: RegExpExecArray | null;
|
|
176
|
+
while ((m = re.exec(cmd)) !== null) {
|
|
177
|
+
out.push(m[1] ?? m[2] ?? m[3] ?? '');
|
|
178
|
+
}
|
|
179
|
+
return out;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// ── Scope classification — the safety boundary ──────────────────
|
|
183
|
+
|
|
184
|
+
export interface GhClassification {
|
|
185
|
+
decision: 'allow' | 'block';
|
|
186
|
+
scope?: GhScope;
|
|
187
|
+
reason?: string;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// Read verbs that are non-mutating across most command groups.
|
|
191
|
+
const READ_VERBS = new Set([
|
|
192
|
+
'list', 'view', 'status', 'diff', 'checks', 'download', 'watch', 'browse', 'get', 'ls',
|
|
193
|
+
]);
|
|
194
|
+
|
|
195
|
+
// Subcommands that defeat the permission model itself — refused regardless
|
|
196
|
+
// of any scope. Token exfil + de-auth + identity swap + arbitrary code.
|
|
197
|
+
function hardBlock(argv: string[]): string | null {
|
|
198
|
+
const [cmd, verb] = [argv[0], argv[1]];
|
|
199
|
+
if (cmd === 'auth') {
|
|
200
|
+
if (verb === 'status') return null; // read-only, allowed under `read`
|
|
201
|
+
return 'gh auth ' + (verb || '') + ' is blocked: it can print/rotate/revoke the access token or swap the active identity. This is never lent.';
|
|
202
|
+
}
|
|
203
|
+
if (cmd === 'alias') {
|
|
204
|
+
if (verb === 'list') return null;
|
|
205
|
+
return 'gh alias is blocked: aliases can embed shell commands (`!...`) and run arbitrary code.';
|
|
206
|
+
}
|
|
207
|
+
if (cmd === 'extension' || cmd === 'extensions' || cmd === 'ext') {
|
|
208
|
+
if (verb === 'list') return null;
|
|
209
|
+
return 'gh extension is blocked: installing/running an extension executes arbitrary third-party code.';
|
|
210
|
+
}
|
|
211
|
+
return null;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function apiIsWrite(argv: string[]): boolean {
|
|
215
|
+
// `gh api` defaults to GET. It becomes a write when an explicit non-GET
|
|
216
|
+
// method is set, or when fields/body are supplied (gh auto-switches to
|
|
217
|
+
// POST). Treat anything that isn't unambiguously a GET as api_write.
|
|
218
|
+
for (let i = 1; i < argv.length; i++) {
|
|
219
|
+
const a = argv[i];
|
|
220
|
+
if (a === '-X' || a === '--method') {
|
|
221
|
+
const m = (argv[i + 1] || '').toUpperCase();
|
|
222
|
+
if (m && m !== 'GET') return true;
|
|
223
|
+
} else if (/^--method=/i.test(a)) {
|
|
224
|
+
if (a.split('=')[1]?.toUpperCase() !== 'GET') return true;
|
|
225
|
+
} else if (/^-X./.test(a)) {
|
|
226
|
+
// Combined short form, e.g. -XPOST
|
|
227
|
+
if (a.slice(2).toUpperCase() !== 'GET') return true;
|
|
228
|
+
} else if (['-f', '-F', '--field', '--raw-field', '--input'].includes(a) || /^--(field|raw-field|input)=/.test(a)) {
|
|
229
|
+
return true; // fields/body present → gh sends POST
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
return false;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
export function classifyGhCommand(argv: string[]): GhClassification {
|
|
236
|
+
const blocked = hardBlock(argv);
|
|
237
|
+
if (blocked) return { decision: 'block', reason: blocked };
|
|
238
|
+
|
|
239
|
+
const cmd = argv[0];
|
|
240
|
+
const verb = argv[1] || '';
|
|
241
|
+
|
|
242
|
+
// Meta / always-read commands.
|
|
243
|
+
if (['version', '--version', 'help', '--help', 'status', 'search', 'auth'].includes(cmd)) {
|
|
244
|
+
return { decision: 'allow', scope: 'read' };
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
const isRead = READ_VERBS.has(verb);
|
|
248
|
+
|
|
249
|
+
switch (cmd) {
|
|
250
|
+
case 'pr':
|
|
251
|
+
return { decision: 'allow', scope: isRead ? 'read' : 'pr' };
|
|
252
|
+
case 'issue':
|
|
253
|
+
if (verb === 'delete') return { decision: 'allow', scope: 'admin' };
|
|
254
|
+
return { decision: 'allow', scope: isRead ? 'read' : 'issue' };
|
|
255
|
+
case 'release':
|
|
256
|
+
return { decision: 'allow', scope: isRead ? 'read' : 'release' };
|
|
257
|
+
case 'repo':
|
|
258
|
+
if (verb === 'delete') return { decision: 'allow', scope: 'admin' };
|
|
259
|
+
return { decision: 'allow', scope: isRead ? 'read' : 'repo' };
|
|
260
|
+
case 'run':
|
|
261
|
+
// workflow runs: cancel/rerun/delete spend or mutate CI
|
|
262
|
+
return { decision: 'allow', scope: isRead ? 'read' : 'workflow' };
|
|
263
|
+
case 'workflow':
|
|
264
|
+
return { decision: 'allow', scope: isRead ? 'read' : 'workflow' };
|
|
265
|
+
case 'cache':
|
|
266
|
+
return { decision: 'allow', scope: isRead ? 'read' : 'workflow' };
|
|
267
|
+
case 'label':
|
|
268
|
+
case 'project':
|
|
269
|
+
case 'gist':
|
|
270
|
+
return { decision: 'allow', scope: isRead ? 'read' : 'repo' };
|
|
271
|
+
case 'api':
|
|
272
|
+
return { decision: 'allow', scope: apiIsWrite(argv) ? 'api_write' : 'read' };
|
|
273
|
+
case 'secret':
|
|
274
|
+
case 'variable':
|
|
275
|
+
case 'org':
|
|
276
|
+
case 'ssh-key':
|
|
277
|
+
case 'gpg-key':
|
|
278
|
+
case 'codespace':
|
|
279
|
+
case 'ruleset':
|
|
280
|
+
case 'config':
|
|
281
|
+
// Account/infra surface. Reads still gated behind admin to keep the
|
|
282
|
+
// surface small — these are off by default anyway.
|
|
283
|
+
if (isRead && (cmd === 'config' || cmd === 'ruleset')) return { decision: 'allow', scope: 'read' };
|
|
284
|
+
return { decision: 'allow', scope: 'admin' };
|
|
285
|
+
case 'browse':
|
|
286
|
+
return { decision: 'allow', scope: 'read' };
|
|
287
|
+
default:
|
|
288
|
+
return { decision: 'block', reason: `gh ${cmd} is not a recognized command in the lend allowlist — refused (default-deny).` };
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
// ── Public handlers ─────────────────────────────────────────────
|
|
293
|
+
|
|
294
|
+
export interface GithubStatusResult {
|
|
295
|
+
installed: boolean;
|
|
296
|
+
version?: string | null;
|
|
297
|
+
authenticated?: boolean;
|
|
298
|
+
account?: string | null; // active GitHub login, for the empir3 capability report
|
|
299
|
+
accounts?: string[]; // all logged-in logins
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
export async function githubStatus(_params: Record<string, unknown> = {}): Promise<{ success: true; result: GithubStatusResult }> {
|
|
303
|
+
const bin = findGhBinary();
|
|
304
|
+
if (!bin) return { success: true, result: { installed: false, authenticated: false } };
|
|
305
|
+
|
|
306
|
+
const ver = await spawnCapture(bin, ['--version'], STATUS_TIMEOUT_MS);
|
|
307
|
+
const vMatch = /gh version (\S+)/i.exec(ver.stdout || '');
|
|
308
|
+
|
|
309
|
+
// `gh auth status` exits 0 iff logged in. Parse the login(s); never surface
|
|
310
|
+
// the token (gh self-masks it as gho_*** anyway, but we don't echo that
|
|
311
|
+
// line regardless).
|
|
312
|
+
const auth = await spawnCapture(bin, ['auth', 'status'], STATUS_TIMEOUT_MS);
|
|
313
|
+
const authedText = `${auth.stdout}\n${auth.stderr}`;
|
|
314
|
+
const accounts: string[] = [];
|
|
315
|
+
const re = /Logged in to \S+ account (\S+)/g;
|
|
316
|
+
let m: RegExpExecArray | null;
|
|
317
|
+
while ((m = re.exec(authedText)) !== null) accounts.push(m[1]);
|
|
318
|
+
// Active account: the login whose block carries "Active account: true".
|
|
319
|
+
let active: string | null = accounts[0] || null;
|
|
320
|
+
const activeBlock = /account (\S+)[^]*?Active account:\s*true/i.exec(authedText);
|
|
321
|
+
if (activeBlock) active = activeBlock[1];
|
|
322
|
+
|
|
323
|
+
return {
|
|
324
|
+
success: true,
|
|
325
|
+
result: {
|
|
326
|
+
installed: true,
|
|
327
|
+
version: vMatch?.[1] || null,
|
|
328
|
+
authenticated: auth.exitCode === 0 && accounts.length > 0,
|
|
329
|
+
account: active,
|
|
330
|
+
accounts,
|
|
331
|
+
},
|
|
332
|
+
};
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
export interface GithubExecParams {
|
|
336
|
+
args?: string[] | string;
|
|
337
|
+
scopes?: Record<string, boolean>;
|
|
338
|
+
cwd?: string;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/**
|
|
342
|
+
* Run a gh command on behalf of a remote/team agent, gated by the scope
|
|
343
|
+
* matrix. The caller (server dispatch) MUST have already confirmed the
|
|
344
|
+
* master `lendGitHubCli` opt-in; this function enforces the per-scope layer.
|
|
345
|
+
*/
|
|
346
|
+
export async function githubExec(params: GithubExecParams): Promise<any> {
|
|
347
|
+
const bin = findGhBinary();
|
|
348
|
+
if (!bin) return { success: false, error: 'gh (GitHub CLI) not installed', stage: 'not_installed' };
|
|
349
|
+
|
|
350
|
+
const { argv, error } = toArgv(params?.args);
|
|
351
|
+
if (!argv) return { success: false, error, stage: 'bad_args' };
|
|
352
|
+
|
|
353
|
+
const scopes = normalizeGhScopes(params?.scopes);
|
|
354
|
+
const klass = classifyGhCommand(argv);
|
|
355
|
+
if (klass.decision === 'block') {
|
|
356
|
+
return { success: false, error: klass.reason || 'gh command refused', stage: 'blocked', command: argv.join(' ') };
|
|
357
|
+
}
|
|
358
|
+
const scope = klass.scope as GhScope;
|
|
359
|
+
if (!scopes[scope]) {
|
|
360
|
+
return {
|
|
361
|
+
success: false,
|
|
362
|
+
stage: 'scope_disabled',
|
|
363
|
+
scope,
|
|
364
|
+
error: `gh ${argv[0]} maps to the "${scope}" scope, which is not enabled for this device. Turn it on in the bridge GitHub CLI settings.`,
|
|
365
|
+
command: argv.join(' '),
|
|
366
|
+
};
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
const r = await spawnCapture(bin, argv, EXEC_TIMEOUT_MS);
|
|
370
|
+
if (r.timedOut) {
|
|
371
|
+
return { success: false, stage: 'timeout', scope, error: `gh timed out after ${r.elapsedMs}ms`, command: argv.join(' ') };
|
|
372
|
+
}
|
|
373
|
+
return {
|
|
374
|
+
success: r.exitCode === 0,
|
|
375
|
+
scope,
|
|
376
|
+
command: argv.join(' '),
|
|
377
|
+
exitCode: r.exitCode,
|
|
378
|
+
stdout: clamp(r.stdout),
|
|
379
|
+
stderr: clamp(r.stderr),
|
|
380
|
+
durationMs: r.elapsedMs,
|
|
381
|
+
...(r.exitCode === 0 ? {} : { stage: 'cli_error', error: (r.stderr.trim() || r.stdout.trim() || `gh exited ${r.exitCode}`).slice(0, 2000) }),
|
|
382
|
+
};
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
/** Lightweight presence/auth probe for the capability announcement + UI row. */
|
|
386
|
+
export async function probeGithubCli(deviceOptedIn: boolean, scopes: Record<GhScope, boolean>) {
|
|
387
|
+
const status = await githubStatus();
|
|
388
|
+
const r = status.result;
|
|
389
|
+
return {
|
|
390
|
+
available: !!r.installed,
|
|
391
|
+
path: findGhBinary(),
|
|
392
|
+
version: r.version || null,
|
|
393
|
+
authenticated: !!r.authenticated,
|
|
394
|
+
account: r.account || null,
|
|
395
|
+
accounts: r.accounts || [],
|
|
396
|
+
device_opted_in: deviceOptedIn,
|
|
397
|
+
scopes,
|
|
398
|
+
};
|
|
399
|
+
}
|