subscription-gateway 0.1.0 → 0.1.2
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 +2 -0
- package/README.ru.md +235 -0
- package/gateway.mjs +290 -179
- package/jsonschema-to-zod.mjs +37 -42
- package/package.json +3 -2
- package/test-schema-adapter.mjs +47 -10
package/README.md
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# subscription-gateway — run the platform on a subscription instead of per-token billing
|
|
2
2
|
|
|
3
|
+
> Russian version: [README.ru.md](./README.ru.md).
|
|
4
|
+
|
|
3
5
|
A system service: it takes a request from the platform (or from any other host),
|
|
4
6
|
drives the model loop through the vendor's official SDK on **subscription**
|
|
5
7
|
access, and streams the answer back.
|
package/README.ru.md
ADDED
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
# subscription-gateway — работа платформы по подписке вместо поштучной оплаты
|
|
2
|
+
|
|
3
|
+
> Русская редакция. English version: [README.md](./README.md).
|
|
4
|
+
|
|
5
|
+
Системная служба: принимает запрос от платформы (или от любого другого хоста),
|
|
6
|
+
ведёт цикл модели через официальный SDK поставщика на **подписочном** доступе и
|
|
7
|
+
отдаёт ответ потоком.
|
|
8
|
+
|
|
9
|
+
Токен подписки лежит в **одном** месте на машине. Агенты его не знают — они знают
|
|
10
|
+
адрес точки входа.
|
|
11
|
+
|
|
12
|
+
---
|
|
13
|
+
|
|
14
|
+
## Что это даёт
|
|
15
|
+
|
|
16
|
+
- цикл модели идёт по подписке, а не по поштучной оплате;
|
|
17
|
+
- секрет не размножается по машине: один файл, права по группе, смена в одном месте;
|
|
18
|
+
- новый агент подключается строкой настройки, без копирования секрета;
|
|
19
|
+
- инструменты платформы можно подмешать в набор модели через мост (см. `dsh-tool-bridge`).
|
|
20
|
+
|
|
21
|
+
---
|
|
22
|
+
|
|
23
|
+
## Когда это НЕ нужно
|
|
24
|
+
|
|
25
|
+
**Если у вас обычный ключ API — не нужно вовсе.** Платформа сама дойдёт до
|
|
26
|
+
поставщика, а лишний процесс между нею и поставщиком только добавляет мест, где
|
|
27
|
+
что-то может замолчать.
|
|
28
|
+
|
|
29
|
+
Шлюз — для случая, когда доступ **подписочный**, а хост про такой доступ ничего
|
|
30
|
+
не знает.
|
|
31
|
+
|
|
32
|
+
**И второй случай, когда не нужно: если агент один.** Смысл выноса токена в том,
|
|
33
|
+
что агентов несколько. При одном проще держать секрет рядом с ним.
|
|
34
|
+
|
|
35
|
+
---
|
|
36
|
+
|
|
37
|
+
## 🔴 Главное перед установкой: чьими руками работает агент
|
|
38
|
+
|
|
39
|
+
SDK **агентный**. Он сам ведёт цикл и сам запускает оболочку, файлы, поиск и веб.
|
|
40
|
+
|
|
41
|
+
Значит «руки» агента — это пользователь, от которого работает **процесс шлюза**, а
|
|
42
|
+
не пользователь платформы. Всё остальное следует отсюда:
|
|
43
|
+
|
|
44
|
+
- служба поднимается **экземпляром на агента** (`gateway@<имя агента>.service`),
|
|
45
|
+
под его собственным пользователем и на своём порту;
|
|
46
|
+
- общий системный пользователь тут не годится по устройству: у него нет доступа к
|
|
47
|
+
дому агента, и все агенты машины действовали бы как одно лицо;
|
|
48
|
+
- **всякое право, выдаваемое рукам агента, выдаётся ЭТОМУ юниту.** Права
|
|
49
|
+
платформы к делу не относятся.
|
|
50
|
+
|
|
51
|
+
Последнее — не теория. Мы потеряли на этом рабочий заход: дали агенту право читать
|
|
52
|
+
системный журнал, вписали его в юнит платформы, проверили по живому процессу
|
|
53
|
+
платформы — всё сошлось, а отказ у агента остался. **Верный метод, приложенный не
|
|
54
|
+
к тому объекту, даёт уверенность, а не истину.** Проверять надо тот процесс,
|
|
55
|
+
который исполняет команды:
|
|
56
|
+
|
|
57
|
+
```bash
|
|
58
|
+
grep ^Groups: /proc/$(systemctl show -p MainPID --value gateway@<агент>.service)/status
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
🔴 `id` и `sudo -u <агент>` на этот вопрос **не отвечают**: они порождают НОВЫЙ
|
|
62
|
+
процесс, который берёт группы из системного файла, и покажут желаемое вместо
|
|
63
|
+
действительного. Живой процесс фиксирует набор групп при запуске.
|
|
64
|
+
|
|
65
|
+
---
|
|
66
|
+
|
|
67
|
+
## Установка
|
|
68
|
+
|
|
69
|
+
### 0. Код и его зависимости
|
|
70
|
+
|
|
71
|
+
```bash
|
|
72
|
+
sudo mkdir -p /opt/subscription-gateway
|
|
73
|
+
cd /opt/subscription-gateway
|
|
74
|
+
npm install subscription-gateway # либо скопируйте этот пакет сюда
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
Шаблон юнита ниже запускает `/opt/subscription-gateway/gateway.mjs`. Положите код
|
|
78
|
+
в другое место — придётся править три места, все названы в комментарии в шапке
|
|
79
|
+
файла юнита.
|
|
80
|
+
|
|
81
|
+
**Признак успеха:** `node -e "import('./gateway.mjs')"` завершается без
|
|
82
|
+
`ERR_MODULE_NOT_FOUND`. Две зависимости времени выполнения — агентный SDK
|
|
83
|
+
поставщика и библиотека схем — обязаны разрешаться из того каталога, в котором
|
|
84
|
+
юнит запускает файл; `NODE_PATH` тут **не помогает**, модули ES его игнорируют.
|
|
85
|
+
|
|
86
|
+
### 1. Токен в одном месте, права по группе
|
|
87
|
+
|
|
88
|
+
```bash
|
|
89
|
+
sudo groupadd -r gateway-token
|
|
90
|
+
sudo install -d -m 750 -o root -g gateway-token /etc/subscription-gateway
|
|
91
|
+
sudo install -m 640 -o root -g gateway-token /dev/null /etc/subscription-gateway/token
|
|
92
|
+
# положите в файл токен подписки
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
**Признак успеха:** файл читается членом группы и не читается никем другим.
|
|
96
|
+
|
|
97
|
+
🔴 Изоляция секрета настоящая только против агентов **без** `sudo`. Агент с `sudo`
|
|
98
|
+
прочтёт файл всё равно — не воображайте защиту, которой нет.
|
|
99
|
+
|
|
100
|
+
### 2. Шаблон юнита
|
|
101
|
+
|
|
102
|
+
Поставьте `systemd/gateway@.service` (он в этом пакете) и заведите экземпляр:
|
|
103
|
+
|
|
104
|
+
```bash
|
|
105
|
+
sudo systemctl enable --now gateway@<агент>.service
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
**Признак успеха:**
|
|
109
|
+
|
|
110
|
+
```bash
|
|
111
|
+
curl -s http://127.0.0.1:<порт>/health
|
|
112
|
+
{"ok":true,"token":"present","sdk":true}
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
🔴 Здоровье означает **наличие секрета**, а не «процесс жив». Без токена служба
|
|
116
|
+
поднята и бесполезна — это обязано быть видно снаружи, поэтому она отвечает 503,
|
|
117
|
+
а не 200.
|
|
118
|
+
|
|
119
|
+
### 3. Окружение экземпляра
|
|
120
|
+
|
|
121
|
+
`/etc/subscription-gateway/instance-<агент>.env`:
|
|
122
|
+
|
|
123
|
+
```
|
|
124
|
+
GATEWAY_PORT=<порт экземпляра>
|
|
125
|
+
GATEWAY_WORK_DIR=/home/<агент>/workspace
|
|
126
|
+
GATEWAY_MAX_TURNS=120
|
|
127
|
+
GATEWAY_MCP={"<имя сервера>":{"type":"http","url":"http://127.0.0.1:ПОРТ/mcp"}}
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
| переменная | что задаёт | умолчание |
|
|
131
|
+
|---|---|---|
|
|
132
|
+
| `GATEWAY_PORT` | порт на петле | 8788 |
|
|
133
|
+
| `GATEWAY_TOKEN_FILE` | файл токена | задаётся юнитом |
|
|
134
|
+
| `GATEWAY_WORK_DIR` | рабочий каталог для инструментов | `$HOME`, иначе `/tmp` |
|
|
135
|
+
| `GATEWAY_MAX_TURNS` | верхний предел ходов внутри одного запроса | 60 |
|
|
136
|
+
| `GATEWAY_MCP` | внешние серверы MCP, JSON | пусто (не ошибка) |
|
|
137
|
+
|
|
138
|
+
🔴 **Поднимайте `GATEWAY_MAX_TURNS` осознанно.** Мы упёрлись в него на 61-м ходе
|
|
139
|
+
длинной задачи, и SDK сообщил об этом как `exited with code N` — то есть кодом без
|
|
140
|
+
причины. Теперь шлюз выкапывает настоящую причину из расшифровки и печатает
|
|
141
|
+
`агент упёрся в предел ходов: дошёл до <N> при пороге <M>`, а не найдя её, говорит
|
|
142
|
+
«причина не установлена» вместо правдоподобной выдумки.
|
|
143
|
+
|
|
144
|
+
### 4. Проверьте, что подписка отвечает
|
|
145
|
+
|
|
146
|
+
Пошлите короткий запрос на `POST /v1/agent-stream` и дождитесь ответа модели.
|
|
147
|
+
|
|
148
|
+
**Признак успеха:** пришёл поток с текстом. **Признак беды:** каждый вызов
|
|
149
|
+
отклоняется пределом частоты при заведомо исправной подписке — см. следующий
|
|
150
|
+
раздел, это не про вашу квоту.
|
|
151
|
+
|
|
152
|
+
---
|
|
153
|
+
|
|
154
|
+
## 🔴 Почему внутри SDK поставщика, а не собственный HTTP
|
|
155
|
+
|
|
156
|
+
Первая редакция собирала запрос к API руками: токен подписки, нужные бета-метки,
|
|
157
|
+
вынутые из клиентского двоичного файла. **Опознание работало** — неверный токен
|
|
158
|
+
получал 401, наш получал 429, — но **каждый** вызов отклонялся пределом частоты
|
|
159
|
+
при совершенно исправной подписке: в ту самую минуту на ней работали три агента.
|
|
160
|
+
|
|
161
|
+
Мы перебрали и отбросили четыре версии: заголовки клиента, привязка к модели,
|
|
162
|
+
истечение токена, набор бета-меток. Правда оказалась другой — **сырой путь просто
|
|
163
|
+
не обслуживается для подписочного доступа**. Тот же токен, та же машина, тот же
|
|
164
|
+
выход в сеть: SDK отвечает за четыре секунды, самодельный запрос получает отказ.
|
|
165
|
+
|
|
166
|
+
Общий вывод дороже самого случая: **не переизобретайте протокол поставщика.** Код
|
|
167
|
+
поставщика знает тонкости, которых нет в документации, и переживёт их изменение. А
|
|
168
|
+
отказ, выглядящий как «у вас кончилась квота», может означать «вы стучитесь не в
|
|
169
|
+
ту дверь».
|
|
170
|
+
|
|
171
|
+
---
|
|
172
|
+
|
|
173
|
+
## Чего он не делает и делать не будет
|
|
174
|
+
|
|
175
|
+
- **наружу не выставляется никогда.** Слушает только петлю. Это доступ к подписке
|
|
176
|
+
без пароля; вынести его на внешний адрес — то же, что опубликовать токен;
|
|
177
|
+
- **подтверждений не спрашивает** (`bypassPermissions`): спрашивать некого, на том
|
|
178
|
+
конце не человек, а хост. Настоящая граница — права пользователя экземпляра, и
|
|
179
|
+
задаются они в systemd, а не здесь;
|
|
180
|
+
- **песочница юнита намеренно не закручивается**: агенту нужны его собственные
|
|
181
|
+
файлы и дом, а против агента с `sudo` ограничения юнита всё равно не граница;
|
|
182
|
+
- **шлюз не знает, какие инструменты он передаёт.** Он получает их описание от
|
|
183
|
+
моста и проксирует вызовы обратно. Это нарочно: следующий агент с другим набором
|
|
184
|
+
подключается без правки шлюза.
|
|
185
|
+
|
|
186
|
+
---
|
|
187
|
+
|
|
188
|
+
## Переходник схем: место, где теряется молча
|
|
189
|
+
|
|
190
|
+
Инструменты, приходящие от хоста, описаны схемой JSON Schema, а SDK ждёт схему
|
|
191
|
+
своего вида. `jsonschema-to-zod.mjs` переводит между ними.
|
|
192
|
+
|
|
193
|
+
🔴 **Незнакомая форма — это молчаливая потеря, а не отказ.** Первая редакция не
|
|
194
|
+
знала формы «строка ИЛИ объект» (`anyOf`/`oneOf`) и возвращала для неё «что
|
|
195
|
+
угодно». Инструмент при этом регистрировался, выглядел исправным — и падал в
|
|
196
|
+
момент вызова, при разборе доводов.
|
|
197
|
+
|
|
198
|
+
Проверяйте переходник **на настоящих схемах вашей платформы**, а не на выдуманных:
|
|
199
|
+
|
|
200
|
+
```bash
|
|
201
|
+
node test-schema-adapter.mjs
|
|
202
|
+
```
|
|
203
|
+
|
|
204
|
+
Стенд в этом пакете берёт схемы из файла, порождённого самой платформой, и
|
|
205
|
+
проверяет, что обе ветки `anyOf` проходят, а чужой тип — **нет**. Вторая половина
|
|
206
|
+
важнее первой: схема, выродившаяся в «что угодно», пропускает всё и тем самым
|
|
207
|
+
прячет ошибку.
|
|
208
|
+
|
|
209
|
+
Три исхода стенда различаются намеренно: `0` — сошлось, `1` — расхождение,
|
|
210
|
+
`2` — слепота (проверять нечем, например не поставлены зависимости). Слепота,
|
|
211
|
+
выданная кодом `0`, была бы хуже отсутствия проверки: она читается как успех.
|
|
212
|
+
|
|
213
|
+
---
|
|
214
|
+
|
|
215
|
+
## Приёмка своей рукой
|
|
216
|
+
|
|
217
|
+
1. `curl /health` → `{"ok":true,"token":"present","sdk":true}`. Проверяет, что
|
|
218
|
+
секрет читается.
|
|
219
|
+
2. `grep ^Groups: /proc/<MainPID>/status` → нужные группы **на живом процессе**.
|
|
220
|
+
Проверяет права рук агента, и только этот способ отвечает на такой вопрос.
|
|
221
|
+
3. Короткий запрос на `/v1/agent-stream` → модель ответила. Проверяет саму подписку.
|
|
222
|
+
4. `node test-schema-adapter.mjs` → всё зелёное. Проверяет переходник схем.
|
|
223
|
+
5. Если рядом поставлен мост: попросите модель вызвать инструмент платформы и
|
|
224
|
+
найдите вызов в журнале хоста. Ответ модели признаком не является.
|
|
225
|
+
|
|
226
|
+
🔴 **Все пять годятся как прогон после обновления** — что SDK поставщика, что
|
|
227
|
+
платформы. Ни одно из этих расхождений не падает с ошибкой: схема вырождается в
|
|
228
|
+
«что угодно», группа теряется при перезапуске, токен остаётся на месте, а путь к
|
|
229
|
+
нему меняется. Всё это выглядит как исправная работа.
|
|
230
|
+
|
|
231
|
+
---
|
|
232
|
+
|
|
233
|
+
## Лицензия
|
|
234
|
+
|
|
235
|
+
MIT.
|
package/gateway.mjs
CHANGED
|
@@ -1,55 +1,53 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* Шлюз подписки Anthropic — общий системный сервис машины.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
* configuration and knows nothing about the token.
|
|
4
|
+
* ЗАЧЕМ. Агентов на машине несколько, каждый под своим пользователем. Если бы
|
|
5
|
+
* подписочный токен клал себе каждый, секрет размножался бы по машине. Здесь он
|
|
6
|
+
* живёт в ОДНОМ месте, под своим пользователем, а агенты получают точку входа.
|
|
7
|
+
* Новый агент подключается строкой в конфиге и о токене не знает.
|
|
9
8
|
*
|
|
10
|
-
* 🔴
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
* knows subtleties that are not in the documentation, and it will survive them
|
|
22
|
-
* changing.
|
|
9
|
+
* 🔴 ПОЧЕМУ ВНУТРИ ОФИЦИАЛЬНЫЙ SDK, А НЕ РУЧНОЙ HTTP (урок 19.08.2026).
|
|
10
|
+
* Сначала шлюз собирал запрос к API руками: подписочный токен, два бета-флага,
|
|
11
|
+
* вынутые из бинаря. Авторизация проходила (с неверным токеном приходило 401,
|
|
12
|
+
* с нашим — 429), но КАЖДЫЙ вызов отбивался лимитом, при полностью живой
|
|
13
|
+
* подписке: в тот же момент на ней работали три агента. Перебрали и отбросили
|
|
14
|
+
* четыре версии — заголовки клиента, привязку к модели, срок годности токена,
|
|
15
|
+
* набор бета-флагов. Верным оказалось иное: сырой путь подписке просто не
|
|
16
|
+
* отдают. Тот же токен, та же машина, тот же выход в сеть — SDK отвечает за
|
|
17
|
+
* четыре секунды, ручной запрос получает отказ.
|
|
18
|
+
* Вывод общего вида: НЕ ИЗОБРЕТАТЬ ПРОТОКОЛ ПОСТАВЩИКА. Вендорский код знает
|
|
19
|
+
* тонкости, которых нет в документации, и переживёт их смену.
|
|
23
20
|
*
|
|
24
|
-
* 🔴
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
* the owner, verified with a live call.
|
|
21
|
+
* 🔴 НЕЗАВИСИМОСТЬ ОТ ГЛАВНОЙ МАШИНЫ. SDK стоит ЗДЕСЬ, токен ЗДЕСЬ, выход в
|
|
22
|
+
* сеть у машины свой. Главная машина в цепочке не участвует и может быть выключена —
|
|
23
|
+
* это прямое требование владельца, проверенное живым вызовом.
|
|
28
24
|
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
25
|
+
* ГРАНИЦЫ. Слушает только петлю. Наружу не выставляется никогда: это доступ к
|
|
26
|
+
* нашей подписке без пароля.
|
|
31
27
|
*
|
|
32
|
-
* 🔴
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
*
|
|
42
|
-
*
|
|
43
|
-
* anyway. Isolation of the secret is real against agents WITHOUT sudo.
|
|
28
|
+
* 🔴 ИНСТРУМЕНТЫ ИСПОЛНЯЕТ ДВИЖОК, А НЕ ПЛАТФОРМА — И ОТСЮДА ВЫБОР ПОЛЬЗОВАТЕЛЯ.
|
|
29
|
+
* SDK агентный: он сам ведёт цикл и сам выполняет оболочку, файлы, поиск, веб.
|
|
30
|
+
* Значит «руки» агента — это тот пользователь, под которым идёт ЭТОТ процесс.
|
|
31
|
+
* Поэтому служба запускается ЭКЗЕМПЛЯРОМ НА АГЕНТА (`<служба>@<агент>.service`), под
|
|
32
|
+
* его собственным именем и на своём порту. Общий системный пользователь здесь
|
|
33
|
+
* не годится структурно: у него нет доступа к дому агента, а все агенты машины
|
|
34
|
+
* действовали бы одним лицом и топтали бы друг друга.
|
|
35
|
+
* Что при этом ОСТАЁТСЯ общим: файл токена — один на машину, в конфиге агента
|
|
36
|
+
* секрета нет, ротация в одном месте.
|
|
37
|
+
* Что НЕ надо себе воображать: агент с sudo прочитает файл токена в любом
|
|
38
|
+
* случае. Изоляция секрета реальна против агентов БЕЗ sudo.
|
|
44
39
|
*
|
|
45
|
-
*
|
|
46
|
-
*
|
|
47
|
-
*
|
|
48
|
-
* not here.
|
|
40
|
+
* ГРАНИЦА ПРАВ. Подтверждения не запрашиваются (`bypassPermissions`): спросить
|
|
41
|
+
* тут некого, на том конце не человек, а платформа. Реальная граница — права
|
|
42
|
+
* пользователя экземпляра, и задаётся она в systemd, а не здесь.
|
|
49
43
|
*/
|
|
50
44
|
|
|
51
45
|
import http from 'node:http';
|
|
52
46
|
import fs from 'node:fs';
|
|
47
|
+
// 🔴 Глобальный crypto здесь — ВЕБ-версия: у неё есть randomUUID и нет
|
|
48
|
+
// createHash. Проверка синтаксиса это пропускает, падает первый же запрос
|
|
49
|
+
// (поймано пробой поведения 30.08.2026, до установки).
|
|
50
|
+
import nodeCrypto from 'node:crypto';
|
|
53
51
|
import { query, createSdkMcpServer, tool } from '@anthropic-ai/claude-agent-sdk';
|
|
54
52
|
import { shape } from './jsonschema-to-zod.mjs';
|
|
55
53
|
|
|
@@ -59,9 +57,9 @@ const TOKEN_FILE = process.env.GATEWAY_TOKEN_FILE || '/etc/subscription-gateway/
|
|
|
59
57
|
const DEFAULT_MODEL = 'claude-opus-5';
|
|
60
58
|
const DEFAULT_MAX_TURNS = Number(process.env.GATEWAY_MAX_TURNS || 60);
|
|
61
59
|
/**
|
|
62
|
-
*
|
|
63
|
-
* GATEWAY_MCP
|
|
64
|
-
*
|
|
60
|
+
* Внешние серверы инструментов (MCP) для агента этого экземпляра. Задаются
|
|
61
|
+
* переменной GATEWAY_MCP как JSON: {"omega":{"type":"http","url":"..."}}.
|
|
62
|
+
* Пусто — агент работает без них, это не ошибка.
|
|
65
63
|
*/
|
|
66
64
|
const MCP_SERVERS = (() => {
|
|
67
65
|
const raw = process.env.GATEWAY_MCP;
|
|
@@ -70,30 +68,23 @@ const MCP_SERVERS = (() => {
|
|
|
70
68
|
const v = JSON.parse(raw);
|
|
71
69
|
return v && Object.keys(v).length ? v : null;
|
|
72
70
|
} catch (e) {
|
|
73
|
-
//
|
|
74
|
-
//
|
|
75
|
-
|
|
76
|
-
console.error(`[subscription-gateway] 🔴 GATEWAY_MCP could not be parsed: ${e?.message}`);
|
|
71
|
+
// Молча игнорировать нельзя: агент останется без памяти, и это будет
|
|
72
|
+
// выглядеть как «память не работает», а не как «я неверно записала строку».
|
|
73
|
+
console.error(`[subscription-gateway] 🔴 GATEWAY_MCP не разобран: ${e?.message}`);
|
|
77
74
|
return null;
|
|
78
75
|
}
|
|
79
76
|
})();
|
|
80
77
|
|
|
81
|
-
/**
|
|
78
|
+
/** Рабочий каталог инструментов по умолчанию — дом пользователя экземпляра. */
|
|
82
79
|
const WORK_DIR = process.env.GATEWAY_WORK_DIR || process.env.HOME || '/tmp';
|
|
83
80
|
|
|
84
81
|
const log = (m) => console.error(`[subscription-gateway] ${m}`);
|
|
85
82
|
|
|
86
83
|
/**
|
|
87
|
-
*
|
|
88
|
-
*
|
|
89
|
-
*
|
|
90
|
-
*
|
|
91
|
-
* one.
|
|
92
|
-
*
|
|
93
|
-
* 🔴 The strings matched here — 'exited with code', 'returned an error result',
|
|
94
|
-
* 'max_turns_reached' — are the SDK'S OWN wording and protocol constants. They
|
|
95
|
-
* are not ours to translate or prettify: change them and the match silently
|
|
96
|
-
* stops finding anything, leaving a code without a reason again.
|
|
84
|
+
* «exited with code N» от SDK — это только код без причины; истина в транскрипте
|
|
85
|
+
* сессии. Ищем attachment.type == "max_turns_reached" и возвращаем человеческую
|
|
86
|
+
* строку с числами. Не нашли — прежний текст + явное «причина не установлена»,
|
|
87
|
+
* БЕЗ додумывания правдоподобной причины.
|
|
97
88
|
*/
|
|
98
89
|
function explainExit(err, sessionId, cwd) {
|
|
99
90
|
const raw = String(err?.message ?? err);
|
|
@@ -105,16 +96,16 @@ function explainExit(err, sessionId, cwd) {
|
|
|
105
96
|
if (!line.includes('max_turns_reached')) continue;
|
|
106
97
|
const a = JSON.parse(line)?.attachment;
|
|
107
98
|
if (a?.type === 'max_turns_reached') {
|
|
108
|
-
return
|
|
99
|
+
return `агент упёрся в лимит ходов: дошёл до ${a.turnCount} при пороге ${a.maxTurns} (max_turns_reached)`;
|
|
109
100
|
}
|
|
110
101
|
}
|
|
111
102
|
} catch {
|
|
112
|
-
return `${raw} —
|
|
103
|
+
return `${raw} — причина не установлена (транскрипт не прочитан)`;
|
|
113
104
|
}
|
|
114
|
-
return `${raw} —
|
|
105
|
+
return `${raw} — причина не установлена (в свежем транскрипте записи max_turns_reached нет)`;
|
|
115
106
|
}
|
|
116
107
|
|
|
117
|
-
/**
|
|
108
|
+
/** Токен читаем при КАЖДОМ запросе: смена секрета не требует перезапуска. */
|
|
118
109
|
function readToken() {
|
|
119
110
|
try {
|
|
120
111
|
return fs.readFileSync(TOKEN_FILE, 'utf8').trim() || null;
|
|
@@ -123,7 +114,7 @@ function readToken() {
|
|
|
123
114
|
}
|
|
124
115
|
}
|
|
125
116
|
|
|
126
|
-
/**
|
|
117
|
+
/** Короткая подпись к вызову инструмента: что именно он делает, одной строкой. */
|
|
127
118
|
function briefOf(input) {
|
|
128
119
|
if (!input || typeof input !== 'object') return '';
|
|
129
120
|
const v = input.command ?? input.file_path ?? input.pattern ?? input.url ?? input.path ?? input.query;
|
|
@@ -146,21 +137,17 @@ function readBody(req) {
|
|
|
146
137
|
}
|
|
147
138
|
|
|
148
139
|
/**
|
|
149
|
-
*
|
|
150
|
-
*
|
|
151
|
-
* A deliberate simplification: the SDK takes the prompt as a string. History is
|
|
152
|
-
* glued together with role labels — the model understands them, and the platform
|
|
153
|
-
* keeps its own history anyway. When there is time for it, real message passing
|
|
154
|
-
* will appear here instead of gluing.
|
|
140
|
+
* Собираем один текстовый запрос из сообщений.
|
|
155
141
|
*
|
|
156
|
-
*
|
|
157
|
-
*
|
|
158
|
-
*
|
|
142
|
+
* Осознанное упрощение: SDK принимает подсказку строкой. Историю склеиваем
|
|
143
|
+
* ролевыми метками — модель их понимает, а платформа всё равно держит свою
|
|
144
|
+
* историю у себя. Когда дойдут руки до инструментов, здесь появится настоящая
|
|
145
|
+
* передача сообщений, а не склейка.
|
|
159
146
|
*/
|
|
160
147
|
function buildPrompt(messages) {
|
|
161
148
|
const parts = [];
|
|
162
149
|
for (const m of messages ?? []) {
|
|
163
|
-
const who = m.role === 'assistant' ? '
|
|
150
|
+
const who = m.role === 'assistant' ? 'Ассистент' : 'Пользователь';
|
|
164
151
|
const c = m.content;
|
|
165
152
|
const text =
|
|
166
153
|
typeof c === 'string'
|
|
@@ -174,52 +161,158 @@ function buildPrompt(messages) {
|
|
|
174
161
|
}
|
|
175
162
|
|
|
176
163
|
/**
|
|
177
|
-
*
|
|
164
|
+
* МЕЖХОДОВОЙ КЭШ ПОДСКАЗКИ.
|
|
165
|
+
*
|
|
166
|
+
* Беда, ради которой это написано (замер 30.08.2026): платформа присылает всю
|
|
167
|
+
* историю каждый ход, buildPrompt склеивает её в ОДИН блок, и этот блок
|
|
168
|
+
* меняется целиком — кэш подсказки не попадает ни разу. На 128 ходах: запись
|
|
169
|
+
* кэша 31,6 млн токенов на первых запросах ходов, то есть около 15% всей
|
|
170
|
+
* условной стоимости уходит на переписывание того, что уже было записано.
|
|
171
|
+
*
|
|
172
|
+
* Лечение: SDK умеет продолжать свою сессию (`resume`). Тогда история едет
|
|
173
|
+
* тем же байтом, что и в прошлый раз, и попадает в кэш целиком — замерено
|
|
174
|
+
* A/B на живом SDK: та же история прежним способом даёт запись 22 128, через
|
|
175
|
+
* resume — чтение 37 630 при записи 44.
|
|
176
|
+
*
|
|
177
|
+
* 🔴 ГДЕ ЭТО НЕ ДЕЙСТВУЕТ И ЧЕГО НЕ ДЕЛАЕТ:
|
|
178
|
+
* - не сокращает историю: это делает сжатие, отдельный рычаг;
|
|
179
|
+
* - не переживает перезапуск шлюза — состояние живёт в памяти процесса.
|
|
180
|
+
* После перезапуска первый ход каждой беседы снова холодный. Это осознанно:
|
|
181
|
+
* состояние на диске пришлось бы сводить с историей платформы, а расхождение
|
|
182
|
+
* двух источников правды дороже одного холодного хода;
|
|
183
|
+
* - не действует, если платформа переписала прошлые сообщения (сжатие,
|
|
184
|
+
* усечение, правка): префикс сверяется хэшем, не сошёлся — идём прежним
|
|
185
|
+
* путём с полной историей. Молчаливого расхождения быть не должно;
|
|
186
|
+
* - выключается целиком переменной GATEWAY_RESUME=0 без правки кода.
|
|
187
|
+
*
|
|
188
|
+
* 🔴 ИСТОЧНИК ПРАВДЫ — ПЛАТФОРМА. Транскрипт SDK здесь только кэш: его потеря
|
|
189
|
+
* означает холодный ход, а не потерю разговора. Поэтому отказ возобновления
|
|
190
|
+
* ловится и переигрывается полной историей, а не отдаётся наружу.
|
|
191
|
+
*/
|
|
192
|
+
const RESUME_ON = (process.env.GATEWAY_RESUME ?? '1') !== '0';
|
|
193
|
+
const SESSII = new Map(); // ключ беседы -> { sessionId, otdano, hashPref }
|
|
194
|
+
const SESSII_MAX = 8; // бесед у агента единицы; предел от утечки памяти
|
|
195
|
+
|
|
196
|
+
function hashText(s) {
|
|
197
|
+
return nodeCrypto.createHash('sha256').update(s, 'utf8').digest('hex').slice(0, 32);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** Текст сообщения в том же виде, в каком его склеивает buildPrompt. */
|
|
201
|
+
function tekstSoobshcheniya(m) {
|
|
202
|
+
const c = m?.content;
|
|
203
|
+
if (typeof c === 'string') return c;
|
|
204
|
+
if (Array.isArray(c)) return c.filter((b) => b?.type === 'text').map((b) => b.text).join('\n');
|
|
205
|
+
return '';
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/** Ключ беседы: первое сообщение. Сменилось — это другая беседа. */
|
|
209
|
+
function kluchBesedy(messages) {
|
|
210
|
+
const first = (messages ?? [])[0];
|
|
211
|
+
if (!first) return null;
|
|
212
|
+
return hashText((first.role ?? '') + '\u0000' + tekstSoobshcheniya(first));
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/** Отпечаток первых n сообщений — сторож против молчаливой правки истории. */
|
|
216
|
+
function hashPrefiksa(messages, n) {
|
|
217
|
+
const parts = [];
|
|
218
|
+
for (let i = 0; i < n && i < messages.length; i++) {
|
|
219
|
+
parts.push((messages[i].role ?? '') + '\u0000' + tekstSoobshcheniya(messages[i]));
|
|
220
|
+
}
|
|
221
|
+
return hashText(parts.join('\u0001'));
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Решение: продолжать сессию или начинать заново. Возвращает и то, чем потом
|
|
226
|
+
* обновить состояние, — чтобы обновление шло по ФАКТУ отправленного, а не по
|
|
227
|
+
* намерению.
|
|
228
|
+
*/
|
|
229
|
+
function planZaprosa(messages) {
|
|
230
|
+
const msgs = messages ?? [];
|
|
231
|
+
const kluch = kluchBesedy(msgs);
|
|
232
|
+
const polnyj = { kluch, resume: null, prompt: buildPrompt(msgs), otdano: msgs.length, pochemu: 'полная история' };
|
|
233
|
+
if (!RESUME_ON || !kluch) return polnyj;
|
|
234
|
+
const st = SESSII.get(kluch);
|
|
235
|
+
if (!st) return polnyj;
|
|
236
|
+
if (msgs.length <= st.otdano) return polnyj;
|
|
237
|
+
if (hashPrefiksa(msgs, st.otdano) !== st.hashPref) {
|
|
238
|
+
// История переписана на той стороне. Говорим вслух: молчаливое расхождение
|
|
239
|
+
// двух картин разговора — худшее, что здесь может случиться.
|
|
240
|
+
log('история изменена платформой — продолжение сессии отменено, иду полной историей');
|
|
241
|
+
SESSII.delete(kluch);
|
|
242
|
+
return polnyj;
|
|
243
|
+
}
|
|
244
|
+
// Ответ прошлого хода SDK уже записал у себя; повторно его не шлём.
|
|
245
|
+
let i = st.otdano;
|
|
246
|
+
while (i < msgs.length && msgs[i]?.role === 'assistant') i++;
|
|
247
|
+
if (i >= msgs.length) return polnyj;
|
|
248
|
+
return {
|
|
249
|
+
kluch,
|
|
250
|
+
resume: st.sessionId,
|
|
251
|
+
prompt: buildPrompt(msgs.slice(i)),
|
|
252
|
+
otdano: msgs.length,
|
|
253
|
+
pochemu: `продолжаю сессию, новых сообщений ${msgs.length - i} из ${msgs.length}`,
|
|
254
|
+
};
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/** Запоминаем ФАКТ: что именно отдано и каким был префикс. */
|
|
258
|
+
function zapomnit(plan, sessionId, messages) {
|
|
259
|
+
if (!plan.kluch) return;
|
|
260
|
+
if (SESSII.size >= SESSII_MAX && !SESSII.has(plan.kluch)) {
|
|
261
|
+
SESSII.delete(SESSII.keys().next().value); // самая старая
|
|
262
|
+
}
|
|
263
|
+
SESSII.set(plan.kluch, {
|
|
264
|
+
sessionId,
|
|
265
|
+
otdano: plan.otdano,
|
|
266
|
+
hashPref: hashPrefiksa(messages, plan.otdano),
|
|
267
|
+
});
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* МОСТ ИНСТРУМЕНТОВ ПЛАТФОРМЫ.
|
|
178
272
|
*
|
|
179
|
-
*
|
|
180
|
-
*
|
|
181
|
-
*
|
|
273
|
+
* Инструменты исполняет движок, поэтому его набор — единственный, который
|
|
274
|
+
* видит модель. Плагины платформы до неё не доходят вовсе. Мост собирает из
|
|
275
|
+
* описания, присланного платформой, MCP-сервер и проксирует вызовы обратно.
|
|
182
276
|
*
|
|
183
|
-
* 🔴
|
|
184
|
-
*
|
|
185
|
-
*
|
|
186
|
-
*
|
|
277
|
+
* 🔴 ШЛЮЗ НЕ ЗНАЕТ, ЧТО ЭТО ЗА ИНСТРУМЕНТЫ. Имена, описания и схемы приходят с
|
|
278
|
+
* той стороны; здесь только транспорт. Следующий агент с другим набором
|
|
279
|
+
* подключается без правки этого файла — это и есть общее решение, а не
|
|
280
|
+
* заплатка под одного.
|
|
187
281
|
*
|
|
188
|
-
*
|
|
189
|
-
*
|
|
190
|
-
* --mcp-config
|
|
191
|
-
* /proc/<pid>/cmdline (
|
|
192
|
-
*
|
|
193
|
-
*
|
|
282
|
+
* ТРАНСПОРТ sdk, А НЕ stdio: sdk-сервер живёт в этом же процессе и не
|
|
283
|
+
* порождает дочернего. Всё, что SDK передаёт дочернему процессу, уходит
|
|
284
|
+
* аргументом --mcp-config и читается любым пользователем машины через
|
|
285
|
+
* /proc/<pid>/cmdline (444) — проверено живым наблюдателем 22.08.2026.
|
|
286
|
+
* Поэтому ПРОПУСК моста при sdk не утекает: он остаётся в памяти двух
|
|
287
|
+
* процессов и в теле запроса по петле.
|
|
194
288
|
*
|
|
195
|
-
* 🔴
|
|
196
|
-
*
|
|
197
|
-
*
|
|
198
|
-
*
|
|
199
|
-
*
|
|
200
|
-
*
|
|
201
|
-
*
|
|
202
|
-
* with — neither for the model nor for the gateway itself.
|
|
289
|
+
* 🔴 ЧТО ИМЕННО НОСИТ ШЛЮЗ. Не личность и не общий секрет, а ОДНОРАЗОВЫЙ
|
|
290
|
+
* ПРОПУСК, выданный платформой на этот ход. Шлюз не знает, чей он: поля
|
|
291
|
+
* личности агента в этом файле нет ни одного — ни в коде, ни в комментариях
|
|
292
|
+
* (нарочно не пишем здесь и само имя поля: иначе проверка грепом нашла бы
|
|
293
|
+
* собственную оговорку и приняла её за вхождение). Платформа сама достаёт
|
|
294
|
+
* личность по пропуску из своей таблицы. Значит заявить чужую личность отсюда
|
|
295
|
+
* нечем — ни модели, ни самому шлюзу.
|
|
203
296
|
*/
|
|
204
297
|
function buildBridgeServer(bridge) {
|
|
205
298
|
if (!bridge?.url || !bridge?.ticket || !Array.isArray(bridge.tools) || !bridge.tools.length) return null;
|
|
206
299
|
|
|
207
|
-
// 🔴
|
|
208
|
-
//
|
|
209
|
-
//
|
|
210
|
-
//
|
|
211
|
-
//
|
|
212
|
-
//
|
|
213
|
-
//
|
|
300
|
+
// 🔴 ШЛЮЗ НЕ ЗНАЕТ, ЧЕЙ ЭТО ЗАПРОС, И ЗНАТЬ НЕ ДОЛЖЕН. Он носит непрозрачный
|
|
301
|
+
// одноразовый пропуск, выданный платформой на этот ход, и предъявляет его на
|
|
302
|
+
// двери. Личность по пропуску достаёт сама платформа из своей таблицы.
|
|
303
|
+
// Личность НЕ передаётся: тот, кто её заявляет, не должен быть тем, кто её
|
|
304
|
+
// назначает. Пропуск живёт в ЗАМЫКАНИИ этого обработчика — при транспорте sdk
|
|
305
|
+
// он не уходит ни в командную строку, ни в окружение (замерено: 0 попаданий
|
|
306
|
+
// при 275 просмотренных процессах).
|
|
214
307
|
const callBridge = async (toolName, args) => {
|
|
215
308
|
const r = await fetch(bridge.url, {
|
|
216
309
|
method: 'POST',
|
|
217
310
|
headers: { 'content-type': 'application/json', 'x-bridge-ticket': bridge.ticket },
|
|
218
311
|
body: JSON.stringify({ tool: toolName, args }),
|
|
219
312
|
});
|
|
220
|
-
if (!r.ok) throw new Error(
|
|
313
|
+
if (!r.ok) throw new Error(`мост ответил ${r.status}`);
|
|
221
314
|
const out = await r.json();
|
|
222
|
-
if (out?.ok !== true) throw new Error(String(out?.error ?? '
|
|
315
|
+
if (out?.ok !== true) throw new Error(String(out?.error ?? 'мост отказал без причины'));
|
|
223
316
|
return out.value;
|
|
224
317
|
};
|
|
225
318
|
|
|
@@ -229,10 +322,10 @@ function buildBridgeServer(bridge) {
|
|
|
229
322
|
try {
|
|
230
323
|
inputShape = shape(t.inputSchema ?? { type: 'object', properties: {} });
|
|
231
324
|
} catch (e) {
|
|
232
|
-
//
|
|
233
|
-
//
|
|
234
|
-
//
|
|
235
|
-
log(`🔴
|
|
325
|
+
// Инструмент со схемой, которую мы не умеем собрать, НЕ выставляем
|
|
326
|
+
// «как есть»: модель получила бы инструмент без формы параметров и
|
|
327
|
+
// ошибалась бы на исполнении. Пропуск громкий, остальные работают.
|
|
328
|
+
log(`🔴 инструмент ${t.name} пропущен: ${e?.message}`);
|
|
236
329
|
continue;
|
|
237
330
|
}
|
|
238
331
|
tools.push(
|
|
@@ -245,10 +338,9 @@ function buildBridgeServer(bridge) {
|
|
|
245
338
|
const value = await callBridge(t.name, args ?? {});
|
|
246
339
|
return { content: [{ type: 'text', text: JSON.stringify(value ?? null) }] };
|
|
247
340
|
} catch (e) {
|
|
248
|
-
//
|
|
249
|
-
//
|
|
250
|
-
|
|
251
|
-
return { content: [{ type: 'text', text: `REFUSED: ${e?.message ?? e}` }], isError: true };
|
|
341
|
+
// Отказ отдаём текстом, а не исключением: модель должна прочитать
|
|
342
|
+
// причину и решить, что делать, а не увидеть обрыв инструмента.
|
|
343
|
+
return { content: [{ type: 'text', text: `ОТКАЗ: ${e?.message ?? e}` }], isError: true };
|
|
252
344
|
}
|
|
253
345
|
},
|
|
254
346
|
),
|
|
@@ -256,26 +348,24 @@ function buildBridgeServer(bridge) {
|
|
|
256
348
|
}
|
|
257
349
|
if (!tools.length) return null;
|
|
258
350
|
|
|
259
|
-
// alwaysLoad:
|
|
260
|
-
//
|
|
351
|
+
// alwaysLoad: иначе инструменты уходят за поиск по каталогу и в системном
|
|
352
|
+
// заголовке их не видно — а именно заголовок у нас признак приёмки.
|
|
261
353
|
return createSdkMcpServer({ name: bridge.name || 'dsh', version: '0.1.0', tools, alwaysLoad: true });
|
|
262
354
|
}
|
|
263
355
|
|
|
264
356
|
/**
|
|
265
|
-
*
|
|
266
|
-
* server with its own name.
|
|
357
|
+
* Слить серверы инструментов, НЕ давая мосту затереть чужой сервер своим именем.
|
|
267
358
|
*
|
|
268
|
-
* 🔴
|
|
269
|
-
*
|
|
270
|
-
*
|
|
271
|
-
* *
|
|
272
|
-
*
|
|
273
|
-
* *
|
|
274
|
-
*
|
|
275
|
-
*
|
|
276
|
-
*
|
|
277
|
-
*
|
|
278
|
-
* server it works wrongly.
|
|
359
|
+
* 🔴 ПРАВИЛО ИМЁН (главная, 22.08.2026): имена серверов не должны совпадать —
|
|
360
|
+
* ни с уже подключёнными здесь, ни между уровнями корень/помощник. Цена
|
|
361
|
+
* совпадения молчаливая и в обе стороны:
|
|
362
|
+
* * здесь простой спред затёр бы одноимённый сервер (например omega) целиком,
|
|
363
|
+
* и модель получила бы вместо него мост, ничего об этом не узнав;
|
|
364
|
+
* * у помощника (опыт Б, 22.08) сервер с ИМЕНЕМ КОРНЯ поднимается со своим
|
|
365
|
+
* окружением, а отвечает всё равно корневой — по факту старта кажется, что
|
|
366
|
+
* настройка работает.
|
|
367
|
+
* Поэтому столкновение — отказ подключить мост, а не тихая замена: без моста
|
|
368
|
+
* агент работает хуже, с подменённым сервером — неверно.
|
|
279
369
|
*/
|
|
280
370
|
function mergeMcpServers(base, bridgeName, bridgeServer) {
|
|
281
371
|
const servers = { ...(base ?? {}) };
|
|
@@ -291,28 +381,23 @@ const server = http.createServer(async (req, res) => {
|
|
|
291
381
|
if (req.url === '/health') {
|
|
292
382
|
const ok = Boolean(readToken());
|
|
293
383
|
res.writeHead(ok ? 200 : 503, { 'content-type': 'application/json' });
|
|
294
|
-
//
|
|
295
|
-
//
|
|
296
|
-
|
|
297
|
-
// 🔴 The `token` field is API SURFACE, not a log line: the README documents
|
|
298
|
-
// this exact response and acceptance step 1 compares against it. If you change
|
|
299
|
-
// the wording, change the README and anything scripted against it in the same
|
|
300
|
-
// pass — the HTTP status is the machine-readable part, this field is not.
|
|
301
|
-
res.end(JSON.stringify({ ok, token: ok ? 'present' : 'MISSING', sdk: true }));
|
|
384
|
+
// Здоровьем считаем НАЛИЧИЕ секрета, а не «процесс жив»: без токена служба
|
|
385
|
+
// поднята, но бесполезна, и это должно быть видно снаружи.
|
|
386
|
+
res.end(JSON.stringify({ ok, token: ok ? 'есть' : 'НЕТ', sdk: true }));
|
|
302
387
|
return;
|
|
303
388
|
}
|
|
304
389
|
|
|
305
390
|
if (req.method !== 'POST' || req.url !== '/v1/agent-stream') {
|
|
306
391
|
res.writeHead(404, { 'content-type': 'application/json' });
|
|
307
|
-
res.end(JSON.stringify({ error: '
|
|
392
|
+
res.end(JSON.stringify({ error: 'неизвестный путь; есть /v1/agent-stream и /health' }));
|
|
308
393
|
return;
|
|
309
394
|
}
|
|
310
395
|
|
|
311
396
|
const token = readToken();
|
|
312
397
|
if (!token) {
|
|
313
398
|
res.writeHead(503, { 'content-type': 'application/json' });
|
|
314
|
-
res.end(JSON.stringify({ error: { type: 'no_credential', message: '
|
|
315
|
-
log('🔴
|
|
399
|
+
res.end(JSON.stringify({ error: { type: 'no_credential', message: 'нет подписочного токена' } }));
|
|
400
|
+
log('🔴 запрос отклонён: токен не прочитан');
|
|
316
401
|
return;
|
|
317
402
|
}
|
|
318
403
|
|
|
@@ -321,13 +406,13 @@ const server = http.createServer(async (req, res) => {
|
|
|
321
406
|
body = await readBody(req);
|
|
322
407
|
} catch {
|
|
323
408
|
res.writeHead(400, { 'content-type': 'application/json' });
|
|
324
|
-
res.end(JSON.stringify({ error: { type: 'bad_json', message: '
|
|
409
|
+
res.end(JSON.stringify({ error: { type: 'bad_json', message: 'тело запроса не разобрано' } }));
|
|
325
410
|
return;
|
|
326
411
|
}
|
|
327
412
|
|
|
328
|
-
//
|
|
329
|
-
//
|
|
330
|
-
//
|
|
413
|
+
// Поток событий строками JSON: одна строка — одно событие. Формат наш
|
|
414
|
+
// собственный и намеренно простой; переводом в протокол платформы занимается
|
|
415
|
+
// модуль на стороне агента.
|
|
331
416
|
res.writeHead(200, {
|
|
332
417
|
'content-type': 'application/x-ndjson; charset=utf-8',
|
|
333
418
|
'cache-control': 'no-cache',
|
|
@@ -336,41 +421,53 @@ const server = http.createServer(async (req, res) => {
|
|
|
336
421
|
|
|
337
422
|
const started = Date.now();
|
|
338
423
|
const cwd = body.cwd || WORK_DIR;
|
|
339
|
-
|
|
424
|
+
let plan = planZaprosa(body.messages);
|
|
425
|
+
// Свой id нужен и при возобновлении: SDK его сохраняет (проверено — id после
|
|
426
|
+
// resume тот же), а нам он нужен для объяснения отказа в explainExit.
|
|
427
|
+
const sessionId = plan.resume || crypto.randomUUID(); // Node >=19, у нас v24
|
|
340
428
|
|
|
341
|
-
//
|
|
342
|
-
//
|
|
429
|
+
// Мост инструментов платформы: подключается ТОЛЬКО когда та сторона прислала
|
|
430
|
+
// описание с пропуском. Нет его — поведение прежнее, байт в байт.
|
|
343
431
|
let bridgeServer = null;
|
|
344
432
|
try {
|
|
345
433
|
bridgeServer = buildBridgeServer(body.bridge);
|
|
346
434
|
} catch (e) {
|
|
347
|
-
//
|
|
348
|
-
//
|
|
349
|
-
|
|
350
|
-
log(`🔴 the bridge was not built: ${e?.message}`);
|
|
435
|
+
// Сбой сборки моста не должен рушить сам запрос: без инструментов агент
|
|
436
|
+
// работает хуже, но работает. Молчать при этом нельзя.
|
|
437
|
+
log(`🔴 мост не собран: ${e?.message}`);
|
|
351
438
|
}
|
|
352
439
|
const bridgeName = body.bridge?.name || 'dsh';
|
|
353
440
|
const merged = mergeMcpServers(MCP_SERVERS, bridgeName, bridgeServer);
|
|
354
441
|
const mcpAll = merged.servers;
|
|
355
|
-
//
|
|
356
|
-
//
|
|
357
|
-
if (merged.mounted) log(
|
|
358
|
-
else if (merged.conflict) log(`🔴
|
|
442
|
+
// Строка «подключён» печатается ПОСЛЕ слияния и только при удаче: она у нас
|
|
443
|
+
// признак приёмки, и врать ей нельзя.
|
|
444
|
+
if (merged.mounted) log(`мост подключён: инструментов ${body.bridge.tools.length}, сервер "${bridgeName}" (личность несёт пропуск, шлюз её не знает)`);
|
|
445
|
+
else if (merged.conflict) log(`🔴 мост НЕ подключён: имя сервера "${merged.conflict}" уже занято другим сервером инструментов`);
|
|
359
446
|
try {
|
|
447
|
+
let model = null;
|
|
448
|
+
let otdanoSobytij = 0;
|
|
449
|
+
// Откат возможен, только пока наружу не ушло ни одного события: после
|
|
450
|
+
// первого отданного куска переиграть ход уже нельзя, иначе платформа
|
|
451
|
+
// получит два начала одного ответа.
|
|
452
|
+
const otdat = (o) => { otdanoSobytij++; send(o); };
|
|
453
|
+
|
|
454
|
+
const progon = async (tekushchij) => {
|
|
360
455
|
const iter = query({
|
|
361
|
-
prompt:
|
|
456
|
+
prompt: tekushchij.prompt,
|
|
362
457
|
options: {
|
|
363
458
|
model: body.model || DEFAULT_MODEL,
|
|
364
|
-
//
|
|
365
|
-
//
|
|
459
|
+
// Цикл с инструментами: одного хода хватает только на разговор. Предел
|
|
460
|
+
// держим, чтобы заклинивший агент не крутился вечно, но с запасом.
|
|
366
461
|
maxTurns: Number(body.maxTurns) > 0 ? Number(body.maxTurns) : DEFAULT_MAX_TURNS,
|
|
367
462
|
permissionMode: 'bypassPermissions',
|
|
368
|
-
|
|
369
|
-
//
|
|
370
|
-
|
|
371
|
-
//
|
|
372
|
-
//
|
|
373
|
-
//
|
|
463
|
+
// Продолжаем свою же сессию, когда префикс сошёлся: тогда история
|
|
464
|
+
// едет тем же байтом и попадает в кэш подсказки целиком.
|
|
465
|
+
...(tekushchij.resume ? { resume: tekushchij.resume } : { sessionId }),
|
|
466
|
+
// 🔴 ВНЕШНИЕ СЕРВЕРЫ ИНСТРУМЕНТОВ ПОДКЛЮЧАЮТСЯ ЗДЕСЬ, А НЕ В ПЛАТФОРМЕ
|
|
467
|
+
// (19.08.2026, стоило часа). Инструменты исполняет движок, поэтому его
|
|
468
|
+
// набор — единственный, который агент видит. Плагин-клиент на стороне
|
|
469
|
+
// платформы подключается без ошибок, числится в составе — и до агента
|
|
470
|
+
// не доходит вовсе: платформа в этой схеме только шасси.
|
|
374
471
|
...(Object.keys(mcpAll).length ? { mcpServers: mcpAll } : {}),
|
|
375
472
|
...(body.cwd ? { cwd: body.cwd } : { cwd: WORK_DIR }),
|
|
376
473
|
...(body.system ? { systemPrompt: { type: 'preset', preset: 'claude_code', append: body.system } } : {}),
|
|
@@ -378,22 +475,21 @@ const server = http.createServer(async (req, res) => {
|
|
|
378
475
|
},
|
|
379
476
|
});
|
|
380
477
|
|
|
381
|
-
let model = null;
|
|
382
478
|
for await (const m of iter) {
|
|
383
479
|
if (m.type === 'assistant') {
|
|
384
480
|
model = m.message?.model ?? model;
|
|
385
481
|
for (const b of m.message?.content ?? []) {
|
|
386
|
-
if (b.type === 'text')
|
|
387
|
-
else if (b.type === 'thinking')
|
|
388
|
-
//
|
|
389
|
-
//
|
|
390
|
-
//
|
|
391
|
-
//
|
|
392
|
-
else if (b.type === 'tool_use')
|
|
482
|
+
if (b.type === 'text') otdat({ type: 'text', text: b.text });
|
|
483
|
+
else if (b.type === 'thinking') otdat({ type: 'thinking', text: b.thinking });
|
|
484
|
+
// Работу инструментами отдаём наружу СОБЫТИЕМ, а не молчанием: иначе
|
|
485
|
+
// долгий заход выглядит как зависший, и платформе нечего показать.
|
|
486
|
+
// Ввод инструмента не пересылаем целиком — там бывают секреты и
|
|
487
|
+
// мегабайты; только имя и короткая подпись.
|
|
488
|
+
else if (b.type === 'tool_use') otdat({ type: 'tool', name: b.name, brief: briefOf(b.input) });
|
|
393
489
|
}
|
|
394
490
|
const u = m.message?.usage;
|
|
395
491
|
if (u) {
|
|
396
|
-
|
|
492
|
+
otdat({
|
|
397
493
|
type: 'usage',
|
|
398
494
|
inputTokens: u.input_tokens ?? 0,
|
|
399
495
|
outputTokens: u.output_tokens ?? 0,
|
|
@@ -403,13 +499,28 @@ const server = http.createServer(async (req, res) => {
|
|
|
403
499
|
}
|
|
404
500
|
}
|
|
405
501
|
}
|
|
502
|
+
};
|
|
503
|
+
|
|
504
|
+
log(`подсказка: ${plan.pochemu}`);
|
|
505
|
+
try {
|
|
506
|
+
await progon(plan);
|
|
507
|
+
} catch (e) {
|
|
508
|
+
// Транскрипт SDK — кэш, а не источник правды. Его потеря (чистка,
|
|
509
|
+
// перезапуск, чужая рука) не должна стоить хода: переигрываем полной
|
|
510
|
+
// историей и говорим об этом вслух.
|
|
511
|
+
if (!plan.resume || otdanoSobytij > 0) throw e;
|
|
512
|
+
log(`🔴 продолжение сессии ${plan.resume} не удалось (${String(e?.message ?? e).slice(0, 140)}) — повторяю полной историей`);
|
|
513
|
+
SESSII.delete(plan.kluch);
|
|
514
|
+
plan = planZaprosa(body.messages);
|
|
515
|
+
await progon(plan);
|
|
516
|
+
}
|
|
517
|
+
zapomnit(plan, plan.resume || sessionId, body.messages);
|
|
406
518
|
send({ type: 'done', model, tookMs: Date.now() - started });
|
|
407
519
|
} catch (e) {
|
|
408
|
-
//
|
|
409
|
-
//
|
|
410
|
-
// starts from nothing.
|
|
520
|
+
// Ошибку отдаём В ПОТОКЕ, а не молчанием: оборванный поток без причины
|
|
521
|
+
// читается как «модель замолчала», и разбираться приходится с нуля.
|
|
411
522
|
const message = explainExit(e, sessionId, cwd);
|
|
412
|
-
log(`🔴
|
|
523
|
+
log(`🔴 сбой вызова: ${message}`);
|
|
413
524
|
send({ type: 'error', message: message.slice(0, 500) });
|
|
414
525
|
} finally {
|
|
415
526
|
res.end();
|
|
@@ -417,13 +528,13 @@ const server = http.createServer(async (req, res) => {
|
|
|
417
528
|
});
|
|
418
529
|
|
|
419
530
|
server.listen(PORT, HOST, () => {
|
|
420
|
-
log(
|
|
421
|
-
if (!readToken()) log('🔴
|
|
531
|
+
log(`слушаю ${HOST}:${PORT}; токен из ${TOKEN_FILE}; движок — официальный SDK`);
|
|
532
|
+
if (!readToken()) log('🔴 предупреждение: токен сейчас НЕ читается, запросы будут отклоняться');
|
|
422
533
|
});
|
|
423
534
|
|
|
424
535
|
for (const sig of ['SIGTERM', 'SIGINT']) {
|
|
425
536
|
process.on(sig, () => {
|
|
426
|
-
log(
|
|
537
|
+
log(`получен ${sig}, закрываюсь`);
|
|
427
538
|
server.close(() => process.exit(0));
|
|
428
539
|
setTimeout(() => process.exit(0), 5000).unref();
|
|
429
540
|
});
|
package/jsonschema-to-zod.mjs
CHANGED
|
@@ -1,47 +1,44 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* JSON Schema → zod raw shape.
|
|
3
|
-
*
|
|
2
|
+
* JSON Schema → zod raw shape. Минимальное подмножество: ровно то, что
|
|
3
|
+
* порождает zod.toJSONSchema на схемах платформы.
|
|
4
4
|
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
* from it.
|
|
5
|
+
* ЗАЧЕМ. tool() из SDK требует zod, а через провод zod не передать. Платформа
|
|
6
|
+
* отдаёт схему как JSON Schema, шлюз собирает из неё zod обратно.
|
|
8
7
|
*
|
|
9
|
-
* 🔴
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
8
|
+
* 🔴 ГРАНИЦА. Незнакомый тип НЕ превращается молча в «что угодно»: такая
|
|
9
|
+
* подмена дала бы модели инструмент без формы параметров, и отказ выглядел бы
|
|
10
|
+
* как работа. Незнакомый тип — исключение, инструмент не выставляется, шлюз
|
|
11
|
+
* пишет причину.
|
|
13
12
|
*/
|
|
14
13
|
import { z } from 'zod'
|
|
15
14
|
|
|
16
15
|
const node = (s, path) => {
|
|
17
|
-
if (!s || typeof s !== 'object') throw new Error(`${path}:
|
|
16
|
+
if (!s || typeof s !== 'object') throw new Error(`${path}: пустая схема`)
|
|
18
17
|
if (Array.isArray(s.enum)) {
|
|
19
|
-
if (!s.enum.every((v) => typeof v === 'string')) throw new Error(`${path}:
|
|
18
|
+
if (!s.enum.every((v) => typeof v === 'string')) throw new Error(`${path}: перечень не из строк`)
|
|
20
19
|
return z.enum(s.enum)
|
|
21
20
|
}
|
|
22
|
-
//
|
|
23
|
-
//
|
|
24
|
-
//
|
|
25
|
-
//
|
|
26
|
-
//
|
|
27
|
-
//
|
|
28
|
-
//
|
|
29
|
-
//
|
|
30
|
-
//
|
|
31
|
-
//
|
|
32
|
-
//
|
|
33
|
-
//
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
if (branches.length < 2) throw new Error(`${path}: ${kw} of ${branches.length} branch is not a choice`)
|
|
40
|
-
return z.union(branches.map((v, i) => node(v, `${path}|${kw}[${i}]`)))
|
|
21
|
+
// Брендированные строки платформы (SessionId, GoalId) описаны пересечением
|
|
22
|
+
// «строка И неизвестное», и на проводе это allOf с пустым вторым членом.
|
|
23
|
+
// Берём единственный типизированный член: пустой схемы в zod нет, а
|
|
24
|
+
// потерять тип нельзя.
|
|
25
|
+
// Разветвление формы: у платформы так описан параметр, принимающий либо
|
|
26
|
+
// строку, либо объект (schedule_create.at). Без этой ветки конвертер не
|
|
27
|
+
// понимал бы узел БЕЗ поля type и отказывал — а шлюз молча не выставлял бы
|
|
28
|
+
// инструмент целиком. Отказ был бы громким в журнале шлюза и невидимым для
|
|
29
|
+
// модели: инструмент просто отсутствует.
|
|
30
|
+
// ГРАНИЦА: ветвей обязано быть не меньше двух, и каждая обязана собираться
|
|
31
|
+
// сама. Одна ветвь — это не выбор, а описка; несобираемая ветвь — потеря
|
|
32
|
+
// формы, то есть ровно то, от чего защищает весь этот файл.
|
|
33
|
+
const vetvi = Array.isArray(s.oneOf) ? s.oneOf : Array.isArray(s.anyOf) ? s.anyOf : undefined
|
|
34
|
+
if (vetvi) {
|
|
35
|
+
const kak = Array.isArray(s.oneOf) ? 'oneOf' : 'anyOf'
|
|
36
|
+
if (vetvi.length < 2) throw new Error(`${path}: ${kak} из ${vetvi.length} ветви — это не выбор`)
|
|
37
|
+
return z.union(vetvi.map((v, i) => node(v, `${path}|${kak}[${i}]`)))
|
|
41
38
|
}
|
|
42
39
|
if (Array.isArray(s.allOf)) {
|
|
43
40
|
const typed = s.allOf.filter((m) => m && typeof m === 'object' && m.type !== undefined)
|
|
44
|
-
if (typed.length !== 1) throw new Error(`${path}: allOf
|
|
41
|
+
if (typed.length !== 1) throw new Error(`${path}: allOf из ${typed.length} типизированных членов не поддержан`)
|
|
45
42
|
return node(typed[0], path)
|
|
46
43
|
}
|
|
47
44
|
switch (s.type) {
|
|
@@ -51,15 +48,14 @@ const node = (s, path) => {
|
|
|
51
48
|
case 'boolean': return z.boolean()
|
|
52
49
|
case 'array': return bounds(z.array(node(s.items, `${path}[]`)), s, 'length')
|
|
53
50
|
case 'object': return z.object(shape(s, path))
|
|
54
|
-
default: throw new Error(`${path}:
|
|
51
|
+
default: throw new Error(`${path}: тип ${JSON.stringify(s.type)} не поддержан`)
|
|
55
52
|
}
|
|
56
53
|
}
|
|
57
54
|
|
|
58
55
|
/**
|
|
59
|
-
*
|
|
60
|
-
*
|
|
61
|
-
*
|
|
62
|
-
* reason will be far from obvious.
|
|
56
|
+
* Пределы значения и длины. Без них схема ТИХО слабеет: положительное число
|
|
57
|
+
* превращается в любое, и модель видит не тот договор, который проверит
|
|
58
|
+
* платформа. Отказ она получит уже на исполнении, а причина будет неочевидна.
|
|
63
59
|
*/
|
|
64
60
|
const bounds = (t, s, kind) => {
|
|
65
61
|
if (kind === 'value') {
|
|
@@ -77,16 +73,15 @@ const bounds = (t, s, kind) => {
|
|
|
77
73
|
}
|
|
78
74
|
|
|
79
75
|
export const shape = (schema, path = '$') => {
|
|
80
|
-
if (schema?.type !== 'object') throw new Error(`${path}:
|
|
76
|
+
if (schema?.type !== 'object') throw new Error(`${path}: ожидался объект`)
|
|
81
77
|
const required = new Set(schema.required ?? [])
|
|
82
78
|
const out = {}
|
|
83
79
|
for (const [key, sub] of Object.entries(schema.properties ?? {})) {
|
|
84
80
|
let t = node(sub, `${path}.${key}`)
|
|
85
|
-
//
|
|
86
|
-
//
|
|
87
|
-
//
|
|
88
|
-
//
|
|
89
|
-
// tools/list against a stub.
|
|
81
|
+
// ПОРЯДОК ЗНАЧИМ. Сначала optional(), потом describe(): конвертер SDK
|
|
82
|
+
// читает описание с ВНЕШНЕГО узла, и при describe().optional() описание
|
|
83
|
+
// параметра МОЛЧА теряется — схема остаётся годной, а модель не видит
|
|
84
|
+
// пояснения к необязательному полю. Проверено tools/list на пустышке.
|
|
90
85
|
if (!required.has(key)) t = t.optional()
|
|
91
86
|
if (typeof sub.description === 'string') t = t.describe(sub.description)
|
|
92
87
|
out[key] = t
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "subscription-gateway",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.2",
|
|
4
4
|
"description": "Subscription gateway for DeepSeek Harness — runs the model loop through the vendor's official agent SDK, so the platform works on a subscription seat instead of a metered API key, with the token kept in one place on the machine.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "gateway.mjs",
|
|
@@ -10,7 +10,8 @@
|
|
|
10
10
|
"schedule.json",
|
|
11
11
|
"test-schema-adapter.mjs",
|
|
12
12
|
"systemd",
|
|
13
|
-
"README.md"
|
|
13
|
+
"README.md",
|
|
14
|
+
"README.ru.md"
|
|
14
15
|
],
|
|
15
16
|
"keywords": [
|
|
16
17
|
"deepseek-harness",
|
package/test-schema-adapter.mjs
CHANGED
|
@@ -1,7 +1,21 @@
|
|
|
1
1
|
// A bench for the schema adapter, taken FROM THE PACKAGE FILE. It checks exactly
|
|
2
2
|
// what the adapter was fixed for: shapes that occur in the platform's schemas and
|
|
3
3
|
// that the first version did not know — it lost them SILENTLY, returning z.any().
|
|
4
|
-
|
|
4
|
+
// 🔴 ТРИ ИСХОДА, А НЕ ДВА: 0 сошлось · 1 расхождение · 2 проверить нечем.
|
|
5
|
+
// Прежняя редакция знала два и при отсутствующей зависимости падала голым
|
|
6
|
+
// стектрейсом с кодом 1 — «предмет расходится» при исправном предмете. У того,
|
|
7
|
+
// кто ТОЛЬКО ЧТО поставил пакет, зависимостей ещё нет, и первое, что он увидит,
|
|
8
|
+
// было бы ложным обвинением коду. Класс лечён в двух других наших пакетах;
|
|
9
|
+
// здесь остался непройденным, пока не собрали третий (01.09.2026).
|
|
10
|
+
let shape
|
|
11
|
+
try {
|
|
12
|
+
({ shape } = await import('./jsonschema-to-zod.mjs'))
|
|
13
|
+
} catch (e) {
|
|
14
|
+
console.log(`СЛЕПОТА: адаптер не загрузился: ${String(e?.message ?? e).slice(0, 120)}`)
|
|
15
|
+
console.log(' Это не расхождение предмета: скорее всего не поставлена зависимость zod'
|
|
16
|
+
+ ' (`npm install` в каталоге пакета) — поставьте и повторите.')
|
|
17
|
+
process.exit(2)
|
|
18
|
+
}
|
|
5
19
|
import { readFileSync } from 'node:fs'
|
|
6
20
|
import { fileURLToPath } from 'node:url'
|
|
7
21
|
import { dirname } from 'node:path'
|
|
@@ -11,6 +25,17 @@ const t = (name, cond, got) => {
|
|
|
11
25
|
if (cond) { ok++; console.log(` ok ${name}`) }
|
|
12
26
|
else { bad++; console.log(` FAIL ${name}${got === undefined ? '' : ` — got ${JSON.stringify(got)}`}`) }
|
|
13
27
|
}
|
|
28
|
+
// 🔴 Исключение ВНУТРИ предмета — это расхождение, а не поломка стенда. Без этой
|
|
29
|
+
// обёртки стенд падал голым стектрейсом: код был верный (1), но снаружи не отличить
|
|
30
|
+
// «предмет плох» от «стенд сломан». Проверено порчей предмета 01.09.2026.
|
|
31
|
+
// An exception INSIDE the subject is a discrepancy, not a broken bench: without this
|
|
32
|
+
// wrapper the bench died with a raw stack trace and the two cases looked alike.
|
|
33
|
+
const blok = (name, fn) => {
|
|
34
|
+
try { fn() } catch (e) {
|
|
35
|
+
bad++
|
|
36
|
+
console.log(` FAIL ${name}: предмет бросил исключение / the subject threw: ${String(e?.message ?? e).slice(0, 160)}`)
|
|
37
|
+
}
|
|
38
|
+
}
|
|
14
39
|
const passes = (z, v) => { const r = z.safeParse(v); return r.success }
|
|
15
40
|
|
|
16
41
|
console.log('\n=== A. Real platform schedule schemas ===')
|
|
@@ -20,12 +45,14 @@ console.log('\n=== A. Real platform schedule schemas ===')
|
|
|
20
45
|
const SCHEDULE_FILE = process.argv[2] ?? `${dirname(fileURLToPath(import.meta.url))}/schedule.json`
|
|
21
46
|
const SCHEDULE = JSON.parse(readFileSync(SCHEDULE_FILE, 'utf8'))
|
|
22
47
|
for (const tool of SCHEDULE) {
|
|
23
|
-
|
|
24
|
-
|
|
48
|
+
blok(`${tool.name}: the schema assembled`, () => {
|
|
49
|
+
const s = shape(tool.parameters)
|
|
50
|
+
t(`${tool.name}: the schema assembled, fields ${Object.keys(s).length}`, Object.keys(s).length > 0 || tool.name === 'schedule_list')
|
|
51
|
+
})
|
|
25
52
|
}
|
|
26
53
|
|
|
27
54
|
console.log('\n=== B. The oneOf shape — the reason the adapter was fixed ===')
|
|
28
|
-
{
|
|
55
|
+
blok('B: the oneOf shape', () => {
|
|
29
56
|
// at: a string OR an object — exactly what the platform hands over
|
|
30
57
|
const s = shape({
|
|
31
58
|
type: 'object',
|
|
@@ -34,10 +61,10 @@ console.log('\n=== B. The oneOf shape — the reason the adapter was fixed ===')
|
|
|
34
61
|
t('the string branch passes', passes(s.at, '2026-08-23T10:00:00Z'))
|
|
35
62
|
t('the object branch passes', passes(s.at, { date: '2026-08-23' }))
|
|
36
63
|
t('a foreign type does NOT pass (it did not degenerate into any)', !passes(s.at, 42), s.at?._def?.typeName)
|
|
37
|
-
}
|
|
64
|
+
})
|
|
38
65
|
|
|
39
66
|
console.log('\n=== C. Required and optional ===')
|
|
40
|
-
{
|
|
67
|
+
blok('C: required and optional', () => {
|
|
41
68
|
const s = shape({
|
|
42
69
|
type: 'object',
|
|
43
70
|
properties: { prompt: { type: 'string' }, after_seconds: { type: 'number' } },
|
|
@@ -46,16 +73,26 @@ console.log('\n=== C. Required and optional ===')
|
|
|
46
73
|
t('required with no value — refused', !passes(s.prompt, undefined))
|
|
47
74
|
t('optional with no value — passes', passes(s.after_seconds, undefined))
|
|
48
75
|
t('optional with a value — passes', passes(s.after_seconds, 300))
|
|
49
|
-
}
|
|
76
|
+
})
|
|
50
77
|
|
|
51
78
|
console.log('\n=== D. Descriptions reach the model ===')
|
|
52
|
-
{
|
|
79
|
+
blok('D: descriptions', () => {
|
|
53
80
|
// 🔴 This literal appears twice on purpose — as the input and as the expected
|
|
54
81
|
// output. Change one without the other and the check goes red on healthy code.
|
|
55
82
|
const DESCRIPTION = 'the exact identifier'
|
|
56
83
|
const s = shape({ type: 'object', properties: { id: { type: 'string', description: DESCRIPTION } }, required: ['id'] })
|
|
57
84
|
t('the field description was carried over', s.id?.description === DESCRIPTION, s.id?.description)
|
|
58
|
-
}
|
|
85
|
+
})
|
|
59
86
|
|
|
60
87
|
console.log(`\nTOTAL: passed ${ok}, failed ${bad}`)
|
|
61
|
-
|
|
88
|
+
// 🔴 Канарейка точного числа: вырезанный раздел иначе пройдёт молча, и стенд
|
|
89
|
+
// будет зелен, проверив меньше. Число считается из предмета — по числу схем в
|
|
90
|
+
// файле платформы плюс семь постоянных случаев (B=3, C=3, D=1).
|
|
91
|
+
// Exact-count canary: a cut-out section would otherwise pass in silence.
|
|
92
|
+
const ZHDYOM = SCHEDULE.length + 7
|
|
93
|
+
if (ok + bad !== ZHDYOM) {
|
|
94
|
+
console.log(`СЛЕПОТА: проверок ${ok + bad}, а стенд состоит из ${ZHDYOM}`
|
|
95
|
+
+ ' — раздел не исполнился / a section did not run')
|
|
96
|
+
process.exit(2)
|
|
97
|
+
}
|
|
98
|
+
process.exit(bad ? 1 : 0) // слепота выходит раньше, кодом 2
|