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 CHANGED
@@ -10,9 +10,9 @@
10
10
 
11
11
  ### Новый агент
12
12
 
13
- 1. В каталоге `gateway/` агента: `npm i felishub-gateway @anthropic-ai/claude-agent-sdk zod`. Нужен Node.js 22.4 или новее.
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. Логин Claude: `claude` в терминале этого пользователя или `ANTHROPIC_API_KEY` / `CLAUDE_CODE_OAUTH_TOKEN` в окружении службы.
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`, и команда отказывает. Любой вызов инструмента, где упомянуты `felishub-gateway`, `felishub-key`, `.config/felishub` или `cli.js … connect`, шлюз всегда отдаёт людям как необратимое решение, что бы ни было в `irreversiblePatterns`.
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=SEO agent gateway for FelisHub (Agent SDK, outbound tunnel)
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=seoagent
73
- WorkingDirectory=/opt/seo-agent/gateway
74
- EnvironmentFile=/home/seoagent/.config/seo-agent/gateway.env
75
- ExecStart=/usr/bin/node /opt/seo-agent/gateway/node_modules/felishub-gateway/src/cli.js
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
- В `gateway.env` — `FELISHUB_KEY_FILE=/home/seoagent/.config/seo-agent/felishub-key.json` и логин Claude. Агент с профилем запускается своим `src/index.js` вместо `cli.js`.
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
- - `vendor/felishub-gateway-<версия>.tgz` — выпущенная версия этого пакета, её ставит `npm ci`. Серверам не нужен доступ к репозиторию шлюза.
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` — как у `claude` на этой машине |
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` — путь к файлу ключа вне каталога агента; по умолчанию `~/.config/felishub/<agentName>.json`.
159
+ - `FELISHUB_KEY_FILE` — путь к файлу ключа вне каталога агента, в отдельном каталоге только для ключей; по умолчанию `~/.config/felishub/<agentName>.json`.
160
160
  - `OPS_GATEWAY_CONFIG` — путь к `config.json`, если он не в текущем каталоге.
161
- - Логин Claude, как обычно: `ANTHROPIC_API_KEY` или `CLAUDE_CODE_OAUTH_TOKEN`, либо вход через `claude`.
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.0", "startedAt": "…", "timezone": "Europe/Moscow", "model": null, "observedModel": "claude-opus-4-1",
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. `bin/release.sh ../devops/agent/gateway ../seo/gateway` прогоняет тесты, собирает tgz, убирает из каждого агента старый `agent-gateway` (зависимость, `node_modules/agent-gateway`, `vendor/agent-gateway-*.tgz`) и прежние `vendor/felishub-gateway-*.tgz`, сохраняет заменяемые файлы в `_dev/backups/2026-10-05-v9-GW/removed/release-…/`, кладёт новый tgz в `vendor/` и ставит его — `package.json` и `package-lock.json` агента меняются только в этой зависимости.
398
- 3. В репозиториях агентов коммитишь `gateway/vendor/`, `gateway/package.json` и `gateway/package-lock.json`, на сервере агента — `npm ci` в `gateway/` и перезапуск шлюза.
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
- Пакет готов к публикации в npm как `felishub-gateway`, но помечен `"private": true`: публикует его только владелец, когда решит, — тогда `npx felishub-gateway connect …` заработает у агентов без tgz.
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.0",
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
- if (agentOptions.hooks) options.hooks = agentOptions.hooks;
134
+ const permissions = projectPermissions(cfg.agentDirAbs);
97
135
  const mcpServers = {};
98
- const allowedTools = [];
136
+ const ownTools = [];
99
137
  if (handlers.felishub) {
100
138
  mcpServers.felishub = await createFelishubServer(handlers.felishub);
101
- allowedTools.push(...FELISHUB_TOOLS);
139
+ ownTools.push(...FELISHUB_TOOLS);
102
140
  }
103
141
  if (handlers.onAsk) {
104
142
  mcpServers.agents = await createAgentsServer(peers, handlers.onAsk);
105
- allowedTools.push("mcp__agents__ask");
143
+ ownTools.push("mcp__agents__ask");
106
144
  }
107
- if (allowedTools.length) {
145
+ if (ownTools.length) {
108
146
  options.mcpServers = mcpServers;
109
- options.allowedTools = 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|\.config\/felishub|cli\.js\S*\s+connect/;
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: "Агент не залогинен в Claude на своём сервере. Владелец: выполни в терминале `claude` и пройди логин (или положи ANTHROPIC_API_KEY либо CLAUDE_CODE_OAUTH_TOKEN в gateway.env), затем повтори сообщение — перезапуск шлюза не нужен.\n\n" + truncate(text, 300) });
320
- return "агент не залогинен в Claude, нужен владелец";
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(MISSING_KEY_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
  }