@empir3/empir3-bridge 0.3.21

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. package/CHANGELOG.md +1531 -0
  2. package/CODE_OF_CONDUCT.md +9 -0
  3. package/CONTRIBUTING.md +75 -0
  4. package/LICENSE +21 -0
  5. package/README.md +464 -0
  6. package/SECURITY.md +130 -0
  7. package/assets/accuracy-lab.html +2639 -0
  8. package/assets/api-clis-real.jpg +0 -0
  9. package/assets/bridge-console-hero.jpg +0 -0
  10. package/assets/browser-privacy.svg +151 -0
  11. package/assets/demo-orchestration.svg +74 -0
  12. package/assets/desktop-select-region.jpg +0 -0
  13. package/assets/in-page-chat.gif +0 -0
  14. package/assets/orchestration-hero.svg +126 -0
  15. package/assets/social-preview.png +0 -0
  16. package/assets/zara-accent.png +0 -0
  17. package/build/bootstrap.js +548 -0
  18. package/build/build.js +680 -0
  19. package/build/payload-entry.js +649 -0
  20. package/build/payload-signing-pub.json +7 -0
  21. package/docs/AGENT_GUIDE.md +259 -0
  22. package/docs/RELEASE.md +106 -0
  23. package/docs/SAFETY.md +112 -0
  24. package/docs/TESTING.md +181 -0
  25. package/installer/server.js +231 -0
  26. package/installer/ui/app.js +278 -0
  27. package/installer/ui/index.html +24 -0
  28. package/installer/ui/styles.css +146 -0
  29. package/package.json +95 -0
  30. package/scripts/bootstrap-e2e.mjs +650 -0
  31. package/scripts/certify-bridge.mjs +636 -0
  32. package/scripts/check-companion-surface.mjs +118 -0
  33. package/scripts/extract-welcome.mjs +64 -0
  34. package/scripts/gh-route-handler-check.mjs +57 -0
  35. package/scripts/gh-wire-test.mjs +107 -0
  36. package/scripts/publish-downloads.mjs +180 -0
  37. package/scripts/smoke-all-tools.mjs +509 -0
  38. package/scripts/smoke-live-bridge.mjs +696 -0
  39. package/scripts/splice-welcome.mjs +63 -0
  40. package/scripts/welcome-body.txt +2733 -0
  41. package/src/anthropic-client.ts +192 -0
  42. package/src/bootstrap-exe.ts +69 -0
  43. package/src/bridge.ts +2444 -0
  44. package/src/chat.ts +345 -0
  45. package/src/cli-runner.ts +239 -0
  46. package/src/cli.ts +649 -0
  47. package/src/config.ts +199 -0
  48. package/src/desktop-overlay.ps1 +121 -0
  49. package/src/executable-resolver.ts +330 -0
  50. package/src/handlers/agy-imagegen.ts +179 -0
  51. package/src/handlers/github-cli.ts +399 -0
  52. package/src/handlers/higgsfield-cli.ts +783 -0
  53. package/src/launch.js +337 -0
  54. package/src/mcp-server.ts +1265 -0
  55. package/src/pair-claim.ts +218 -0
  56. package/src/payload-daemon.ts +168 -0
  57. package/src/server.ts +21036 -0
  58. package/src/tool-defaults.ts +230 -0
  59. package/src/update-check.js +136 -0
  60. package/tray/build.py +76 -0
  61. package/tray/requirements.txt +2 -0
  62. package/tray/tray.py +1843 -0
@@ -0,0 +1,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
+ }