arena-bridge 0.7.3 → 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 +33 -0
- package/README.md +76 -5
- package/package.json +1 -1
- package/src/approvals.mjs +22 -2
- package/src/cli.mjs +13 -0
- package/src/direct.mjs +36 -5
- package/src/http.mjs +4 -2
- package/src/hub.mjs +13 -3
- package/src/mac.mjs +12 -0
- package/src/mcp.mjs +1 -1
- package/src/telegram.mjs +65 -10
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,39 @@ 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
|
+
|
|
32
|
+
## 0.7.4
|
|
33
|
+
|
|
34
|
+
### Fixed
|
|
35
|
+
- Approvals over HTTP work again: `POST /approvals/<id>` now answers with the decision instead
|
|
36
|
+
of an internal error, the local token is accepted by the API, and the agent token is refused —
|
|
37
|
+
an agent can no longer approve its own dangerous action.
|
|
38
|
+
- The same rule now applies to the hub mode.
|
|
39
|
+
|
|
7
40
|
## 0.7.3
|
|
8
41
|
|
|
9
42
|
### 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-
|
|
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
|
-
#
|
|
46
|
-
|
|
55
|
+
npm i -g arena-bridge # только командная строка
|
|
56
|
+
pip install arena-bridge # то же самое на Python
|
|
57
|
+
```
|
|
47
58
|
|
|
48
|
-
|
|
59
|
+
```bash
|
|
60
|
+
# 1. один раз: создать конфиг, проверить машину, поднять API + туннель
|
|
49
61
|
arena-bridge setup --root ~/projects --root ~/Documents
|
|
50
62
|
|
|
51
|
-
#
|
|
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` упадёт, мост поднимет его заново и сам
|
|
@@ -258,6 +275,59 @@ arena-bridge telegram inbox
|
|
|
258
275
|
|
|
259
276
|
---
|
|
260
277
|
|
|
278
|
+
## Нативное приложение для macOS
|
|
279
|
+
|
|
280
|
+
Окно, в котором всё видно и всё управляется: статус, пуск/стоп, подтверждения, журнал.
|
|
281
|
+
|
|
282
|
+
```bash
|
|
283
|
+
git clone https://github.com/vernikr/arena-bridge.git
|
|
284
|
+
cd arena-bridge/macos/ArenaBridge
|
|
285
|
+
./build.sh # → dist/Arena Bridge.app
|
|
286
|
+
cp -R "dist/Arena Bridge.app" /Applications/
|
|
287
|
+
```
|
|
288
|
+
|
|
289
|
+
Нужен только Swift из Xcode Command Line Tools (`xcode-select --install`), Xcode целиком не
|
|
290
|
+
требуется. Собирается под текущую архитектуру: `ARCH=x86_64` для Intel, `ARCH=arm64` для
|
|
291
|
+
Apple Silicon.
|
|
292
|
+
|
|
293
|
+
| Блок в окне | Что делает |
|
|
294
|
+
| --- | --- |
|
|
295
|
+
| Статус | запущен/остановлен, версия, режим, число инструментов, публичный адрес, уведомления, Telegram |
|
|
296
|
+
| Управление | «Запустить» / «Остановить» (служба `ai.arena.bridge`), «Настроить…», «Скопировать промпт агенту» |
|
|
297
|
+
| Доверенное окно | «Открыть на 3 ч» / «Закрыть» и статус «действует ещё 2:59» |
|
|
298
|
+
| Подтверждения | ожидающие запросы агента с кнопками «Разрешить» / «Разрешить на 3 ч» / «Отклонить» |
|
|
299
|
+
| Журнал | команды с аргументами, клик по скриншоту или ax-tree открывает его целиком, «Сохранить…» в файл (с ax-tree) |
|
|
300
|
+
|
|
301
|
+
Когда агенту нужно решение, приложение показывает системное уведомление macOS
|
|
302
|
+
«Approval needed» — в строке меню и в центре уведомлений. Внутри окна запрос тоже виден
|
|
303
|
+
сразу, в карточке «Подтверждения».
|
|
304
|
+
|
|
305
|
+
«Настроить…» собирает конфиг, службу и запуск одной кнопкой: туннель (ngrok/cloudflared),
|
|
306
|
+
зарезервированный домен, authtoken, папки для агента, Telegram (токен бота и chat id).
|
|
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
|
+
|
|
329
|
+
---
|
|
330
|
+
|
|
261
331
|
## Доверенное окно: работать без подтверждений
|
|
262
332
|
|
|
263
333
|
```bash
|
|
@@ -265,6 +335,7 @@ arena-bridge trust --for 2h # всё, на 2
|
|
|
265
335
|
arena-bridge trust --for 30m --tools bash # только команды
|
|
266
336
|
arena-bridge trust --for 1h --paths ~/projects/my-app # только эта папка
|
|
267
337
|
arena-bridge trust --status
|
|
338
|
+
arena-bridge trust --status --json # для приложений и скриптов
|
|
268
339
|
arena-bridge trust --off
|
|
269
340
|
```
|
|
270
341
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "arena-bridge",
|
|
3
|
-
"version": "0.7.
|
|
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
|
-
`
|
|
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.
|
|
76
|
+
version: '0.7.6',
|
|
56
77
|
uptime_sec: Math.round(process.uptime()),
|
|
57
78
|
});
|
|
58
79
|
}
|
|
@@ -118,10 +139,20 @@ export function createDirectServer(cfg, engine) {
|
|
|
118
139
|
|
|
119
140
|
const approveMatch = /^\/approvals\/([\w-]+)$/.exec(p);
|
|
120
141
|
if (req.method === 'POST' && approveMatch) {
|
|
142
|
+
// settling a request is a local-only privilege: the agent token must not self-approve
|
|
143
|
+
const expected = loadConfig().localToken;
|
|
144
|
+
const presented = bearer(req);
|
|
145
|
+
if (!expected || !presented || !safeEqual(presented, expected)) {
|
|
146
|
+
return sendJson(res, 403, {
|
|
147
|
+
ok: false,
|
|
148
|
+
error: 'forbidden',
|
|
149
|
+
reason: 'approvals require the local token — run "arena-bridge approve <id>" on the machine',
|
|
150
|
+
});
|
|
151
|
+
}
|
|
121
152
|
const body = await readJson(req, 64 * 1024);
|
|
122
153
|
const decision = body.decision === 'deny' ? 'deny' : 'approve';
|
|
123
154
|
const res2 = decide(approveMatch[1], decision, 'api');
|
|
124
|
-
return sendJson(res2.ok ? 200 : 400, res2);
|
|
155
|
+
return sendJson(res, res2.ok ? 200 : 400, res2);
|
|
125
156
|
}
|
|
126
157
|
|
|
127
158
|
if (req.method === 'POST' && p === '/exec') {
|
|
@@ -178,7 +209,7 @@ export function createDirectServer(cfg, engine) {
|
|
|
178
209
|
}
|
|
179
210
|
});
|
|
180
211
|
|
|
181
|
-
const poller = startPoller(cfg, { onMessage: handleTelegramMessage });
|
|
212
|
+
const poller = startPoller(cfg, { onMessage: handleTelegramMessage, onCallback: handleTelegramCallback });
|
|
182
213
|
server.on('close', () => poller?.stop());
|
|
183
214
|
|
|
184
215
|
return server;
|
package/src/http.mjs
CHANGED
|
@@ -72,6 +72,8 @@ export function bearer(req) {
|
|
|
72
72
|
|
|
73
73
|
export function isAuthorized(req, cfg) {
|
|
74
74
|
const token = bearer(req);
|
|
75
|
-
if (!token
|
|
76
|
-
|
|
75
|
+
if (!token) return false;
|
|
76
|
+
if (cfg.token && safeEqual(token, cfg.token)) return true;
|
|
77
|
+
// the local token is for the machine owner: approvals and trust
|
|
78
|
+
return !!(cfg.localToken && safeEqual(token, cfg.localToken));
|
|
77
79
|
}
|
package/src/hub.mjs
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import http from 'node:http';
|
|
2
2
|
import crypto from 'node:crypto';
|
|
3
|
-
import { sendJson, sendText, readJson, readBody, isAuthorized, safeEqual } from './http.mjs';
|
|
3
|
+
import { sendJson, sendText, readJson, readBody, isAuthorized, bearer, safeEqual } from './http.mjs';
|
|
4
4
|
import { handleMcp } from './mcp.mjs';
|
|
5
5
|
import { buildPrompt } from './prompt.mjs';
|
|
6
6
|
import { toolCatalog } from './engine.mjs';
|
|
@@ -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.
|
|
93
|
+
version: '0.7.6',
|
|
94
94
|
devices: [...devices.keys()],
|
|
95
95
|
});
|
|
96
96
|
}
|
|
@@ -158,10 +158,20 @@ export function createHubServer(cfg) {
|
|
|
158
158
|
|
|
159
159
|
const approveMatch = /^\/approvals\/([\w-]+)$/.exec(p);
|
|
160
160
|
if (req.method === 'POST' && approveMatch) {
|
|
161
|
+
// settling a request is a local-only privilege: the agent token must not self-approve
|
|
162
|
+
const expected = loadConfig().localToken;
|
|
163
|
+
const presented = bearer(req);
|
|
164
|
+
if (!expected || !presented || !safeEqual(presented, expected)) {
|
|
165
|
+
return sendJson(res, 403, {
|
|
166
|
+
ok: false,
|
|
167
|
+
error: 'forbidden',
|
|
168
|
+
reason: 'approvals require the local token — run "arena-bridge approve <id>" on the machine',
|
|
169
|
+
});
|
|
170
|
+
}
|
|
161
171
|
const body = await readJson(req, 64 * 1024);
|
|
162
172
|
const decision = body.decision === 'deny' ? 'deny' : 'approve';
|
|
163
173
|
const r = decide(approveMatch[1], decision, 'api');
|
|
164
|
-
return sendJson(r.ok ? 200 : 400, r);
|
|
174
|
+
return sendJson(res, r.ok ? 200 : 400, r);
|
|
165
175
|
}
|
|
166
176
|
|
|
167
177
|
if (req.method === 'POST' && p === '/exec') {
|
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.
|
|
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
|
-
|
|
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 = {
|