arena-bridge 0.7.4 → 0.7.6

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 CHANGED
@@ -4,6 +4,31 @@ All notable changes to this project are documented here.
4
4
  The format follows [Keep a Changelog](https://keepachangelog.com/) and the project uses
5
5
  [Semantic Versioning](https://semver.org/).
6
6
 
7
+ ## 0.7.6
8
+
9
+ ### Added
10
+ - Release artifacts: every tag publishes the npm archive, the python sdist and wheel, and the
11
+ macOS app for arm64 and x86_64 as a GitHub Release.
12
+ - `install.sh`: one command installs the command line tool and the macOS app.
13
+ - `feature-tree.md`: the complete feature tree of the product.
14
+ - The ax-tree artifact opens in the app as a readable list of elements, with a switch to the raw
15
+ answer of the tool.
16
+ - `mac_see` asks for the accessibility tree on its own when the first answer has no elements.
17
+
18
+ ### Changed
19
+ - The README says in one line how to get past Gatekeeper.
20
+
21
+ ## 0.7.5
22
+
23
+ ### Added
24
+ - Approvals in Telegram carry native buttons: Approve once, Approve for 3h, Deny. "Approve for 3h"
25
+ also opens a trust window for the same tool.
26
+ - `arena-bridge trust --status --json` prints the trust window as JSON.
27
+ - The native macOS app: a trust window status with the time left, an "Approve for 3 h" button,
28
+ local notifications when a request needs a decision, the command arguments in the work log,
29
+ a click-through to screenshots and accessibility trees, and a button that copies the agent prompt.
30
+ - The app icon: `macos/ArenaBridge/Resources/AppIcon.png`, turned into an `.icns` by `build.sh`.
31
+
7
32
  ## 0.7.4
8
33
 
9
34
  ### Fixed
package/README.md CHANGED
@@ -12,7 +12,7 @@
12
12
  <img src="https://img.shields.io/badge/license-MIT-blue.svg?style=flat-square" alt="license">
13
13
  <img src="https://img.shields.io/badge/node-%3E%3D18-green.svg?style=flat-square" alt="node">
14
14
  <img src="https://img.shields.io/badge/dependencies-0-brightgreen.svg?style=flat-square" alt="dependencies">
15
- <img src="https://img.shields.io/badge/tests-149%20passed-brightgreen.svg?style=flat-square" alt="tests">
15
+ <img src="https://img.shields.io/badge/tests-154%20passed-brightgreen.svg?style=flat-square" alt="tests">
16
16
  </p>
17
17
 
18
18
  <p align="center">
@@ -23,6 +23,8 @@
23
23
 
24
24
  ## Что это
25
25
 
26
+ Полное дерево функциональности — в [feature-tree.md](feature-tree.md).
27
+
26
28
  Облачный агент (Arena.ai Agent Mode, Claude, Cursor, любой MCP-клиент) работает в песочнице
27
29
  с выходом в интернет, но не имеет доступа к вашему компьютеру. `arena-bridge` — это маленький
28
30
  HTTP-сервис на вашей машине: он слушает только localhost, выставляется наружу туннелем
@@ -41,16 +43,31 @@ HTTP-сервис на вашей машине: он слушает только
41
43
 
42
44
  ## Быстрый старт
43
45
 
46
+ Одна команда ставит и командную строку, и нативное приложение для macOS:
47
+
48
+ ```bash
49
+ curl -fsSL https://raw.githubusercontent.com/vernikr/arena-bridge/main/install.sh | sh
50
+ ```
51
+
52
+ Или по отдельности:
53
+
44
54
  ```bash
45
- # 1. установить
46
- npm i -g arena-bridge
55
+ npm i -g arena-bridge # только командная строка
56
+ pip install arena-bridge # то же самое на Python
57
+ ```
47
58
 
48
- # 2. один раз: создать конфиг, проверить машину, поднять API + туннель
59
+ ```bash
60
+ # 1. один раз: создать конфиг, проверить машину, поднять API + туннель
49
61
  arena-bridge setup --root ~/projects --root ~/Documents
50
62
 
51
- # 3. скопировать блок «Paste this into the agent chat» и вставить его в чат с агентом
63
+ # 2. скопировать блок «Paste this into the agent chat» и вставить его в чат с агентом
52
64
  ```
53
65
 
66
+ > **macOS и Gatekeeper.** Приложение подписано локально, поэтому при первом запуске система может
67
+ > сказать «нельзя открыть, так как разработчик не проверен». Обойти это можно без терминала:
68
+ > правый клик по **Arena Bridge** в «Программах» → **Открыть** → **Открыть**. Через терминал:
69
+ > `xattr -dr com.apple.quarantine "/Applications/Arena Bridge.app"`.
70
+
54
71
  `setup` печатает готовый промпт и публичный URL. Дальше — просто говорите агенту задачу.
55
72
 
56
73
  Туннель под присмотром: если `cloudflared`/`ngrok` упадёт, мост поднимет его заново и сам
@@ -275,14 +292,40 @@ Apple Silicon.
275
292
 
276
293
  | Блок в окне | Что делает |
277
294
  | --- | --- |
278
- | Статус | запущен/остановлен, версия, режим, число инструментов, публичный адрес, Telegram |
279
- | Управление | «Запустить» / «Остановить» (служба `ai.arena.bridge`) и «Настроить…» |
280
- | Подтверждения | ожидающие запросы агента с кнопками «Разрешить» / «Отклонить» |
281
- | Журнал | последние записи, фильтр по инструменту, «Сохранить…» в файл |
295
+ | Статус | запущен/остановлен, версия, режим, число инструментов, публичный адрес, уведомления, Telegram |
296
+ | Управление | «Запустить» / «Остановить» (служба `ai.arena.bridge`), «Настроить…», «Скопировать промпт агенту» |
297
+ | Доверенное окно | «Открыть на 3 ч» / «Закрыть» и статус «действует ещё 2:59» |
298
+ | Подтверждения | ожидающие запросы агента с кнопками «Разрешить» / «Разрешить на 3 ч» / «Отклонить» |
299
+ | Журнал | команды с аргументами, клик по скриншоту или ax-tree открывает его целиком, «Сохранить…» в файл (с ax-tree) |
300
+
301
+ Когда агенту нужно решение, приложение показывает системное уведомление macOS
302
+ «Approval needed» — в строке меню и в центре уведомлений. Внутри окна запрос тоже виден
303
+ сразу, в карточке «Подтверждения».
282
304
 
283
305
  «Настроить…» собирает конфиг, службу и запуск одной кнопкой: туннель (ngrok/cloudflared),
284
306
  зарезервированный домен, authtoken, папки для агента, Telegram (токен бота и chat id).
285
307
 
308
+ Иконка приложения лежит в `macos/ArenaBridge/Resources/AppIcon.png`; `build.sh` сам собирает
309
+ из неё `.icns` и кладёт в бандл.
310
+
311
+ ---
312
+
313
+ ## Подтверждения в Telegram: кнопки
314
+
315
+ Под сообщением «Approval needed» появляются нативные кнопки:
316
+
317
+ ```
318
+ Approve once Approve for 3h Deny
319
+ ```
320
+
321
+ * **Approve once** — разрешить этот запрос и всё;
322
+ * **Approve for 3h** — разрешить и открыть доверенное окно на 3 часа для этого же инструмента
323
+ (команды больше не спрашивают);
324
+ * **Deny** — отклонить.
325
+
326
+ Кнопки работают и в приложении на Mac, и в Telegram; сообщение в Telegram после нажатия
327
+ переписывается на итог. Прежние команды `/approve <id>` и `/deny <id>` тоже работают.
328
+
286
329
  ---
287
330
 
288
331
  ## Доверенное окно: работать без подтверждений
@@ -292,6 +335,7 @@ arena-bridge trust --for 2h # всё, на 2
292
335
  arena-bridge trust --for 30m --tools bash # только команды
293
336
  arena-bridge trust --for 1h --paths ~/projects/my-app # только эта папка
294
337
  arena-bridge trust --status
338
+ arena-bridge trust --status --json # для приложений и скриптов
295
339
  arena-bridge trust --off
296
340
  ```
297
341
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "arena-bridge",
3
- "version": "0.7.4",
3
+ "version": "0.7.6",
4
4
  "description": "Give a cloud AI agent (Arena.ai Agent Mode, Claude, Cursor, ...) controlled access to your own machine over HTTP/MCP: files, shell, Chrome and the macOS desktop, with an approval gate, trust windows and an audit log.",
5
5
  "type": "module",
6
6
  "bin": {
package/src/approvals.mjs CHANGED
@@ -2,7 +2,8 @@ import fs from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import crypto from 'node:crypto';
4
4
  import { APPROVALS_DIR, ensureDirs } from './config.mjs';
5
- import { sendTelegram } from './telegram.mjs';
5
+ import { sendTelegram, approvalKeyboard } from './telegram.mjs';
6
+ import { saveTrust } from './trust.mjs';
6
7
 
7
8
  const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
8
9
 
@@ -43,6 +44,24 @@ export function decide(id, decision, by = 'cli') {
43
44
  return { ok: true, approval: rec };
44
45
  }
45
46
 
47
+ /**
48
+ * Settle a request and, when asked, open a trust window for the same tool.
49
+ */
50
+ export function settle(id, decision, by = 'cli', { trustSec = 0 } = {}) {
51
+ const res = decide(id, decision, by);
52
+ if (res.ok && trustSec > 0) {
53
+ const tool = res.approval?.tool;
54
+ saveTrust({
55
+ enabled: true,
56
+ until: Date.now() + trustSec * 1000,
57
+ tools: tool ? [tool] : [],
58
+ paths: [],
59
+ createdAt: Date.now(),
60
+ });
61
+ }
62
+ return res;
63
+ }
64
+
46
65
  function redact(args) {
47
66
  const clone = { ...args };
48
67
  for (const k of Object.keys(clone)) {
@@ -82,7 +101,8 @@ export async function requestApproval(tool, args, cfg) {
82
101
  text:
83
102
  `Tool: ${tool}\n` +
84
103
  `id: ${id}\n` +
85
- `Reply /approve ${id} or /deny ${id} in Telegram, or run: arena-bridge approve ${id}`,
104
+ `Use the buttons below, reply /approve ${id} or /deny ${id}, or run: arena-bridge approve ${id}`,
105
+ buttons: approvalKeyboard(id),
86
106
  }).catch(() => {});
87
107
  }
88
108
 
package/src/cli.mjs CHANGED
@@ -359,6 +359,19 @@ function cmdTrust(args) {
359
359
  return;
360
360
  }
361
361
  if (args.flags.status || (!args.flags.for && !args.flags.tools && !args.flags.paths)) {
362
+ if (args.flags.json) {
363
+ const remaining = state && state.active ? Math.max(0, Math.round((state.until - Date.now()) / 1000)) : 0;
364
+ console.log(
365
+ JSON.stringify({
366
+ enabled: !!(state && state.active),
367
+ until: state && state.active ? state.until : null,
368
+ remaining_sec: remaining,
369
+ tools: state ? state.tools : [],
370
+ paths: state ? state.paths : [],
371
+ }),
372
+ );
373
+ return;
374
+ }
362
375
  if (!state || !state.active) {
363
376
  console.log('no active trust window (every side-effecting action needs approval)');
364
377
  } else {
package/src/direct.mjs CHANGED
@@ -3,11 +3,11 @@ import fsp from 'node:fs/promises';
3
3
  import { sendJson, sendText, sendRaw, readJson, readBody, isAuthorized, bearer, safeEqual } from './http.mjs';
4
4
  import { handleMcp } from './mcp.mjs';
5
5
  import { buildPrompt } from './prompt.mjs';
6
- import { listApprovals, decide } from './approvals.mjs';
6
+ import { listApprovals, decide, settle } from './approvals.mjs';
7
7
  import { checkPath } from './policy.mjs';
8
8
  import { loadConfig } from './config.mjs';
9
9
  import { tailAudit, readArtifact, auditDir } from './audit.mjs';
10
- import { startPoller, parseCommand, sendTelegram } from './telegram.mjs';
10
+ import { startPoller, parseCommand, sendTelegram, answerCallback, editMessageText } from './telegram.mjs';
11
11
 
12
12
  export function createDirectServer(cfg, engine) {
13
13
  const mcpCtx = {
@@ -37,6 +37,27 @@ export function createDirectServer(cfg, engine) {
37
37
  }
38
38
  };
39
39
 
40
+ const handleTelegramCallback = async (cb) => {
41
+ const current = loadConfig();
42
+ const [action, id] = String(cb.data || '').split(':');
43
+ if (!id || !['approve', 'approve3h', 'deny'].includes(action)) {
44
+ await answerCallback(current, cb.callback_id, 'Unknown action');
45
+ return;
46
+ }
47
+ const trustSec = action === 'approve3h' ? 3 * 3600 : 0;
48
+ const res = settle(id, action === 'deny' ? 'deny' : 'approve', 'telegram', { trustSec });
49
+ const tool = res.approval?.tool || 'the tool';
50
+ const text = !res.ok
51
+ ? `Cannot ${action}: ${res.error || 'unknown error'}`
52
+ : action === 'deny'
53
+ ? `Denied ${id}`
54
+ : trustSec
55
+ ? `Approved ${id} — ${tool} is trusted for the next 3 hours`
56
+ : `Approved ${id}`;
57
+ await answerCallback(current, cb.callback_id, res.ok ? 'Done' : 'Error');
58
+ await editMessageText(current, { chatId: cb.chat_id, messageId: cb.message_id, text });
59
+ };
60
+
40
61
  const server = http.createServer(async (req, res) => {
41
62
  let url;
42
63
  try {
@@ -52,7 +73,7 @@ export function createDirectServer(cfg, engine) {
52
73
  ok: true,
53
74
  mode: 'direct',
54
75
  device: cfg.device,
55
- version: '0.7.4',
76
+ version: '0.7.6',
56
77
  uptime_sec: Math.round(process.uptime()),
57
78
  });
58
79
  }
@@ -188,7 +209,7 @@ export function createDirectServer(cfg, engine) {
188
209
  }
189
210
  });
190
211
 
191
- const poller = startPoller(cfg, { onMessage: handleTelegramMessage });
212
+ const poller = startPoller(cfg, { onMessage: handleTelegramMessage, onCallback: handleTelegramCallback });
192
213
  server.on('close', () => poller?.stop());
193
214
 
194
215
  return server;
package/src/hub.mjs CHANGED
@@ -90,7 +90,7 @@ export function createHubServer(cfg) {
90
90
  return sendJson(res, 200, {
91
91
  ok: true,
92
92
  mode: 'hub',
93
- version: '0.7.4',
93
+ version: '0.7.6',
94
94
  devices: [...devices.keys()],
95
95
  });
96
96
  }
package/src/mac.mjs CHANGED
@@ -218,6 +218,18 @@ export function macTools(cfg = {}) {
218
218
  const denied = isPermissionError(json, res);
219
219
  if (res.exit_code !== 0 && !denied && !UNKNOWN.test(res.stderr || '')) break;
220
220
  }
221
+ // no elements yet: ask for the accessibility tree on its own
222
+ if (!best || !best.elements.length) {
223
+ for (const variant of [
224
+ ['see', '--tree', '--no-screenshot', '--json', ...targetFlags(args)],
225
+ ['see', '--tree', '--no-screenshot', '--no-remote', '--json', ...targetFlags(args)],
226
+ ]) {
227
+ const res = await run(peek(), variant, { timeout: 60 });
228
+ const json = parseJson(res.stdout);
229
+ const elements = collectElements(json);
230
+ if (elements.length) return { res, json, elements, variant };
231
+ }
232
+ }
221
233
  return best;
222
234
  };
223
235
 
package/src/mcp.mjs CHANGED
@@ -48,7 +48,7 @@ export async function handleMcp(req, res, ctx) {
48
48
  mcpResponse(id, {
49
49
  protocolVersion: PROTOCOL_VERSION,
50
50
  capabilities: { tools: { listChanged: false } },
51
- serverInfo: { name: 'arena-bridge', version: '0.7.4' },
51
+ serverInfo: { name: 'arena-bridge', version: '0.7.6' },
52
52
  instructions:
53
53
  'Tools operate the user\'s own machine. Dangerous operations may return ' +
54
54
  'PROTECTED and require a local human approval.',
package/src/telegram.mjs CHANGED
@@ -110,10 +110,23 @@ export function rateOk(limit = RATE_LIMIT, windowMs = RATE_WINDOW_MS) {
110
110
  return true;
111
111
  }
112
112
 
113
+ /**
114
+ * The inline keyboard under an approval message: approve once, approve for 3 hours, deny.
115
+ */
116
+ export function approvalKeyboard(id) {
117
+ return [
118
+ [
119
+ { text: 'Approve once', callback_data: `approve:${id}` },
120
+ { text: 'Approve for 3h', callback_data: `approve3h:${id}` },
121
+ ],
122
+ [{ text: 'Deny', callback_data: `deny:${id}` }],
123
+ ];
124
+ }
125
+
113
126
  /**
114
127
  * Send a text message to one of the allowed chats.
115
128
  */
116
- export async function sendTelegram(cfg, { text, chatId, title, silent } = {}) {
129
+ export async function sendTelegram(cfg, { text, chatId, title, silent, buttons } = {}) {
117
130
  const s = telegramSettings(cfg);
118
131
  if (!s.enabled) {
119
132
  return {
@@ -151,17 +164,16 @@ export async function sendTelegram(cfg, { text, chatId, title, silent } = {}) {
151
164
  };
152
165
  }
153
166
  const text_ = title ? `${title}\n${body}` : body;
154
- let res = await apiCall(cfg, 'sendMessage', {
167
+ const markup = buttons && buttons.length ? { inline_keyboard: buttons } : undefined;
168
+ const payload = {
155
169
  chat_id: target,
156
170
  text: text_,
157
171
  disable_notification: !!silent,
158
- });
172
+ ...(markup ? { reply_markup: markup } : {}),
173
+ };
174
+ let res = await apiCall(cfg, 'sendMessage', payload);
159
175
  if (!res.ok && /parse entities|Markdown/i.test(res.reason || '')) {
160
- res = await apiCall(cfg, 'sendMessage', {
161
- chat_id: target,
162
- text: text_,
163
- disable_notification: !!silent,
164
- });
176
+ res = await apiCall(cfg, 'sendMessage', payload);
165
177
  }
166
178
  if (!res.ok) return res;
167
179
  return {
@@ -172,6 +184,29 @@ export async function sendTelegram(cfg, { text, chatId, title, silent } = {}) {
172
184
  };
173
185
  }
174
186
 
187
+ /**
188
+ * Acknowledge a button press so Telegram stops showing the spinner.
189
+ */
190
+ export async function answerCallback(cfg, callbackId, text = '') {
191
+ if (!callbackId) return { ok: false, error: 'telegram_no_callback' };
192
+ const payload = { callback_query_id: callbackId };
193
+ if (text) payload.text = String(text).slice(0, 200);
194
+ return apiCall(cfg, 'answerCallbackQuery', payload);
195
+ }
196
+
197
+ /**
198
+ * Replace the text of a message and drop its keyboard.
199
+ */
200
+ export async function editMessageText(cfg, { chatId, messageId, text } = {}) {
201
+ if (!chatId || !messageId) return { ok: false, error: 'telegram_no_message' };
202
+ return apiCall(cfg, 'editMessageText', {
203
+ chat_id: chatId,
204
+ message_id: messageId,
205
+ text: String(text ?? ''),
206
+ reply_markup: { inline_keyboard: [] },
207
+ });
208
+ }
209
+
175
210
  /* ------------------------------------------------------------------ *
176
211
  * inbox
177
212
  * ------------------------------------------------------------------ */
@@ -258,7 +293,7 @@ export function parseCommand(text) {
258
293
  * Poll the bot for incoming messages and hand them to the callback.
259
294
  * Returns a handle with stop().
260
295
  */
261
- export function startPoller(cfg, { onMessage } = {}) {
296
+ export function startPoller(cfg, { onMessage, onCallback } = {}) {
262
297
  const s = telegramSettings(cfg);
263
298
  if (!s.enabled || !s.poll) return null;
264
299
  let stopped = false;
@@ -282,7 +317,7 @@ export function startPoller(cfg, { onMessage } = {}) {
282
317
  const res = await apiCall(fresh(), 'getUpdates', {
283
318
  offset,
284
319
  timeout: 20,
285
- allowed_updates: ['message'],
320
+ allowed_updates: ['message', 'callback_query'],
286
321
  });
287
322
  if (!res.ok) {
288
323
  const state = readState();
@@ -301,6 +336,26 @@ export function startPoller(cfg, { onMessage } = {}) {
301
336
  const state = readState();
302
337
  state.offset = offset;
303
338
  writeState(state);
339
+ const cb = upd.callback_query;
340
+ if (cb) {
341
+ const item = {
342
+ id: upd.update_id,
343
+ callback_id: cb.id,
344
+ data: cb.data || '',
345
+ chat_id: cb.message?.chat?.id ?? null,
346
+ message_id: cb.message?.message_id ?? null,
347
+ from: cb.from?.username || cb.from?.first_name || 'unknown',
348
+ date: cb.message?.date || null,
349
+ };
350
+ if (onCallback) {
351
+ try {
352
+ await onCallback(item);
353
+ } catch {
354
+ /* the callback must not kill the loop */
355
+ }
356
+ }
357
+ continue;
358
+ }
304
359
  const msg = upd.message;
305
360
  if (!msg) continue;
306
361
  const item = {