felishub-gateway 1.2.0 → 1.2.1
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/README.md +58 -23
- package/package.json +1 -1
- package/src/agent.js +46 -6
- package/src/config.js +65 -1
- package/src/identity.js +4 -0
- package/src/index.js +10 -3
- package/src/tunnel.js +2 -3
package/README.md
CHANGED
|
@@ -10,9 +10,9 @@
|
|
|
10
10
|
|
|
11
11
|
### Новый агент
|
|
12
12
|
|
|
13
|
-
1. В каталоге `gateway/` агента: `npm i felishub-gateway @anthropic-ai/claude-agent-sdk
|
|
13
|
+
1. В каталоге `gateway/` агента: `npm i felishub-gateway@1.2.1 @anthropic-ai/claude-agent-sdk@<версия> --save-exact`. Нужен Node.js 22.4 или новее.
|
|
14
14
|
2. `config.json` с `agentName` (остальное — по умолчанию, см. ниже).
|
|
15
|
-
3.
|
|
15
|
+
3. Вход в Claude: `ANTHROPIC_API_KEY` (ключ Claude Console) или `CLAUDE_CODE_OAUTH_TOKEN` (токен подписки) в окружении службы.
|
|
16
16
|
4. В FelisHub: «Команда» → «Подключить агента», скопировать команду. Человек выполняет её сам в терминале сервера, в каталоге `gateway/`:
|
|
17
17
|
|
|
18
18
|
```sh
|
|
@@ -25,6 +25,7 @@
|
|
|
25
25
|
### Файл ключа
|
|
26
26
|
|
|
27
27
|
- Путь — `FELISHUB_KEY_FILE`, иначе `~/.config/felishub/<agentName>.json`. Ключ — вне каталога агента: внутри него агент прочитал бы ключ своим `Read` или приложил бы его файлом, поэтому команда и шлюз такой путь не принимают.
|
|
28
|
+
- Каталог ключа — отдельный, только для ключей шлюзов: любое упоминание этого каталога шлюз считает необратимым (см. ниже). Каталог, внутри которого лежит каталог агента, например домашняя папка, команда и шлюз не принимают.
|
|
28
29
|
- В файле — адрес FelisHub, пространство, имя агента и закрытый ключ. Файл пишется с правами `0600`, папка создаётся с `0700`. FelisHub хранит только открытый ключ.
|
|
29
30
|
- Ключ привязан к одному FelisHub и одному пространству. Повторный `connect` с новым кодом того же FelisHub перезаписывает ключ: это замена ключа или переезд в другое пространство. Ключ другого FelisHub команда без `--replace` не перезапишет, а запущенный шлюз на другой FelisHub не переходит до перезапуска.
|
|
30
31
|
- Шлюз перечитывает файл при каждом подключении и проверяет его при каждом пинге FelisHub. Новый ключ того же пространства вступает в силу, когда в FelisHub нажмут «Заменить ключ». Ключ другого пространства — сразу: шлюз переподключается туда, а разговоры и расписания прежнего пространства откладывает (см. «Безопасность»).
|
|
@@ -41,7 +42,7 @@
|
|
|
41
42
|
|
|
42
43
|
Если ключ лежит в другой папке (`FELISHUB_KEY_FILE`), запретите её так же.
|
|
43
44
|
|
|
44
|
-
`connect` не запускается из хода агента: внутри хода в окружении есть `OPS_AGENT_CONVERSATION`, и команда отказывает. Любой вызов
|
|
45
|
+
`connect` не запускается из хода агента: внутри хода в окружении есть `OPS_AGENT_CONVERSATION`, и команда отказывает. Любой вызов Bash, Read, правки файла, Glob, Grep, WebFetch или MCP-инструмента агента, где упомянуты `felishub-gateway`, `felishub-key`, `cli.js … connect`, `.config` и `felishub` в одной команде, маска после `.config` или сразу под домашней папкой (`~/.c*`, `$HOME/.*`, `/home/<пользователь>/.c*`), «папка/файл» ключа, каталог ключа — полным путём или через `~`, `$HOME`, `${HOME}`, — или маска, под которую попадает файл ключа или его каталог (`/srv/k*/key.json`, `/srv/*`), шлюз всегда отдаёт людям как необратимое решение, что бы ни было в `irreversiblePatterns` и в правилах `allow`. `//` и `/./` в пути эту проверку не обходят.
|
|
45
46
|
|
|
46
47
|
### Связь с FelisHub
|
|
47
48
|
|
|
@@ -64,29 +65,28 @@
|
|
|
64
65
|
|
|
65
66
|
```ini
|
|
66
67
|
[Unit]
|
|
67
|
-
Description=
|
|
68
|
+
Description=FelisHub agent gateway (Agent SDK, outbound tunnel)
|
|
68
69
|
After=network-online.target
|
|
69
70
|
Wants=network-online.target
|
|
70
71
|
|
|
71
72
|
[Service]
|
|
72
|
-
User=
|
|
73
|
-
WorkingDirectory=/opt/
|
|
74
|
-
EnvironmentFile=/home/
|
|
75
|
-
ExecStart=/usr/bin/node /opt/
|
|
73
|
+
User=agent
|
|
74
|
+
WorkingDirectory=/opt/my-agent/gateway
|
|
75
|
+
EnvironmentFile=/home/agent/.config/felishub/my-agent.env
|
|
76
|
+
ExecStart=/usr/bin/node /opt/my-agent/gateway/node_modules/felishub-gateway/src/cli.js
|
|
76
77
|
Restart=on-failure
|
|
77
78
|
|
|
78
79
|
[Install]
|
|
79
80
|
WantedBy=multi-user.target
|
|
80
81
|
```
|
|
81
82
|
|
|
82
|
-
В `
|
|
83
|
+
В `my-agent.env` — вход в Claude (`ANTHROPIC_API_KEY` или `CLAUDE_CODE_OAUTH_TOKEN`) и `FELISHUB_KEY_FILE`, если ключ шлюза лежит не в `~/.config/felishub/<agentName>.json`. Файл должен быть доступен только пользователю службы (`chmod 600`). Агент с профилем запускается своим `src/index.js` вместо `cli.js`.
|
|
83
84
|
|
|
84
85
|
### Профиль агента
|
|
85
86
|
|
|
86
87
|
В каталоге `gateway/` агента:
|
|
87
88
|
|
|
88
|
-
- `
|
|
89
|
-
- `package.json` — зависимости `felishub-gateway` (tgz из `vendor/`) и `@anthropic-ai/claude-agent-sdk`. Версию SDK агент закрепляет сам: вместе с SDK приезжает CLI Claude Code, и агенты обновляют его независимо.
|
|
89
|
+
- `package.json` и `package-lock.json` — зависимости `felishub-gateway` и `@anthropic-ai/claude-agent-sdk` из npm с точными версиями (`--save-exact`), их ставит `npm ci`. Версию SDK агент закрепляет сам: вместе с SDK приезжает CLI Claude Code, и агенты обновляют его независимо.
|
|
90
90
|
- `src/index.js` — профиль агента, вызов `startGateway(...)`.
|
|
91
91
|
- `config.json`, `state.json`, `schedules.json`. Шлюз ищет `config.json` в текущем каталоге или по пути из `OPS_GATEWAY_CONFIG`. Запуск: `node src/index.js` из `gateway/` агента.
|
|
92
92
|
|
|
@@ -108,7 +108,7 @@ startGateway({
|
|
|
108
108
|
|
|
109
109
|
- `defaults` — умолчания конфигурации этого агента поверх общих из `src/config.js`. Всё, что задано в `config.json`, их перекрывает.
|
|
110
110
|
- `instructions` — строки системного промпта после общих правил шлюза.
|
|
111
|
-
- `turnOptions` — вызывается перед каждым ходом. Получает `conversation` (`id`, `kind`, `title`, `model`, `budgetUsd`, `maxTurns`, у разговора коллеги ещё `peer`), `actor` (`id`, `name`, `role`), текст хода и модель. Возвращает переменные окружения сессии (`env`) и хуки Agent SDK (`hooks`).
|
|
111
|
+
- `turnOptions` — вызывается перед каждым ходом. Получает `conversation` (`id`, `kind`, `title`, `model`, `budgetUsd`, `maxTurns`, у разговора коллеги ещё `peer`), `actor` (`id`, `name`, `role`), текст хода и модель. Возвращает переменные окружения сессии (`env`) и хуки Agent SDK (`hooks`). Хуки `PreToolUse` профиля идут после хука шлюза, см. «Разрешения».
|
|
112
112
|
|
|
113
113
|
В окружении сессии агента есть `OPS_AGENT_CONVERSATION` (id разговора), `OPS_AGENT_USER` (имя человека или агента, начавшего ход) и `OPS_AGENT_ROLE` (его роль). `FELISHUB_KEY_FILE` в окружение сессии не попадает: агент не узнаёт, где лежит ключ.
|
|
114
114
|
|
|
@@ -140,7 +140,7 @@ startGateway({
|
|
|
140
140
|
| `agentName` | — | Обязателен. Имя агента для коллег и FelisHub: `a-z`, `0-9`, `_`, `-`, до 32 символов |
|
|
141
141
|
| `agentDir` | `..` | Каталог агента относительно `config.json`, там же `logs/events.jsonl` |
|
|
142
142
|
| `timezone` | `Europe/Moscow` | Часовой пояс расписаний, дневных бюджетов и времени в заголовке хода |
|
|
143
|
-
| `model` | `null` | Модель по умолчанию; `null` —
|
|
143
|
+
| `model` | `null` | Модель по умолчанию; `null` — модель Claude Code по умолчанию |
|
|
144
144
|
| `maxConcurrentTurns` | 1 | Сколько разговоров агент ведёт одновременно |
|
|
145
145
|
| `maxTurns` | 80 | Лимит шагов одного хода |
|
|
146
146
|
| `turnTimeoutSec` | 1800 | Ход дольше этого останавливается, исход `timeout` |
|
|
@@ -148,7 +148,7 @@ startGateway({
|
|
|
148
148
|
| `questionTimeoutSec` | 1800 | Сколько вопрос ждёт ответа |
|
|
149
149
|
| `sessionIdleResetMin` | 180 | Разговор без ходов дольше этого начинает новую сессию; 0 выключает |
|
|
150
150
|
| `dailyBudgetUsd` | `null` | Дневной бюджет разговора по умолчанию; `null` или 0 — без лимита |
|
|
151
|
-
| `irreversiblePatterns` | `[]` | Регулярные выражения. Для Bash проверяется команда, для остальных инструментов — `<инструмент> <JSON ввода>`. Совпало — решение уровня `irreversible`. Ключ шлюза и `connect` — `irreversible` всегда |
|
|
151
|
+
| `irreversiblePatterns` | `[]` | Регулярные выражения. Для Bash проверяется команда, для правок файлов (`Write`, `Edit`, `MultiEdit`, `NotebookEdit`) — только путь, `<инструмент> {"file_path":…}`, для остальных инструментов — `<инструмент> <JSON ввода>`. Проверка идёт перед каждым вызовом Bash, Read, правки файла, Glob, Grep, WebFetch и MCP-инструментов, в том числе разрешённым правилом `allow` или хуком, см. «Разрешения». Совпало — решение уровня `irreversible`. Ключ шлюза и `connect` — `irreversible` всегда |
|
|
152
152
|
| `mailMaxHops` | 4 | Сколько раз подряд агенты пересылают друг другу запросы одной цепочки |
|
|
153
153
|
| `mailDailyLimit` | 30 | Запросов одному коллеге в сутки и столько же входящих от него |
|
|
154
154
|
|
|
@@ -156,9 +156,9 @@ startGateway({
|
|
|
156
156
|
|
|
157
157
|
Окружение:
|
|
158
158
|
|
|
159
|
-
- `FELISHUB_KEY_FILE` — путь к файлу ключа вне каталога
|
|
159
|
+
- `FELISHUB_KEY_FILE` — путь к файлу ключа вне каталога агента, в отдельном каталоге только для ключей; по умолчанию `~/.config/felishub/<agentName>.json`.
|
|
160
160
|
- `OPS_GATEWAY_CONFIG` — путь к `config.json`, если он не в текущем каталоге.
|
|
161
|
-
-
|
|
161
|
+
- Вход в Claude: `ANTHROPIC_API_KEY` (ключ Claude Console) или `CLAUDE_CODE_OAUTH_TOKEN` (токен подписки). Шлюз передаёт сессиям своё окружение, поэтому после смены ключа шлюз перезапускают.
|
|
162
162
|
|
|
163
163
|
## Переход с 0.4
|
|
164
164
|
|
|
@@ -207,6 +207,19 @@ startGateway({
|
|
|
207
207
|
|
|
208
208
|
FelisHub не подключает шлюз ниже 1.2.0. Журнал событий, разговоры и расписания переходят как есть; события до подключения FelisHub не загружает.
|
|
209
209
|
|
|
210
|
+
## Переход с 1.2
|
|
211
|
+
|
|
212
|
+
Шлюз 1.2.1 ставится вместо 1.2.0 без изменений в `config.json` и окружении, если ключ шлюза лежит в отдельном каталоге (см. «Файл ключа»). Что меняется для агентов:
|
|
213
|
+
|
|
214
|
+
- Правила `allow` из `.claude/settings.json` агента начинают работать, см. «Разрешения». В 1.2.0 Claude Code молча их отбрасывал. Перед выкаткой перечитайте список: всё, что в нём есть, пойдёт без карточки.
|
|
215
|
+
- Если правило `allow` держится на хуке агента, который сам решает «разрешить», «спросить» или «запретить», проверьте, что хук не падает: при сбое с кодом, отличным от 2, и при выходе по таймауту вызов пройдёт без карточки. Раньше в этом случае приходила карточка.
|
|
216
|
+
- «Необратимое» проверяется перед каждым вызовом Bash, Read, Write, Edit, MultiEdit, NotebookEdit, Glob, Grep, WebFetch и MCP-инструментов. Разрешение из хука и чтение в папке агента, которое Claude Code разрешает сам, больше его не обходят. Новые карточки — у команд и чтений, которые совпали с `irreversiblePatterns` или упоминают `felishub-gateway`, например путь в `gateway/node_modules`. Agent, Task*, Skill, AskUserQuestion и другие внутренние инструменты Claude Code хук не проверяет, как и в 1.2.0: каждый вызов, который делает субагент, проходит ту же проверку.
|
|
217
|
+
- Для правок файлов `irreversiblePatterns` сравнивается только с путём: запись в журнал, где упомянуто что-то из списка, больше не становится карточкой «Необратимое».
|
|
218
|
+
- Правила `deny` и `ask` шлюз передаёт Claude Code вместе с `allow`: если Claude Code не примет `settings.json` целиком, запреты всё равно действуют, а вот хуки из файла — нет, см. «Разрешения».
|
|
219
|
+
- Защита ключа шлюза шире: маски, `//` и `/./` в пути, `.config` и `felishub` в одной команде, путь и каталог из `FELISHUB_KEY_FILE`. Держите ключ в отдельном каталоге: всё, что упоминает этот каталог, теперь приходит карточкой `irreversible`. Если каталог ключа содержит каталог агента, шлюз не запускается и просит перенести ключ.
|
|
220
|
+
- Подсказка «шлюз не подключён» называет папку, где выполнить `connect`, а без `config.json` команда и шлюз объясняют, где их запускать, вместо ошибки `ENOENT`.
|
|
221
|
+
- Если шлюз не смог войти в Claude, подсказка в разговоре просит проверить ключ в окружении шлюза и перезапустить шлюз.
|
|
222
|
+
|
|
210
223
|
## Разговоры и акторы
|
|
211
224
|
|
|
212
225
|
Разговор — отдельная сессия Agent SDK со своей очередью, бюджетом и журналом. Id: `^[a-z0-9][a-z0-9_-]{0,63}$`.
|
|
@@ -229,7 +242,7 @@ FelisHub шлёт команды по соединению шлюза кадра
|
|
|
229
242
|
|
|
230
243
|
```json
|
|
231
244
|
{
|
|
232
|
-
"agent": "devops", "version": "1.2.
|
|
245
|
+
"agent": "devops", "version": "1.2.1", "startedAt": "…", "timezone": "Europe/Moscow", "model": null, "observedModel": "claude-opus-4-1",
|
|
233
246
|
"paused": false, "pausedBy": null,
|
|
234
247
|
"maxConcurrentTurns": 1, "dailyBudgetUsd": 5, "spendTodayUsd": 0.42,
|
|
235
248
|
"permissionTimeoutSec": 900, "questionTimeoutSec": 1800,
|
|
@@ -380,6 +393,28 @@ FelisHub шлёт команды по соединению шлюза кадра
|
|
|
380
393
|
3. Итоговый текст хода B (или отказ, или описание сбоя), не длиннее 20000 символов, уходит A тем же путём как `{ type: "answer", re, replyConversation, hops }`.
|
|
381
394
|
4. Шлюз A принимает один ответ на свой запрос `re` и только от того агента, которому его отправил, остальные отбрасывает. Ответ запускает ход в разговоре, откуда ушёл запрос.
|
|
382
395
|
|
|
396
|
+
## Разрешения
|
|
397
|
+
|
|
398
|
+
Шлюз запускает Claude Code в папке агента с `settingSources: ["project"]` и `permissionMode: "default"`. Из `.claude/settings.json` агента работают правила `deny` и `ask`, хуки и MCP-серверы из `.mcp.json` (при `enableAllProjectMcpServers`). `settings.local.json` и настройки из `~/.claude` шлюз не подключает.
|
|
399
|
+
|
|
400
|
+
Правила `allow` из настроек проекта Claude Code применяет только в папке, которой доверяют, а шлюз доверие не ставит. Поэтому перед каждым ходом шлюз сам читает `permissions.allow`, `permissions.deny` и `permissions.ask` из `<agentDir>/.claude/settings.json` и передаёт строки из них в Agent SDK одним набором, как настройки запуска (`settings`). Правила совпадают так же, как в Claude Code: `./`, `/`, `~`, имена MCP-инструментов; неправильное правило ничего не разрешает и не задевает соседние. Правка файла действует со следующего хода, без перезапуска. Если файл не читается как JSON, ход заканчивается ошибкой «.claude/settings.json агента не читается: …» с подсказкой исправить файл; сессия разговора при этом не сбрасывается.
|
|
401
|
+
|
|
402
|
+
Если файл читается как JSON, но Claude Code его не принимает — например, `permissions.defaultMode` с неизвестным режимом или `model` не строкой, — Claude Code пропускает весь файл: хуки, `enableAllProjectMcpServers` и остальные настройки из него не действуют. Правила `allow`, `deny` и `ask` шлюз при этом всё равно применяет, а хуки агента молча выключены. После правки `settings.json` проверьте, что хуки агента срабатывают: попросите агента о действии, которое хук должен запретить, и убедитесь, что пришёл отказ хука.
|
|
403
|
+
|
|
404
|
+
Как решается вызов:
|
|
405
|
+
|
|
406
|
+
1. `deny` из правила или хука — отказ без карточки.
|
|
407
|
+
2. Первым в `PreToolUse` идёт хук шлюза. Он проверяет Bash, Read, Write, Edit, MultiEdit, NotebookEdit, Glob, Grep, WebFetch и MCP-инструменты, кроме `mcp__felishub__*` и `mcp__agents__ask`. Если вызов совпал с `irreversiblePatterns` или с защитой ключа шлюза, хук отвечает «спросить», и приходит карточка `irreversible` — даже когда вызов разрешает правило `allow`, хук профиля или агента или сам Claude Code (чтение внутри папки агента). Agent, Task*, Skill, AskUserQuestion и другие внутренние инструменты Claude Code хук пропускает: каждый вызов, который делает субагент, проходит этот же хук.
|
|
408
|
+
3. «Спросить» из хука агента или правила `ask` важнее `allow` — карточка `normal`.
|
|
409
|
+
4. `allow` из правила или хука и чтение внутри папки агента, которое Claude Code разрешает сам, — вызов идёт без карточки.
|
|
410
|
+
5. Всё остальное — карточка `normal`.
|
|
411
|
+
|
|
412
|
+
Правило `allow` не добавляет агенту инструментов, которые Claude Code по умолчанию прячет: `Glob(./**)` и `Grep(./**)` не включают Glob и Grep, агент ищет через `find` и `grep` в Bash.
|
|
413
|
+
|
|
414
|
+
Запись инструментом Write Claude Code сверяет с правилами `Edit(…)`. Поэтому запрет записи пишите как `Edit(путь)`, `Write(путь)` — по желанию рядом. Один `deny Write(…)` не перекрывает `allow Edit(…)`: Write пройдёт по разрешению без карточки.
|
|
415
|
+
|
|
416
|
+
Хук агента, который упал с кодом, отличным от 2, или не уложился в свой `timeout`, Claude Code считает промолчавшим: если вызов разрешён правилом `allow`, он пройдёт без карточки. Не разрешайте через `allow` то, что держится только на хуке, и не разрешайте команды, которые читают произвольные файлы (`cat`, `git diff --no-index`): защита ключа ловит путь по тексту команды, а не любой способ до него добраться.
|
|
417
|
+
|
|
383
418
|
## Безопасность
|
|
384
419
|
|
|
385
420
|
- Шлюз только подключается к FelisHub и ничего не слушает. `wss://` обязателен; `ws://` — только для `localhost`, `127.0.0.1` и `[::1]`.
|
|
@@ -390,16 +425,16 @@ FelisHub шлёт команды по соединению шлюза кадра
|
|
|
390
425
|
- Сервер агента — граница доверия: кто вошёл на него пользователем шлюза, может всё, что может шлюз. Держите ключ вне каталога агента и запретите агенту читать его (строки выше), а `connect` выполняйте только сами.
|
|
391
426
|
- Файлы: человек кладёт файл только в `inbox/<разговор>/` каталога агента, агент отдаёт только файлы из каталога агента вне каталога шлюза. Ссылки раскрываются до проверки, временный файл загрузки создаётся без перехода по ссылкам.
|
|
392
427
|
- Всё, что пришло из инструментов `felishub`, — слова агента: приложение помечает их так и никогда не показывает вместо команды.
|
|
428
|
+
- Команды агента видят окружение шлюза, в том числе `ANTHROPIC_API_KEY` и `CLAUDE_CODE_OAUTH_TOKEN`, а их вывод уходит в FelisHub. Шлюз такие команды сам не ловит: запретите их в `.claude/settings.json` агента (`deny`: `Bash(env:*)`, `Bash(printenv:*)`), а остальные способы (`set`, `export -p`, `ps e`, `/proc/*/environ`) добавьте в `irreversiblePatterns` или хуки агента.
|
|
393
429
|
|
|
394
430
|
## Выпуск версии
|
|
395
431
|
|
|
396
|
-
1. Правишь код здесь, поднимаешь `version` в `package.json`.
|
|
397
|
-
2.
|
|
398
|
-
3.
|
|
399
|
-
|
|
400
|
-
Агент, которого не указали в `release.sh`, остаётся на своей версии, пока его не обновят.
|
|
432
|
+
1. Правишь код здесь, поднимаешь `version` в `package.json`, прогоняешь `npm test`.
|
|
433
|
+
2. Агент со шлюзом из npm: после публикации версии впиши её в `gateway/package.json` агента и выполни `npm install` в `gateway/`.
|
|
434
|
+
3. Агент со шлюзом из архива в `vendor/`: `bin/release.sh <каталог gateway агента>…` прогоняет тесты, собирает tgz, убирает из каждого агента старый `agent-gateway` (зависимость, `node_modules/agent-gateway`, `vendor/agent-gateway-*.tgz`) и прежние `vendor/felishub-gateway-*.tgz`, сохраняет заменяемые файлы в резервную копию в `_dev/backups/` рядом с каталогом шлюза, кладёт новый tgz в `vendor/` и ставит его — `package.json` и `package-lock.json` агента меняются только в этой зависимости.
|
|
435
|
+
4. В репозитории агента коммитишь `gateway/package.json`, `gateway/package-lock.json` и `gateway/vendor/`, если он есть, на сервере агента — `npm ci` в `gateway/` и перезапуск шлюза.
|
|
401
436
|
|
|
402
|
-
|
|
437
|
+
Агент, которого не обновили, остаётся на своей версии.
|
|
403
438
|
|
|
404
439
|
## Тесты
|
|
405
440
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "felishub-gateway",
|
|
3
|
-
"version": "1.2.
|
|
3
|
+
"version": "1.2.1",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Шлюз агента FelisHub: сессии Agent SDK по разговорам, решения и вопросы, расписания, бюджеты — сам подключается к FelisHub исходящим соединением",
|
|
6
6
|
"license": "MIT",
|
package/src/agent.js
CHANGED
|
@@ -1,9 +1,14 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
1
3
|
import { createAgentsServer } from "./mail.js";
|
|
2
4
|
import { createFelishubServer, FELISHUB_TOOLS } from "./felishub.js";
|
|
3
5
|
import { truncate, tail, cleanTitle } from "./format.js";
|
|
6
|
+
import { isIrreversible } from "./config.js";
|
|
4
7
|
|
|
5
8
|
const GATEWAY_ONLY_ENV = /^FELISHUB_KEY_FILE$/;
|
|
6
9
|
const TOOL_OUTPUT_LIMIT = 2000;
|
|
10
|
+
const SYSTEM_TOOLS = ["Bash", "Read", "Write", "Edit", "MultiEdit", "NotebookEdit", "Glob", "Grep", "WebFetch"];
|
|
11
|
+
const PATH_RULE_TOOLS = ["Read", "Write", "Edit", "MultiEdit", "NotebookEdit", "Glob", "Grep"];
|
|
7
12
|
|
|
8
13
|
let sdkQuery = null;
|
|
9
14
|
|
|
@@ -39,6 +44,39 @@ export function buildAppend(conversation, instructions = [], peers = {}, answers
|
|
|
39
44
|
].filter(Boolean).join("\n");
|
|
40
45
|
}
|
|
41
46
|
|
|
47
|
+
function fromAgentRoot(rule) {
|
|
48
|
+
const match = rule.match(/^(\w+)\(\/([^/].*)\)$/);
|
|
49
|
+
if (!match || !PATH_RULE_TOOLS.includes(match[1])) return rule;
|
|
50
|
+
return `${match[1]}(./${match[2]})`;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function agentRules(list) {
|
|
54
|
+
if (!Array.isArray(list)) return [];
|
|
55
|
+
return list.filter((rule) => typeof rule === "string").map(fromAgentRoot);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function projectPermissions(agentDir) {
|
|
59
|
+
const file = path.join(agentDir, ".claude", "settings.json");
|
|
60
|
+
if (!fs.existsSync(file)) return null;
|
|
61
|
+
let settings;
|
|
62
|
+
try {
|
|
63
|
+
settings = JSON.parse(fs.readFileSync(file, "utf8"));
|
|
64
|
+
} catch (err) {
|
|
65
|
+
throw Object.assign(new Error(`.claude/settings.json агента не читается: ${err.message}`), { code: "AGENT_SETTINGS" });
|
|
66
|
+
}
|
|
67
|
+
const permissions = settings?.permissions;
|
|
68
|
+
return { allow: agentRules(permissions?.allow), deny: agentRules(permissions?.deny), ask: agentRules(permissions?.ask) };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function irreversibleHook(cfg, ownTools) {
|
|
72
|
+
return async (input) => {
|
|
73
|
+
if (ownTools.includes(input.tool_name)) return {};
|
|
74
|
+
if (!SYSTEM_TOOLS.includes(input.tool_name) && !input.tool_name.startsWith("mcp__")) return {};
|
|
75
|
+
if (!isIrreversible(cfg, input.tool_name, input.tool_input)) return {};
|
|
76
|
+
return { hookSpecificOutput: { hookEventName: "PreToolUse", permissionDecision: "ask" } };
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
42
80
|
function toolResultText(content) {
|
|
43
81
|
if (typeof content === "string") return content;
|
|
44
82
|
if (!Array.isArray(content)) return "";
|
|
@@ -93,21 +131,23 @@ export async function runTurn({ cfg, conversation, actor, peers = {}, sessionId,
|
|
|
93
131
|
return decision;
|
|
94
132
|
},
|
|
95
133
|
};
|
|
96
|
-
|
|
134
|
+
const permissions = projectPermissions(cfg.agentDirAbs);
|
|
97
135
|
const mcpServers = {};
|
|
98
|
-
const
|
|
136
|
+
const ownTools = [];
|
|
99
137
|
if (handlers.felishub) {
|
|
100
138
|
mcpServers.felishub = await createFelishubServer(handlers.felishub);
|
|
101
|
-
|
|
139
|
+
ownTools.push(...FELISHUB_TOOLS);
|
|
102
140
|
}
|
|
103
141
|
if (handlers.onAsk) {
|
|
104
142
|
mcpServers.agents = await createAgentsServer(peers, handlers.onAsk);
|
|
105
|
-
|
|
143
|
+
ownTools.push("mcp__agents__ask");
|
|
106
144
|
}
|
|
107
|
-
if (
|
|
145
|
+
if (ownTools.length) {
|
|
108
146
|
options.mcpServers = mcpServers;
|
|
109
|
-
options.allowedTools =
|
|
147
|
+
options.allowedTools = ownTools;
|
|
110
148
|
}
|
|
149
|
+
if (permissions) options.settings = { permissions };
|
|
150
|
+
options.hooks = { ...agentOptions.hooks, PreToolUse: [{ hooks: [irreversibleHook(cfg, ownTools)] }, ...(agentOptions.hooks?.PreToolUse || [])] };
|
|
111
151
|
if (sessionId) options.resume = sessionId;
|
|
112
152
|
if (model) options.model = model;
|
|
113
153
|
|
package/src/config.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
|
+
import os from "node:os";
|
|
2
3
|
import path from "node:path";
|
|
3
4
|
|
|
4
5
|
export const DEFAULTS = {
|
|
@@ -21,7 +22,10 @@ export const DEFAULTS = {
|
|
|
21
22
|
const REMOVED_KEYS = ["chatId", "ownerId", "topics", "approvals", "ownerCanApprove", "ownerCanWriteAnywhere", "showToolCalls", "modelShortcuts", "ownerOnlyPatterns"];
|
|
22
23
|
const AGENT_NAME_RE = /^[a-z0-9_-]{1,32}$/;
|
|
23
24
|
const OLD_MODE_ENV = /^(GATEWAY_API_TOKEN|PEER_TOKEN_.+)$/;
|
|
24
|
-
const GATEWAY_GUARD = /felishub-gateway|felishub-key
|
|
25
|
+
const GATEWAY_GUARD = /felishub-gateway|felishub-key|cli\.js\S*\s+connect|\.config\b[\s\S]*felishub|\.config\/+(\.\/+)*[^\s\/]*[*?[]|(~|\$\{?HOME\b\}?|\/(home|Users)\/[^\s\/]+)\/+\.[^\s\/]*[*?[]/;
|
|
26
|
+
const FILE_EDIT_TOOLS = ["Write", "Edit", "MultiEdit", "NotebookEdit"];
|
|
27
|
+
const SHELL_SEPARATORS = /[\s;&|<>()=,:"'`]+/;
|
|
28
|
+
const HOME_PREFIX = /^(~|\$HOME|\$\{HOME\})(?=\/)/;
|
|
25
29
|
|
|
26
30
|
export const CONVERSATION_ID_RE = /^[a-z0-9][a-z0-9_-]{0,63}$/;
|
|
27
31
|
export const SYSTEM_CONVERSATION = "system";
|
|
@@ -64,6 +68,9 @@ export function validateConfig(raw, defaults = {}) {
|
|
|
64
68
|
}
|
|
65
69
|
|
|
66
70
|
export function loadConfig(configPath, defaults = {}) {
|
|
71
|
+
if (!fs.existsSync(configPath)) {
|
|
72
|
+
throw new Error(`нет ${configPath}: запускайте felishub-gateway в папке шлюза агента, рядом с config.json, или задайте путь к нему в OPS_GATEWAY_CONFIG`);
|
|
73
|
+
}
|
|
67
74
|
const raw = JSON.parse(fs.readFileSync(configPath, "utf8"));
|
|
68
75
|
const outdated = removedKeys(raw);
|
|
69
76
|
if (outdated.length) {
|
|
@@ -152,13 +159,70 @@ export function normalizeShell(command) {
|
|
|
152
159
|
function commandTexts(toolName, input) {
|
|
153
160
|
const command = typeof input?.command === "string" ? input.command : null;
|
|
154
161
|
if (toolName === "Bash") return [String(command ?? ""), normalizeShell(command)];
|
|
162
|
+
if (FILE_EDIT_TOOLS.includes(toolName)) return [toolName + " " + JSON.stringify({ file_path: input?.file_path ?? input?.notebook_path })];
|
|
155
163
|
const raw = toolName + " " + JSON.stringify(input ?? {});
|
|
156
164
|
if (command === null) return [raw];
|
|
157
165
|
return [raw, toolName + " " + JSON.stringify({ ...input, command: normalizeShell(command) })];
|
|
158
166
|
}
|
|
159
167
|
|
|
168
|
+
function keyDirForms(keyDir) {
|
|
169
|
+
const home = os.homedir();
|
|
170
|
+
if (!keyDir.startsWith(home + path.sep)) return [keyDir];
|
|
171
|
+
const inHome = keyDir.slice(home.length);
|
|
172
|
+
return [keyDir, "~" + inHome, "$HOME" + inHome, "${HOME}" + inHome];
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function nameMatches(glob, name) {
|
|
176
|
+
let g = 0;
|
|
177
|
+
let n = 0;
|
|
178
|
+
let star = -1;
|
|
179
|
+
let starName = 0;
|
|
180
|
+
while (n < name.length) {
|
|
181
|
+
if (glob[g] === "?" || (glob[g] !== "*" && glob[g] === name[n])) {
|
|
182
|
+
g += 1;
|
|
183
|
+
n += 1;
|
|
184
|
+
} else if (glob[g] === "*") {
|
|
185
|
+
star = g;
|
|
186
|
+
starName = n;
|
|
187
|
+
g += 1;
|
|
188
|
+
} else if (star !== -1) {
|
|
189
|
+
g = star + 1;
|
|
190
|
+
starName += 1;
|
|
191
|
+
n = starName;
|
|
192
|
+
} else {
|
|
193
|
+
return false;
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
while (glob[g] === "*") g += 1;
|
|
197
|
+
return g === glob.length;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function globMatches(glob, target) {
|
|
201
|
+
const globParts = glob.replace(/\[[^\]]*\]/g, "?").split("/");
|
|
202
|
+
const targetParts = target.split("/");
|
|
203
|
+
if (globParts.length !== targetParts.length) return false;
|
|
204
|
+
return globParts.every((part, i) => nameMatches(part, targetParts[i]));
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function mentionsKeyFile(cfg, texts) {
|
|
208
|
+
if (!cfg.keyFile) return false;
|
|
209
|
+
const keyDir = path.dirname(cfg.keyFile);
|
|
210
|
+
const keyName = path.join(path.basename(keyDir), path.basename(cfg.keyFile));
|
|
211
|
+
const dirForms = keyDirForms(keyDir);
|
|
212
|
+
for (const text of texts) {
|
|
213
|
+
const flat = text.replace(/\/(?:\.?\/)+/g, "/");
|
|
214
|
+
if (flat.includes(keyName)) return true;
|
|
215
|
+
const tokens = flat.split(SHELL_SEPARATORS);
|
|
216
|
+
if (dirForms.some((form) => flat.includes(form + "/") || tokens.includes(form))) return true;
|
|
217
|
+
const globs = tokens.filter((token) => /[*?[]/.test(token)).map((token) => token.replace(HOME_PREFIX, os.homedir()));
|
|
218
|
+
if (globs.some((glob) => globMatches(glob, cfg.keyFile) || globMatches(glob, keyDir))) return true;
|
|
219
|
+
}
|
|
220
|
+
return false;
|
|
221
|
+
}
|
|
222
|
+
|
|
160
223
|
export function isIrreversible(cfg, toolName, input) {
|
|
161
224
|
const texts = commandTexts(toolName, input);
|
|
225
|
+
if (mentionsKeyFile(cfg, texts)) return true;
|
|
162
226
|
const patterns = [GATEWAY_GUARD, ...cfg.irreversiblePatterns.map((pattern) => new RegExp(pattern))];
|
|
163
227
|
return patterns.some((pattern) => texts.some((text) => pattern.test(text)));
|
|
164
228
|
}
|
package/src/identity.js
CHANGED
|
@@ -22,6 +22,10 @@ export function keyFilePath(agentName, agentDir) {
|
|
|
22
22
|
if (real === dir || real.startsWith(dir + path.sep)) {
|
|
23
23
|
throw new Error(`ключ шлюза не может лежать в каталоге агента (${file}): задайте FELISHUB_KEY_FILE вне его`);
|
|
24
24
|
}
|
|
25
|
+
const fromKeyDir = path.relative(path.dirname(real), dir);
|
|
26
|
+
if (!fromKeyDir.startsWith("..") && !path.isAbsolute(fromKeyDir)) {
|
|
27
|
+
throw new Error(`ключу шлюза нужен свой каталог, а ${path.dirname(file)} содержит каталог агента: задайте FELISHUB_KEY_FILE в отдельном каталоге, например ~/.config/felishub/`);
|
|
28
|
+
}
|
|
25
29
|
return file;
|
|
26
30
|
}
|
|
27
31
|
|
package/src/index.js
CHANGED
|
@@ -55,6 +55,7 @@ export function startGateway({ defaults = {}, instructions = [], turnOptions } =
|
|
|
55
55
|
console.error(err.message);
|
|
56
56
|
process.exit(1);
|
|
57
57
|
}
|
|
58
|
+
cfg.keyFile = keyFile;
|
|
58
59
|
const state = loadState(statePath);
|
|
59
60
|
const queues = new ConversationQueues(cfg.maxConcurrentTurns);
|
|
60
61
|
const approvals = new Approvals();
|
|
@@ -289,7 +290,7 @@ export function startGateway({ defaults = {}, instructions = [], turnOptions } =
|
|
|
289
290
|
return await attempt(session.sessionId);
|
|
290
291
|
} catch (err) {
|
|
291
292
|
const text = String(err?.message || err);
|
|
292
|
-
if (abortController.signal.aborted || /authenticat|oauth|api key|401/i.test(text) || !/session|resume|conversation/i.test(text)) throw err;
|
|
293
|
+
if (abortController.signal.aborted || err?.code === "AGENT_SETTINGS" || /authenticat|oauth|api key|401/i.test(text) || !/session|resume|conversation/i.test(text)) throw err;
|
|
293
294
|
log(`session ${sessionKey}: cannot resume ${session.sessionId}, starting a new one`);
|
|
294
295
|
clearSession(state, sessionKey);
|
|
295
296
|
persist();
|
|
@@ -315,9 +316,14 @@ export function startGateway({ defaults = {}, instructions = [], turnOptions } =
|
|
|
315
316
|
if (entry.abortController.signal.aborted) return "задача остановлена";
|
|
316
317
|
log(`conversation ${conversationId}: turn ${turnId} failed`, err);
|
|
317
318
|
const text = String(err?.message || err);
|
|
319
|
+
if (err?.code === "AGENT_SETTINGS") {
|
|
320
|
+
const reply = "ошибка шлюза: " + truncate(text, 500);
|
|
321
|
+
events.append(conversationId, "error", { turnId, text: reply + "\n\nТому, кто ведёт сервер агента: исправьте JSON в .claude/settings.json в папке агента и повторите сообщение. Шлюз читает файл перед каждым ходом, перезапуск не нужен." });
|
|
322
|
+
return reply;
|
|
323
|
+
}
|
|
318
324
|
if (/authenticat|oauth|api key|not logged in|401/i.test(text)) {
|
|
319
|
-
events.append(conversationId, "error", { turnId, text: "
|
|
320
|
-
return "
|
|
325
|
+
events.append(conversationId, "error", { turnId, text: "Шлюз не смог войти в Claude. Тому, кто ведёт сервер агента: проверьте ANTHROPIC_API_KEY (ключ Claude Console) или CLAUDE_CODE_OAUTH_TOKEN (токен подписки) в окружении шлюза, перезапустите шлюз и повторите сообщение.\n\n" + truncate(text, 300) });
|
|
326
|
+
return "шлюз не вошёл в Claude: нужен ключ в окружении шлюза";
|
|
321
327
|
}
|
|
322
328
|
if (/usage limit|rate limit|429|overloaded|529|too many requests|quota/i.test(text)) {
|
|
323
329
|
events.append(conversationId, "error", { turnId, text: "Упёрлись в лимит: " + truncate(text, 300) + "\n\nЭто лимит подписки или скорости API, а не ошибка задачи. Подожди, пока окно лимита обновится, или переведи разговор на более дешёвую модель, потом повтори сообщение." });
|
|
@@ -789,6 +795,7 @@ export function startGateway({ defaults = {}, instructions = [], turnOptions } =
|
|
|
789
795
|
|
|
790
796
|
const tunnel = startTunnel({
|
|
791
797
|
keyFile,
|
|
798
|
+
configDir: path.dirname(path.resolve(configPath)),
|
|
792
799
|
agentName: cfg.agentName,
|
|
793
800
|
version: VERSION,
|
|
794
801
|
events,
|
package/src/tunnel.js
CHANGED
|
@@ -23,7 +23,6 @@ const WAITING_RETRY_MS = 5000;
|
|
|
23
23
|
const REFUSED_RETRY_MS = 30000;
|
|
24
24
|
const LIMITED_RETRY_MS = 60000;
|
|
25
25
|
const STATUS_KINDS = new Set(["user", "job", "turn-start", "turn-end", "permission", "permission-result", "question", "question-result", "agent-paused", "agent-resumed", "schedule-changed", "session-reset"]);
|
|
26
|
-
const MISSING_KEY_HINT = "шлюз не подключён к FelisHub: выполните в этой папке команду из окна «Подключить агента» (felishub-gateway connect …)";
|
|
27
26
|
|
|
28
27
|
function binaryFrame(id, payload) {
|
|
29
28
|
const head = Buffer.alloc(4);
|
|
@@ -70,7 +69,7 @@ export function enrol({ hub, code, privateKey, details }) {
|
|
|
70
69
|
});
|
|
71
70
|
}
|
|
72
71
|
|
|
73
|
-
export function startTunnel({ keyFile, agentName, version, events, status, handleCommand, onWorkspace, log }) {
|
|
72
|
+
export function startTunnel({ keyFile, configDir, agentName, version, events, status, handleCommand, onWorkspace, log }) {
|
|
74
73
|
let key = null;
|
|
75
74
|
let keyStat = null;
|
|
76
75
|
let rejectedKeyId = null;
|
|
@@ -99,7 +98,7 @@ export function startTunnel({ keyFile, agentName, version, events, status, handl
|
|
|
99
98
|
const stat = fs.statSync(keyFile);
|
|
100
99
|
return { stat, data: loadKey(keyFile) };
|
|
101
100
|
} catch (err) {
|
|
102
|
-
if (err.code === "ENOENT") hint(
|
|
101
|
+
if (err.code === "ENOENT") hint(`шлюз не подключён к FelisHub: выполните в папке ${configDir} команду из окна «Подключить агента» (felishub-gateway connect …)`);
|
|
103
102
|
else notice(`tunnel: cannot read key file ${keyFile}: ${err.message}`);
|
|
104
103
|
return null;
|
|
105
104
|
}
|