Compare commits

...

65 Commits

Author SHA1 Message Date
Codex a9fe5f44e3 feat(deploy): register L1 credential provenance successor 2026-07-24 17:40:34 +03:00
Codex c19b0789c7 feat(deploy): register L1 credential reuse transition 2026-07-24 17:20:01 +03:00
Codex 34769af350 feat(deploy): register normalized identity transition 2026-07-24 12:04:40 +03:00
Codex 659f98ad8f chore(deploy): add unit identity rollout artifacts 2026-07-24 11:43:26 +03:00
Codex abebb9fac6 feat(provider): publish full unit identity product 2026-07-24 11:42:41 +03:00
Codex 8cc917508e feat(deploy): package classified subject aspect transition 2026-07-24 09:32:02 +03:00
Codex eab47bc1fa feat(provider-contract): add classified unit contact aspect 2026-07-24 09:31:34 +03:00
Codex f02a1d4125 feat(deploy): register execution plan module ownership transition 2026-07-23 23:27:36 +03:00
Codex 73364b4acb feat(provider-contract): add Gelios v9 telemetry projection 2026-07-23 23:27:31 +03:00
Codex 59f9fc2b26 Register Engine telemetry runtime deploy successor 2026-07-23 22:31:01 +03:00
Codex 25b12d77e4 feat(deploy): register Engine MCP materialization transition 2026-07-23 20:37:31 +03:00
Codex 4cf3a83ef1 feat(provider-contract): define materializable L2 graph blueprints 2026-07-23 20:22:22 +03:00
Codex a5a5644dbf feat(provider-contract): compile governed L2 execution plans 2026-07-23 19:27:01 +03:00
Codex 6f9da1b677 fix(deploy): attest Engine telemetry gateway transition 2026-07-23 16:24:52 +03:00
Codex f97a9d924d feat(deploy): register Engine telemetry catalog transition 2026-07-23 14:55:56 +03:00
Codex e4383b6162 feat(deploy): register Engine MCP profile decoder transition 2026-07-23 14:38:12 +03:00
Codex 50cba59bde Add exact Engine L2 reconciliation transition 2026-07-23 13:10:11 +03:00
Codex ad3760e767 Add reproducible Engine L2 deployment artifact 2026-07-23 12:25:05 +03:00
Codex 2a30e1e991 test(deploy): pin Foundry unit profile policy artifact 2026-07-23 09:44:02 +03:00
Codex ec45d09509 fix(deploy): derive validation descriptor from current lineage 2026-07-23 09:07:06 +03:00
Codex 9db0de540d fix(deploy): transition node intelligence source atomically 2026-07-23 08:58:47 +03:00
Codex 6c764d0d28 fix(deploy): route private validation slice canonically 2026-07-23 08:51:19 +03:00
Codex 93eb13219a feat(deploy): promote private node reconciliation 2026-07-23 08:43:36 +03:00
Codex 23919e384e feat(deploy): promote publish-path provider authority 2026-07-23 08:16:11 +03:00
Codex 4cdc78d545 feat(deploy): add Gelios profile authority transition 2026-07-23 07:48:17 +03:00
Codex e843bbda66 feat(provider): add Gelios unit profile product 2026-07-22 23:36:57 +03:00
Codex 2def90fa33 fix(deploy): include provider capability catalog 2026-07-22 19:16:26 +03:00
Codex 92070f21e8 feat(provider): catalogue complete Gelios data surface 2026-07-22 19:15:12 +03:00
Codex fdda153595 feat(deploy): seal Gelios telemetry authority transition 2026-07-22 13:06:32 +03:00
Codex 4402c9ed25 feat(data-plane): add bounded Gelios telemetry contract 2026-07-22 11:47:24 +03:00
Codex a9b8d71968 fix(data-plane): accept exact replacement replays 2026-07-21 23:11:35 +03:00
Codex 45884cd4dc test(deploy): seal zone v2 consumer policy artifact 2026-07-21 15:21:00 +03:00
Codex 358b259aac fix(n8n): make replacement replay idempotent 2026-07-21 15:06:59 +03:00
Codex bc23c550db fix(deploy): render new authority paths in plan 2026-07-21 14:27:37 +03:00
Codex 77bf8036d2 feat(deploy): register Depttrans zone authority 2026-07-21 14:20:06 +03:00
Codex 9bebd2f504 feat(deploy): register n8n publisher replace v2 2026-07-21 13:39:15 +03:00
Codex 92d292ce87 fix(data-plane): preserve immutable zone product v1 2026-07-21 12:48:49 +03:00
Codex 343812b90d feat(map): ingest audited department zone snapshot 2026-07-21 12:02:11 +03:00
Codex 3446f3edc2 feat(platform): add replaceable geozone data layer 2026-07-20 21:48:27 +03:00
Codex 8a7465cf0e feat(platform): complete the Gelios external data loop 2026-07-20 20:45:05 +03:00
Codex def9a24e0d feat(edp): provision Foundry reader grants 2026-07-19 15:03:48 +03:00
Codex 847a08da93 feat(ops): add workspace MCP deployment overlays 2026-07-19 12:34:06 +03:00
Codex 56b766b23b chore(deploy): preserve Engine auth recovery builders 2026-07-19 12:34:00 +03:00
Codex d85912b9d7 feat(deploy): add Engine ontology SDK transition 2026-07-19 12:33:56 +03:00
Codex f67c49bc4d feat(provider): add Gelios SDK package v2 2026-07-19 12:33:50 +03:00
Codex 95446bdc24 fix(edp): resolve managed reader source scope 2026-07-19 12:28:00 +03:00
Codex 0611a88971 feat(n8n): add rotating provider access credential 2026-07-19 10:11:56 +03:00
Codex 3c5d8f6cef feat(platform): add managed data product history plane 2026-07-18 14:38:06 +03:00
Codex 02816c4352 feat(deploy): add engine node intelligence transition 2026-07-17 21:50:50 +03:00
Codex 31e078d6e5 docs(platform): align connector, EDP, and deploy canon 2026-07-17 18:09:55 +03:00
Codex 2dd6e33a54 feat(deploy): extend canon for managed EDP and Engine grants 2026-07-17 18:09:39 +03:00
Codex a0a4d36fa2 feat(data-plane): add signed managed writer bindings 2026-07-17 18:09:15 +03:00
Codex 3415674e76 feat(connectors): version provider packages and ontology contracts 2026-07-17 18:08:36 +03:00
Codex 567f1550ab feat(deploy): register managed platform runtime components 2026-07-16 02:25:31 +03:00
Codex fedaf24098 chore(proxy-contur): preserve legacy Contour compatibility service 2026-07-16 02:24:31 +03:00
Codex 7b55d887bc feat(map): add cache-first Cesium gateway and AMD egress 2026-07-16 02:23:56 +03:00
Codex 59e9c92415 feat(engine): add private NDC nodes and ontology bridge 2026-07-16 02:23:45 +03:00
Codex 569b8762e6 feat(data-plane): add provider contracts and ontology delivery 2026-07-16 02:23:34 +03:00
Codex e527812826 feat(map): add persistent gateway and ontology package 2026-07-13 17:14:34 +03:00
Codex d196d4b0c7 Narrow DC CMS source deploy runtime 2026-07-06 11:58:52 +03:00
Codex 7ef0ca8eab Add DC CMS site workspace deploy component 2026-07-06 11:55:08 +03:00
Codex a31b679ab9 Print compose logs on deploy failure 2026-07-06 10:44:36 +03:00
Codex 7460258228 Wait for DC CMS auth bootstrap in deploy runner 2026-07-06 10:32:24 +03:00
Codex 099796a61d Register DC CMS in deploy runner 2026-07-06 10:09:46 +03:00
Codex a7c6c34c82 feat(ai-workspace): support SEO codex provider bridge 2026-07-03 08:43:37 +03:00
371 changed files with 158826 additions and 180 deletions

3
.gitignore vendored
View File

@ -16,6 +16,9 @@ build/
.next/
coverage/
# canonical deploy packages are transferred through the NAS inbox, not Git
infra/deploy-artifacts/
# logs
*.log
logs/

View File

@ -46,3 +46,7 @@ Notification Core живёт в `services/notification-core` как отдель
AI Workspace Assistant живёт в `services/ai-workspace-assistant` как общий платформенный слой для пользовательских Codex executors, selected executor, shared conversations, surfaces и tool packs. AI Workspace Hub остаётся отдельным тонким транспортом для remote Codex workers и не хранит смысловое состояние ассистента.
Ontology Core живёт в `services/ontology-core` как docs-first семантический слой для canonical entities, relations, aliases, guardrails, evidence, первого resolver MVP между OPS и ENGINE и policy MVP для NDC Core Assistant access. Он не владеет доменными БД HUB/OPS/ENGINE и не заменяет Launcher/HUB roles или OPS Gateway enforcement.
Map Gateway живёт в `services/map-gateway` как общий platform boundary для provider credentials, безопасного asset endpoint exchange, tile/3D Tiles proxy и persistent offline TileCache. Runtime cache не является source artifact и хранится в named volume/object storage, а не в Git.
Внешние бизнес-поставщики подключаются по `packages/external-provider-contract`: adapter конкретного API принадлежит изолированному NDC L2 workflow, а Platform даёт один provider-neutral External Data Plane для raw retention, canonical facts, current/history projections и scoped read products. Connection instance несёт tenant scope, credential reference и collection profile, но не secret value. Provider/domain mapping, entity filtering и renderer logic не попадают в Platform Data Plane. Provider-issued credentials сохраняются в native NDC L2 Credentials; внутренний scoped publish grant генерируется и сохраняется generic control plane внутри native credential boundary, EDP получает только digest, а MCP видит только opaque reference/status. Legacy plaintext provisioning и managed digest-only ensure включаются независимо и по умолчанию закрыты. Подробный канон — `docs/ADR_L2_OWNED_EXTERNAL_CONNECTORS.md`.

View File

@ -0,0 +1,143 @@
# ADR: канон External Provider Data Plane
Статус: **superseded**.
Дата: 2026-07-13.
Владелец решения: NODE.DC Platform.
> Заменено 2026-07-14 документом
> [`ADR_L2_OWNED_EXTERNAL_CONNECTORS.md`](ADR_L2_OWNED_EXTERNAL_CONNECTORS.md).
> Этот черновик неверно помещал provider adapter, normalisation и collection
> policy в `services/<provider>-gateway`. Новое правило: adapter принадлежит
> изолированному L2 workflow; Platform Data Plane остаётся provider-neutral.
## Контекст
Клиент может подключить к NODE.DC любой внешний продукт: телеметрию, ERP,
роботов, энергетику, BIM-систему или иной источник данных. Gelios Pro для
Gelios — первый конкретный поставщик для проверки канона, а не исключительная
архитектурная ветка. Нельзя превращать Engine workflow, Ontology Core или общую БД Platform
в место, куда попадают токены, raw payloads и частная логика каждого API.
Нужна повторяемая форма, в которой новый provider добавляется как отдельный
adapter, но получает общие правила scope, секретов, collection, хранения,
read-model, аудита и безопасной публикации в NDC.
## Решение
1. В `platform/packages/external-provider-contract` живёт общий versioned
контракт интеграции. Это не runtime и не база данных. Он задаёт форму
`provider`, `connection`, `capability catalog`, `credential reference`,
`access scope`, `field policy`, `collection profile`, `retention policy`,
`read model` и красный command-domain.
2. Каждый provider получает самостоятельный app-owned adapter в
`platform/services/<provider>-gateway`. Первый экземпляр —
`platform/services/gelios-gateway`. Adapter владеет intake contract, rate
budget, normalisation, collection policy, storage и своим internal
read/realtime API. Сам provider secret остаётся в Engine Credentials и
доступен только назначенному защищённому execution workflow.
3. Один provider service может обслуживать много клиентов. Каждая строка,
cursor, audit-event и read-model обязана иметь `tenant_id` и
`connection_id`; видимость provider account сама по себе не является
продуктовым scope. Для клиента с отдельными требованиями изоляции допустим
отдельный deployment/database profile без изменения контракта.
4. База принадлежит adapter-сервису, а не Ontology Core, Engine, Tasker или
общему Platform Postgres. Для пространственно-временного Gelios-кейса
базой служит PostgreSQL 16 + TimescaleDB + PostGIS (`gelios-postgres`).
Другой provider может выбрать иной storage engine только через явный ADR,
сохранив внешний контракт.
5. Ontology Core хранит только provider-neutral и provider-specific смыслы,
связи, правила и контракты. Он не хранит credentials, runtime telemetry,
customer raw payloads или renderer objects. Enforcement остаётся в
gateway/adapters. Engine Credentials хранит provider secret в границе
специально назначенного execution workflow; значение не сериализуется в
граф, ontology, логи, read-model или UI.
6. Engine L2 Collector использует credential reference и получает разрешённые
данные поставщика. Он передаёт в adapter только аутентифицированный нормализованный
intake payload. Остальные L2 workflow получают исключительно scoped
internal API/event contract и не читают gateway DB напрямую.
## Каноническая форма нового подключения
```text
Client / tenant
-> Engine Credential + protected Collector workflow
-> provider connection instance
-> provider adapter (safe intake policy + normalizer)
-> provider-owned storage and projections
-> internal read/realtime contract
-> L2 workflow / approved interface binding
-> renderer adapter
```
Каждый новый provider добавляет только свой adapter package, capability
catalog, schema mappings, scrubbed fixtures и domain ontology package. Он не
добавляет отдельную схему доступа к Engine/Studio и не создаёт прямой путь из
browser в provider API.
## Полнота данных без неконтролируемого объёма
«Предусмотреть все данные» означает каталогизировать каждую provider
capability и поле, а не опрашивать весь account на максимальной частоте.
Collection profile явно решает, какие safe-read capabilities, поля, scope и
частота включены в конкретной connection instance.
| Слой | Что хранится | Режим |
| --- | --- | --- |
| Capability catalog | documented endpoint/read-field/command capability и его риск | versioned source + ontology |
| Inventory/configuration | units, devices, groups, sensors, custom definitions | медленный reconcile |
| Current projection | последняя разрешённая позиция, состояние и display fields | idempotent upsert |
| Event history | нормализованные события и approved measurements | append-only, partitioned |
| Raw envelope | полный safe-read ответ с provenance и hash | restricted cold layer, retention-bound |
| Aggregates/features | rollups и признаки для аналитики/предиктива | derived, replaceable |
Raw envelope не выдаётся UI и не становится таблицей «всё в JSON навсегда».
Вначале он может быть compressed/partitioned storage с метаданными в БД; при
реальном объёме переносится в object storage, а PostgreSQL хранит immutable
index, hash, policy и ссылку. Retention, raw depth и downsampling утверждаются
после замера сообщений/сек, размера payload, требуемой истории, RPO/RTO и
стоимости. Так инженер может запросить ранее не показанное поле из
каталога/архива, не раздувая горячую read-модель.
## Realtime и интерфейс
Частота provider collection, обновления `current projection` и выдачи в UI —
три разные настройки. Например, map consumer может получать выбранную
read-модель раз в 3 секунды, но это не даёт ему права опрашивать Gelios раз в
3 секунды или создавать отдельный polling loop на каждого зрителя.
Gateway сначала обновляет одну current projection и публикует change event.
L2/Map binding затем может sampling/throttle этот поток по утверждённой
настройке интерфейса. Источник истины для live state — gateway storage, не
долгоживущий workflow и не Cesium session.
## Commands: моделируются, но не подключаются
Command templates, параметры, delivery states и audit входят в capability
catalog и ontology полностью. Read adapter не содержит send route и не
использует command capability. В будущем command execution создаётся только
как отдельный `provider-command-gateway`/red-domain deployment с явным
человеческим подтверждением, role/scope check, idempotency, audit и отдельным
security review. До этого команда не может быть отправлена из collector, L2,
Map или AI Workspace.
## Обязательные артефакты каждого adapter
- `provider manifest`: provider id, adapter version, auth modes, rate limits;
- capability and field catalog: read/write classification, source evidence,
pagination and error semantics;
- connection profile: tenant, secret reference, approved scope, field policy,
collection and retention profile;
- normalised contract and migrations; scrubbed fixtures and contract tests;
- health/metrics/audit without secrets or raw personal data;
- ontology package with stable subjects, relations and guardrails;
- internal read/realtime API contract; no browser/provider bypass.
## Не решено этим ADR
- конкретный deployment topology и HA/PITR target для каждого volume;
- выбор object storage после real-volume measurement;
- L2 stream execution contract и Module Studio binding implementation;
- параметры first Gelios collection profile и owner-approved connection scope.
Gelios-specific применение этого решения описано в
`docs/ADR_GELIOS_DATA_PLANE.md`.

View File

@ -0,0 +1,107 @@
# ADR: Gelios adapter в External Provider Data Plane
Статус: **superseded**.
Дата: 2026-07-13.
Владелец решения: NODE.DC Platform.
> Заменено 2026-07-14 документом
> [`ADR_L2_OWNED_EXTERNAL_CONNECTORS.md`](ADR_L2_OWNED_EXTERNAL_CONNECTORS.md).
> Gelios остаётся domain/ontology примером, но не получает отдельный
> provider-owned gateway: fetch, mapping и collection policy живут в его L2
> connector instance; Platform предоставляет нейтральный Data Plane.
## Контекст
Gelios Pro поставляет непрерывную телеметрию, конфигурацию устройств и пространственные данные. В текущем доступе подтверждены 107 видимых units, из них 105 с `lastMsg`; окончательный connection scope должен быть зафиксирован allowlist-ом владельца. Это не данные Ontology Core и не данные Tasker. Они требуют отдельного контура для текущего состояния, истории, геозапросов, аналитики и будущих прогнозов.
Gelios является первым provider adapter, который подчиняется общему
`docs/ADR_EXTERNAL_PROVIDER_DATA_PLANE.md`: connection instance хранит
connection scope/policy, Engine Credentials владеет provider secret, gateway
владеет storage/read-моделью, а ontology описывает значения, но не runtime
data.
Физические trike-команды существуют в домене, но **не входят в ingestion или тестирование**. Для них позднее потребуется отдельный, ручной и аудируемый контур.
## Решение
1. Создать отдельный сервис `platform/services/gelios-gateway` как storage/read gateway:
- не хранит и не получает provider token;
- принимает только аутентифицированный safe-read intake от защищённого Engine L2 Collector workflow;
- применяет allowlist и field policy до записи;
- нормализует ответ в сущности пакета `gelios`;
- публикует read-модель для Engine L2 workflow и Map View.
2. В Engine появляется отдельный Gelios Credential и NDC Agent L2 collection profile:
- credential скрывает access/refresh pair и его lifecycle;
- credential привязан только к намеренно пошаренному Collector workflow;
- Collector содержит allowlist read-capabilities и не имеет command transport;
- ни секрет, ни provider response без нормализации не передаются в ontology, UI, Gateway или другие workflow.
3. Выделить сервису собственную БД `gelios-postgres`, не деля её с Tasker, Authentik, Notification Core или Ontology Core. В будущей multi-tenant форме эта БД обслуживает несколько Gelios connection instances, но все records разделены `tenant_id` и `connection_id`.
4. Базовый движок: PostgreSQL 16 с расширениями TimescaleDB и PostGIS.
- Timescale hypertable хранит временные ряды и автоматически делит их по времени.
- PostGIS хранит нормализованные точки и геометрии зон, а не renderer-объекты Cesium.
- Данный выбор покрывает транзакционную конфигурацию, realtime upsert, исторические запросы, SQL-аналитику и географию одним контуром.
5. **Не** использовать RabbitMQ Tasker как общую шину Gelios. Если измерения покажут, что прямой writer или число независимых потребителей не справляются, добавить в Gelios-контур NATS JetStream с durable pull consumers. Он даст replay, acknowledgement и контролируемое удержание сообщений.
6. Engine level-2 Collector — единственная точка provider access; остальные workflow являются потребителями read-модели. Ontology Core остаётся только словарём и контрактами.
## Целевой поток
```text
Gelios REST (safe read only)
-> NDC Agent L2 collection profile + protected credential
-> authenticated normalized intake
-> Gelios Gateway: scope -> field policy -> normalizer
-> gelios-postgres: current state + immutable telemetry history
-> [при необходимости] NATS JetStream
-> `fleet.positions.current.v1` read API / realtime subscription
-> NDC workflow level 2
-> Map View semantic binding
-> Cesium renderer adapter
```
Ни один шаг не получает права отправить команду устройству. Красный command-domain находится вне этого потока; metadata каталога команд сохраняется, но send transport не создаётся.
## Модель хранения v0
| Слой | Назначение | Минимальные записи |
| --- | --- | --- |
| Контроль | граница и повторяемость сбора | `access_scope`, `collection_run`, `ingestion_cursor`, endpoint/response metrics |
| Каталог | стабильные сущности и их конфигурация | `unit`, `unit_group`, `tracker_device`, sensor/maintenance/custom-field definitions |
| Current state | одна актуальная запись на unit для карты и интерфейса | `unit_current`, `position_fix`, approved operational status |
| History | неизменяемые события с временем наблюдения и получения | `telemetry_snapshot`, selective `sensor_reading`, restricted raw payload reference |
| Spatial | геометрии для запросов, не Cesium graphics | point/track/geozone with SRID 4326 |
| Analytics | роллапы и признаки, не запросы по всему raw | hourly/daily aggregates, feature sets, model runs |
| Audit | попытки, ошибки, политика, будущие команды | collection audit; separate command audit later |
`unit_current` обновляется idempotently по `unitSubjectId`. История записывается append-only с ключом дедупликации, включающим provider unit id, observed time и fingerprint сообщения. Все временные таблицы имеют `observed_at` и `received_at`: задержка поставщика не должна переписывать фактическое время на карте.
## Индексы и жизненный цикл
- Основной путь истории: `(unit_subject_id, observed_at DESC)`.
- Пространственный индекс только для нормализованной geography/geometry; рендер-кэши в БД не храним.
- Сырые `params`/raw messages — restricted, отдельно от публичной Studio read-model.
- Политики retention, downsampling и резервного копирования должны быть утверждены до запуска history backfill. Они зависят от фактических msg/s, размера payload, нужной глубины истории, RPO/RTO и стоимости хранения.
- Для предиктива держать recent/raw слой и отдельные часовые/дневные агрегаты. Timescale continuous aggregates позволяют сохранять длительную агрегированную историю после сокращения raw при корректно согласованных refresh и retention политиках.
## Нулевая итерация без лишней инфраструктуры
1. Зафиксировать owner-approved allowlist и видимые поля.
2. Сделать только safe-read Engine Collector с ограничением по scope, rate limit, paging и cursor.
3. В течение согласованного окна измерить: сообщений/сек, размер ответа, lag, дубликаты, задержку записи и нагрузку запросов карты.
4. На фактах включить Timescale hypertables, PostGIS и retention policy; после этого решить, нужен ли JetStream сразу.
5. Подать только `fleet.positions.current.v1`/approved current position в Map View. Историю и raw не отдавать в renderer напрямую.
## Что не решено этим ADR
- окончательное правило connection scope;
- частота polling/возможность provider push;
- сроки хранения raw, нормализованной истории и агрегатов;
- RPO/RTO, репликация и production backup plan;
- допуск к ручному command gateway. До отдельного решения отправка команд запрещена.
## Обоснование и источники
- [Timescale hypertables](https://docs.timescale.com/use-timescale/latest/hypertables/) — временные таблицы PostgreSQL автоматически партиционируются по времени.
- [Timescale self-hosted installation](https://docs.timescale.com/self-hosted/latest/install/) — расширение разворачивается как self-hosted PostgreSQL-контур; production требует backup/PITR и HA-плана.
- [Retention и continuous aggregates](https://docs.timescale.com/use-timescale/latest/data-retention/data-retention-with-continuous-aggregates/) — raw и агрегаты требуют согласованных lifecycle-политик.
- [PostGIS](https://postgis.net/docs/en/) — PostgreSQL-расширение для spatial types и GiST R-tree индексов.
- [NATS JetStream consumers](https://docs.nats.io/nats-concepts/jetstream/consumers) — durable consumers дают acknowledgement, повторную доставку и recovery; рекомендуются pull consumers для новых масштабируемых обработчиков.

View File

@ -0,0 +1,238 @@
# ADR: внешние коннекторы принадлежат NDC L2 workflow
Статус: **accepted**.
Дата: 2026-07-16.
Владелец решения: NODE.DC Platform.
## Контекст
NODE.DC состоит из двух разных слоёв:
- платформенный слой даёт NDC L1, NDC L2, Ontology, credentials, Data Products
и Foundry-boundaries;
- пользовательские автоматизации собирают из этих возможностей конкретную
бизнес-логику.
Новый account, provider credential, tenant, расписание или интерфейс не должны создавать
новый platform service и не должны требовать изменения NODE.DC source. Команда
NODE.DC подключается только для нового поставщика либо для расширения
подтверждённых capabilities и ontology уже поддержанного поставщика.
Gelios — первый живой источник и acceptance-кейс этого канона, а не отдельная
архитектурная ветка. Документ заменяет provider-runtime решения из
`ADR_EXTERNAL_PROVIDER_DATA_PLANE.md` и `ADR_GELIOS_DATA_PLANE.md`; те
документы остаются только историей решения.
## Решение
### 1. Пакет поставщика создаётся один раз
Каждый поддержанный поставщик получает один версионируемый **provider package**.
Он является знанием платформы о внешнем API и содержит:
- стабильный `providerId` и версии подтверждённого API;
- credential contract без значения секрета;
- каталог safe-read capabilities, pagination/rate-limit metadata и response
shapes;
- mappings из provider fields в версии Ontology;
- совместимые Data Products и scrubbed contract fixtures;
- явно отделённый каталог команд без активного command transport.
Пакет не является runtime service, scheduler, базой данных или customer
configuration. Он расширяется только когда фактически подтверждён новый API,
новый тип данных или новая ontology revision.
Одна учётная запись поставщика задаётся данными, а не кодом:
`provider package + credential reference + connection profile + NDC L2 workflow instance`
Поэтому второй account или другая provider credential pair создаёт ещё один credential/profile и
workflow instance. Платформенный source, Data Plane и Foundry при этом не
меняются.
### 2. NDC L1 проектирует, NDC L2 workflow исполняет
NDC L1 получает пользовательское намерение, проверяет доступные capabilities и
Ontology, проектирует или изменяет разрешённый NDC L2 workflow через NDC MCP и
анализирует execution evidence.
Один изолированный NDC L2 workflow представляет один connection instance и
владеет исполняемой механикой:
- safe-read вызовами provider API через credential reference;
- pagination, cursor, retry, rate limit, batch size и collection cadence;
- проверкой response shape и разбиением ответа на items;
- mapping к точной ontology revision;
- idempotency, watermark и публикацией canonical facts;
- private workflow state, когда он действительно нужен.
Provider-specific mapping сначала реализуется штатными workflow nodes и малым
Code-преобразователем. Это позволяет проверить реальный API без преждевременного
создания custom nodes. В custom NDC nodes переносится только повторившаяся и
доказанная **платформенная boundary-механика**, а не уникальная логика
поставщика.
### 3. Credentials являются данными connection instance
Gelios выдаёт ровно два provider secrets: access token и refresh token. Текущий
HTTP request binding `httpBearerAuth` использует access token. Автоматический
refresh в принятом runtime не доказан, поэтому provider package фиксирует его
как `operator_managed`; ни один из token values не попадает в graph, package,
Ontology, Data Plane, Foundry, execution logs, MCP или Ops. Новый account или
provider-issued token pair означает новые native credential records и
connection instance, но не новую Platform-сущность.
Локальное имя credential (например, с суффиксом `read access`) не является
provider scope. В target-контракте Read-классификацию задаёт allowlisted
method/endpoint в capability catalog и Engine workflow policy; тот же access
token нельзя называть отдельным «read token» или «write token» только из-за
label. Сейчас deployed Engine safe-ref policy ограничивает generic HTTP
credential только по host, но ещё не связывает его с package/version и exact
method/path. Поэтому Gelios `GET /api/v1/units` пока является декларативной
capability, а не завершённой runtime-security гарантией; canonical acceptance
требует capability-bound Engine policy и её MCP proof.
Writer capability для публикации Data Product — отдельный внутренний native NDC
L2 credential, не третий Gelios token. Generic Engine/Platform control plane
генерирует capability внутри native credential boundary, сохраняет её там же и
передаёт EDP только SHA-256 digest вместе с точным provider, connection и
набором Data Products. Пользователь и MCP получают только opaque
reference/status. Capability не записывается в graph, provider package, env,
file, Ops или trace и не копируется через пользовательский UI.
Широкий credential sink, resolver/daemon с произвольной записью secret и любой
provider-specific credential service запрещены. Нужна одна узкая операция
`ensure data-product publish grant`: scope выводится из granted L1→L2 target,
зарегистрированного connection profile и разрешённого Data Product; issuance,
rotation и native binding должны быть idempotent, CAS/crash-safe и auditable.
Engine генерирует capability внутри credential boundary и передаёт EDP только
SHA-256 digest через idempotent binding key + generation; EDP не возвращает
plaintext. Новая generation создаётся до переключения node reference, а старая
отзывается только после успешного bind/acceptance.
Старые ручные EDP `POST/rotate` endpoints, которые возвращают capability один
раз, включаются отдельным legacy-флагом и не входят в canonical acceptance.
Digest-only managed endpoint имеет независимый флаг и принимает только
Ed25519-signed Engine service requests: exact audience/method/request target/raw
body hash входят в подпись, timestamp ограничен по skew, nonce защищён bounded
fail-closed replay cache. Legacy provisioner bearer на managed routes не
действует. EDP получает только deployment public key; matching private key
остаётся только внутри Engine server boundary. Endpoint остаётся выключенным,
пока key provisioning, Engine signer и exact native credential binding policy
не пройдут runtime acceptance.
В deployed Engine MCP этой операции пока нет — это текущий platform gap перед
canonical publish proof, а не действие пользователя. Это решение не заявляет
автоматический refresh Gelios: до отдельного runtime proof он остаётся
`operator_managed`.
### 4. External Data Plane нейтрален к поставщику
Platform предоставляет один versioned External Data Plane. Он принимает
canonical facts через scoped writer binding, хранит current/history
projections и публикует snapshot/patch contracts. В нём запрещены:
- ветвления по provider, customer, account, entity или renderer;
- provider field mapping и бизнес-фильтрация;
- provider credential, endpoint, schedule или command transport;
- caller-supplied tenant/connection scope.
`NDC Data Product Publish` отправляет только
`nodedc.data-product.publish/v1`. External Data Plane materializes immutable
scope из writer binding, проверяет разрешённый Data Product и его ontology
revision, затем сохраняет batch.
EDP runtime импортирует только package subpath
`@nodedc/external-provider-contract/data-plane`. Его deploy artifact и image
содержат только provider-neutral wire validators; `providers/gelios`, mappings,
fixtures и tests туда не входят. Изменение или добавление provider package не
пересобирает и не перезапускает EDP: каталог поставщиков разворачивается через
Engine/Ontology/control-plane путь отдельно.
Collection cadence, history cadence и presentation cadence независимы:
- NDC L2 забирает источник с частотой, нужной бизнес-задаче;
- current projection принимает каждое валидное изменение;
- declarative history policy может хранить все точки или sampling;
- Foundry читает snapshot+patch и отдельно ограничивает частоту render.
Для разной частоты БД и интерфейса не создаются второй provider sink, отдельный
gateway или параллельный прямой push в renderer.
### 5. Полнота означает все сущности разрешённой capability
Connection profile выбирает safe-read capabilities, а не зашитый в Platform
список entity IDs. Если разрешённый endpoint возвращает все доступные credential
сущности, NDC L2 обрабатывает каждый валидный item. Новый объект появляется
автоматически.
Пользовательская фильтрация, слои и видимость находятся после сбора — в
automation/Data Product/Foundry. Технические ограничения допустимы только как
pagination, quota, batch size, backpressure и защита от повреждённого ответа.
### 6. Custom NDC nodes ограничены платформенными границами
Первый канонический набор:
- `NDC Data Product Publish` — NDC L2 → External Data Plane;
- `NDC Data Product Read` — scoped snapshot/patch read;
- `NDC Foundry Binding` — control-plane связь Data Product с
`Application → Page → typed slot`.
Эти nodes не содержат provider ID, tenant ID, connection ID, endpoint, token,
mapping или cadence. Runtime package сохраняет технический namespace
`n8n-nodes-ndc.*`, но пользовательские документы и интерфейсы используют
только терминологию NDC.
`NDC Foundry Binding` не транспортирует каждый realtime tick. Он создаёт
постоянную связь интерфейса с Data Product; Foundry затем читает его
snapshot+patch contract.
### 7. Реальный Gelios acceptance-кейс
Первая версия provider package должна доказать путь:
`Gelios safe read → все доступные units → map.moving_object facts →
fleet.positions.current.v1 → Foundry map`
Canonical product использует ontology revision
`ontology.map.moving_object.v1` и только объявленные snake_case поля. NDC L2
не передаёт caller scope и не собирает собственный batch envelope вокруг
`NDC Data Product Publish`.
Acceptance выполняется по порядку:
1. Platform выполняет generic `ensure data-product publish grant`, выпускает
scoped EDP binding и атомарно сохраняет capability в native NDC L2
Credentials;
2. NDC MCP видит только совместимый opaque writer credential reference/status;
3. применить подтверждённый graph patch без legacy intake;
4. validate/preflight;
5. один успешный manual run и проверка execution/trace/Data Product;
6. только затем отдельным изменением добавить Schedule Trigger;
7. после доказанного snapshot+patch подключить Foundry binding.
### 8. Capacity и topology
Количество одновременно активных NDC L2 проектов ограничивается фактическими
ресурсами железа и профилем нагрузки. Пока capacity проверяется оператором и не
автоматизируется. Канон не объявляет отдельные worker/webhook generations или
high-load topology, которых ещё нет в принятом runtime.
## Последствия
- `services/<provider>-gateway` не является частью канона и не создаётся для
новых provider integrations. Существующий self-hosted Gelios Gateway и его
Timescale volume остаются frozen compatibility contour, воспроизводятся из
source/deploy и не изменяются новым L2 pilot без отдельного решения.
- Новый account или provider-issued access/refresh pair — configuration change,
а не platform release.
- Новый provider или неподдержанная capability — versioned provider/Ontology
change с contract tests.
- Существующий legacy L1/SDK workflow является frozen compatibility boundary:
новый L2 pilot его не редактирует, не отзывает его credential и не ставит его
вывод условием acceptance.
- Legacy `/internal/data-plane/v1/intake` остаётся выключенным migration-only
route и не используется новыми NDC L2 workflows.
- Команды устройствам остаются отдельным red-domain контуром с явным
подтверждением, scope, idempotency и аудитом.
- Foundry развивается после доказанного Data Product path; provider API и
credentials в Foundry не попадают.

View File

@ -27,6 +27,18 @@ Do not mix these classes. A URL reachable from the Mac browser is not automatica
- The deployed AI Hub is allowed only as transport. Launcher, Engine, Ops, Authentik, task manager, and downstream app calls must stay local.
- The remote worker must not call product apps directly. Assistant actions must route back through the local AI Workspace Assistant.
`local-seo/deployed-hub-control-plane`:
- SEO frontend: `http://127.0.0.1:5177`
- SEO backend: `http://127.0.0.1:4100`
- SEO AI Workspace profile: `deployed-hub`
- SEO backend Assistant-compatible control-plane URL: `SEO_AI_WORKSPACE_CONTROL_URL=https://ai-hub.nodedc.ru`
- SEO backend control-plane token: `SEO_AI_WORKSPACE_CONTROL_TOKEN`, accepted by the public Hub server-side API.
- Worker-facing Hub: `wss://ai-hub.nodedc.ru/api/ai-workspace/hub`
- Public Hub exposes only token-gated Assistant control-plane proxy endpoints required by SEO model-provider setup, probe, thread dispatch, and message polling.
- Public Hub must proxy those calls to internal Platform Assistant with server-side internal token. Browser clients and remote workers must not receive the Hub internal token or Assistant token.
- This profile is for local SEO development against the deployed AI Workspace service plane. It is not the same as `local`, and it must not silently fall back to localhost or product app URLs.
`synology/prod-public`:
- Browser Launcher: `https://hub.nodedc.ru`
@ -57,6 +69,23 @@ New apps must join AI Workspace through app manifests/config/adapters:
Do not add app-specific address logic to the agent installer, Engine, Ops, or Platform Assistant runtime flow.
### NDC SEO Mode
`seo-mode` is a first-class AI Workspace surface, not `global`.
Local SEO testing should keep SEO app/backend on fixed local ports while using the configured AI Workspace transport/profile:
- SEO frontend: `http://127.0.0.1:5177`
- SEO backend: `http://127.0.0.1:4100`
- SEO model-provider surface: `seo-mode`
- SEO model task mode: `seo-model-task`
- Worker-facing transport: public AI Hub relay when selected by profile, normally `wss://ai-hub.nodedc.ru/api/ai-workspace/hub`
- SEO task payload: sanitized `seo-model-task` contract only
The SEO backend must select profiles through env/config, not code edits. Local and deploy-host may use different Assistant URLs, executor ids, setup commands, model defaults, and app grants, but the request contract stays the same.
Remote Codex for SEO must not receive Wordstat/Yandex credentials, product app tokens, unrestricted repo access, rewrite/apply actions, or destructive actions. Wordstat/SERP collection remains SEO-backend-owned.
## Verification
Before changing worker, Assistant, Hub, or app adapter routing, run:

View File

@ -0,0 +1,82 @@
# AI Workspace ↔ Ontology Core MCP
## Purpose
This is the read-only semantic path for a NODE.DC AI Workspace run:
```text
Codex worker
-> dynamic per-run MCP configuration
-> public AI Workspace Hub (pairing-bound route)
-> internal Ontology Core MCP
-> freshly loaded ontology catalog and domain packages
```
`ai-workspace-assistant` creates this MCP server entry at run-profile time for every Hub-connected executor when `AI_WORKSPACE_ONTOLOGY_MCP_ENABLED=true`. It is a platform runtime grant (`ontology:catalog:read`), not a user-owned installer secret and not a hard-coded copy of ontology rules in a prompt.
The worker receives only a pairing-bound Hub URL. The Hub verifies that the pairing agent is online and replaces any external authorization with the platform-internal token before forwarding to `ontology-core`. Ontology Core is internal-only and has no host port or reverse-proxy route.
## Read-only MCP surface
`nodedc_ontology` exposes only these tools:
- `ontology_status` — catalog counts, domain packages and a safe catalog hash;
- `ontology_search` — canonical entities, relations and aliases;
- `ontology_get_entity` — entity definition, aliases, relations and guardrails;
- `ontology_get_guardrails` — semantic/safety rules and blocked conflations;
- `ontology_resolve_context` — semantic route advice with source identifiers removed.
It intentionally does not expose filesystem paths, evidence ledgers, credentials, raw payloads, live telemetry, databases, command dispatch, workflow mutation or Studio controls.
## Boundary for external providers and runtime data
Ontology Core describes the canonical meanings and constraints:
```text
gelios.unit -> gelios.telemetry_snapshot -> gelios.position_fix -> map.moving_object
```
It does **not** serve provider runtime data or call a provider API. Provider
access and mapping belong to the granted NDC L2 workflow; shared current and
history products belong to the provider-neutral External Data Plane. A scoped
Data Product read capability may later be supplied to an AI Workspace run as a
separate dynamic MCP server. Ontology supplies meanings, relations, guardrails
and the allowed contract route; the data capability enforces access and returns
live data.
This preserves one-way responsibility:
- Ontology Core: semantics, contracts, aliases, guardrails and context advice.
- External Data Plane: access-scoped Data Product snapshots and updates.
- Command Gateway: separate red-domain command route, explicit confirmation and audit.
- NDC L2 workflow: provider access, collection and mapping using granted capabilities.
- Foundry: presentation consumer, outside this implementation.
## Live catalog updates without AI Workspace rule redeploy
`loadCatalog()` runs for each Ontology MCP tool call; it does not cache the merged catalog. Therefore a new or edited domain package is visible to existing AI Workspace rules as soon as the catalog content is updated in the running Ontology Core container or mounted runtime catalog directory.
For a catalog-only release, update/restart **Ontology Core only**. The AI Workspace Assistant, Hub and Codex worker configuration do not need a rules redeploy because the MCP tool names and route stay stable. `NODEDC_ONTOLOGY_CATALOG_ROOT` may point Ontology Core at a separately managed catalog directory when a runtime-mounted catalog is required.
A Hub/Assistant rollout is needed only if the MCP transport, authorization contract or tool definitions themselves change.
## Safety properties
- The MCP server accepts only internal bearer authentication from AI Hub.
- A browser `Origin` is rejected by default; allow specific origins only through `ONTOLOGY_MCP_ALLOWED_ORIGINS` if an intentional browser transport is introduced.
- The Hub does not pass browser cookies or external `Authorization` downstream.
- The Hub route is pairing-bound and is unavailable when the worker is offline.
- All current tools are read-only. There is no generic ontology write API.
## Validation
```text
cd platform/services/ontology-core
npm run validate
npm run smoke:mcp
cd ../../
node --check services/ai-workspace-assistant/src/server.mjs
node --check services/ai-workspace-hub/src/server.mjs
docker compose --env-file infra/.env -f infra/docker-compose.dev.yml config
```

View File

@ -51,6 +51,51 @@ NODEDC_INTERNAL_ACCESS_TOKEN=change-me-generate-with-infra-scripts-init-dev-env
COOKIE_DOMAIN=.local.nodedc
COOKIE_SECURE=false
# External Data Plane — provider-neutral storage owned by the Platform. This
# password is a database credential only; never reuse NODEDC_INTERNAL_ACCESS_TOKEN.
EXTERNAL_DATA_PLANE_TIMESCALE_IMAGE=timescale/timescaledb-ha:pg16.14-ts2.28.2-all
EXTERNAL_DATA_PLANE_PG_DB=nodedc_data_plane
EXTERNAL_DATA_PLANE_PG_USER=nodedc_data_plane
EXTERNAL_DATA_PLANE_PG_PASS=change-me-generate-with-infra-scripts-init-dev-env
EXTERNAL_DATA_PLANE_HOST_BIND=127.0.0.1:18106
EXTERNAL_DATA_PLANE_DATABASE_POOL_SIZE=10
EXTERNAL_DATA_PLANE_RAW_RETENTION_DAYS=14
EXTERNAL_DATA_PLANE_MAX_BATCH_BYTES=5242880
EXTERNAL_DATA_PLANE_MAX_FACTS_PER_PUBLISH=5000
EXTERNAL_DATA_PLANE_MAX_ATTRIBUTES_BYTES_PER_FACT=65536
EXTERNAL_DATA_PLANE_MAX_PATCH_OPERATIONS=500
EXTERNAL_DATA_PLANE_MAX_PATCH_BYTES=262144
EXTERNAL_DATA_PLANE_PATCH_RETENTION_MS=3600000
EXTERNAL_DATA_PLANE_RECEIPT_RETENTION_MS=604800000
EXTERNAL_DATA_PLANE_RETENTION_DELETE_LIMIT=10000
EXTERNAL_DATA_PLANE_STREAM_HEARTBEAT_MS=20000
EXTERNAL_DATA_PLANE_STREAM_POLL_MS=1000
EXTERNAL_DATA_PLANE_MAX_READER_STREAMS=10
EXTERNAL_DATA_PLANE_WRITER_BINDING_MAX_TTL_DAYS=90
EXTERNAL_DATA_PLANE_MAX_FUTURE_SKEW_SECONDS=300
EXTERNAL_DATA_PLANE_RETENTION_SWEEP_MS=3600000
EXTERNAL_DATA_PLANE_LEGACY_INTAKE_ENABLED=false
# Internal control-plane writer/reader binding issuance; keep false until the
# atomic NDC L2 ensure-grant operation is deployed. Legacy path returns a
# plaintext capability and must never be exposed to users.
EXTERNAL_DATA_PLANE_PROVISIONING_ENABLED=false
# Digest-only managed writer-binding ensure. It accepts only signed Engine
# service requests; the legacy provisioner bearer is deliberately invalid here.
# Keep false until the matching Engine private key is provisioned. The trust
# directory is mounted read-only into EDP and contains only `public-key.pem`.
EXTERNAL_DATA_PLANE_MANAGED_PROVISIONING_ENABLED=false
EXTERNAL_DATA_PLANE_MANAGED_PROVISIONER_SERVICE_ID=nodedc-engine
EXTERNAL_DATA_PLANE_MANAGED_PROVISIONER_KEY_ID=engine-edp-managed-provisioner-v1
EXTERNAL_DATA_PLANE_MANAGED_PROVISIONER_AUDIENCE=nodedc-external-data-plane.managed-provisioning.v1
EXTERNAL_DATA_PLANE_MANAGED_PROVISIONER_MAX_SKEW_SECONDS=60
EXTERNAL_DATA_PLANE_MANAGED_PROVISIONER_REPLAY_CACHE_MAX_ENTRIES=10000
EXTERNAL_DATA_PLANE_FOUNDRY_PROVISIONING_ENABLED=true
EXTERNAL_DATA_PLANE_FOUNDRY_PROVISIONER_SERVICE_ID=nodedc-module-foundry
EXTERNAL_DATA_PLANE_FOUNDRY_PROVISIONER_KEY_ID=foundry-edp-managed-provisioner-v1
EXTERNAL_DATA_PLANE_FOUNDRY_PROVISIONER_AUDIENCE=nodedc-external-data-plane.managed-provisioning.v1
EXTERNAL_DATA_PLANE_FOUNDRY_PROVISIONER_MAX_SKEW_SECONDS=60
EXTERNAL_DATA_PLANE_FOUNDRY_PROVISIONER_REPLAY_CACHE_MAX_ENTRIES=10000
# notification core
NOTIFICATION_PG_DB=nodedc_notifications
NOTIFICATION_PG_USER=nodedc_notifications
@ -66,8 +111,15 @@ NODEDC_AI_WORKSPACE_ASSISTANT_URL=http://ai-workspace-assistant:18082
AI_WORKSPACE_OPS_ENTITLEMENT_URL=http://host.docker.internal:4100/api/internal/v1/ai-workspace/entitlements
AI_WORKSPACE_OPS_ENTITLEMENT_TOKEN=replace-with-ops-agent-gateway-internal-token
AI_WORKSPACE_OPS_ENTITLEMENT_REQUIRED=false
# Add Module Foundry only after its domain, Launcher handoff and Authentik group
# are verified. Preserve existing adapters when adding this JSON member:
# {"module-foundry":{"url":"https://<foundry-domain>/api/ai-workspace/entitlements","required":false}}
# The generic adapter reuses the existing NODE.DC internal server credential;
# never define a separate Foundry token for a browser or worker.
AI_WORKSPACE_ENTITLEMENT_ADAPTERS_JSON=
AI_WORKSPACE_ASSISTANT_ACTION_RELAY_ENABLED=true
AI_WORKSPACE_ASSISTANT_ACTION_RELAY_ID=local-dev
AI_WORKSPACE_ONTOLOGY_MCP_ENABLED=true
# AI Workspace Hub for downloaded Codex workers.
# Default local development may use the deployed AI Hub only as a relay for remote Codex workers.
@ -78,3 +130,51 @@ AI_WORKSPACE_HUB_HOST_BIND=127.0.0.1:18081
AI_WORKSPACE_HUB_PUBLIC_URL=wss://ai-hub.nodedc.ru/api/ai-workspace/hub
AI_WORKSPACE_HUB_INTERNAL_URL=https://ai-hub.nodedc.ru
AI_WORKSPACE_HUB_FALLBACK_URLS=
# The worker receives a per-run, pairing-bound read-only Ontology MCP URL through AI Hub.
# Do not put Ontology Core tokens or catalog paths in worker configuration.
AI_WORKSPACE_ONTOLOGY_MCP_PUBLIC_URL=
ONTOLOGY_CORE_HOST_BIND=127.0.0.1:18104
# Gelios Gateway — frozen legacy storage/read compatibility service. Provider
# credentials belong to the protected Engine Collector and are never configured
# in this service. Keep this contour reproducible; do not use it as a template
# for new providers.
GELIOS_TIMESCALE_IMAGE=timescale/timescaledb-ha:pg16.14-ts2.28.2-all
GELIOS_PG_DB=nodedc_gelios
GELIOS_PG_USER=nodedc_gelios
GELIOS_PG_PASS=change-me-generate-with-infra-scripts-init-dev-env
# URL-encode reserved characters in GELIOS_PG_PASS when forming this URL.
GELIOS_DATABASE_URL=postgresql://nodedc_gelios:change-me-generate-with-infra-scripts-init-dev-env@gelios-postgres:5432/nodedc_gelios
GELIOS_GATEWAY_HOST_BIND=127.0.0.1:18105
GELIOS_TENANT_ID=replace-with-tenant-id
GELIOS_CONNECTION_ID=gelios-connection-id
# `allowlist` accepts only GELIOS_ALLOWED_UNIT_IDS. `all` accepts every unit
# returned by this already-approved tenant + connection, including future units.
GELIOS_UNIT_SCOPE=allowlist
GELIOS_ALLOWED_UNIT_IDS=
GELIOS_INTAKE_ENABLED=false
GELIOS_RAW_RETENTION_DAYS=14
# Presentation state only; it does not change provider collection cadence.
GELIOS_POSITION_STALE_AFTER_MS=300000
# map gateway — keep the actual ion token only in local/staging environment files or Docker secrets.
# Never copy it into workflow metadata, Git, frontend code, or a runtime cache key.
MAP_GATEWAY_HOST_BIND=127.0.0.1:18103
MAP_GATEWAY_ALLOW_ANONYMOUS=true
MAP_GATEWAY_CORS_ORIGIN=http://127.0.0.1:3333,http://localhost:3333
MAP_GATEWAY_UPSTREAM_ALLOWLIST=api.cesium.com,assets.ion.cesium.com,tile.openstreetmap.org,dev.virtualearth.net,ecn.t0.tiles.virtualearth.net,ecn.t1.tiles.virtualearth.net,ecn.t2.tiles.virtualearth.net,ecn.t3.tiles.virtualearth.net
# Imported Engine imagery is selected through the explicit offline cache
# profile, not through a global host-side block.
MAP_GATEWAY_LEGACY_CACHE_HOSTS=
# Leave empty unless the provider/data licence explicitly permits disconnected/offline use.
MAP_GATEWAY_OFFLINE_PROVIDER_ALLOWLIST=
MAP_GATEWAY_UPSTREAM_TIMEOUT_SECONDS=30
CESIUM_ION_TOKEN=
CESIUM_ION_ASSET_ALLOWLIST=1,2,96188
MAP_CACHE_MODE=readwrite
# Mutable cache is mounted at /var/lib/nodedc-map-live-cache by compose.
# MAP_OFFLINE_SNAPSHOT_DIR is intentionally wired by compose as a read-only
# imported Engine snapshot; never place secrets or the live cache there.
MAP_CACHE_MAX_MB=20480
MAP_CACHE_MAX_OBJECT_MB=128
MAP_CACHE_DEFAULT_TTL_SECONDS=604800

View File

@ -6,6 +6,7 @@
- reverse proxy;
- локальные домены;
- shared env examples;
- Map Gateway с persistent TileCache;
- будущие docker compose файлы.
Первый local dev слой проксирует текущие локальные приложения без физического переноса репозиториев:
@ -70,10 +71,19 @@ docker compose --env-file infra/.env -f infra/docker-compose.dev.yml ps
curl -I -H 'Host: auth.local.nodedc' http://127.0.0.1/
curl -I -H 'Host: launcher.local.nodedc' http://127.0.0.1/
curl -I -H 'Host: task.local.nodedc' http://127.0.0.1/
curl http://127.0.0.1:18103/healthz
```
Generated Authentik bootstrap credentials are stored only in `infra/.env`.
## Map Gateway и offline TileCache
`map-gateway` добавлен как общий платформенный сервис на `127.0.0.1:18103`. На NAS его mutable live-cache лежит в `/volume1/docker/nodedc-platform/map-gateway/live-tile-cache`; read-only offline snapshot — рядом в `offline-snapshot`. Это host bind mounts, поэтому папки видны через SMB как `nodedc-platform/map-gateway/`, но не попадают в Git, Docker image или deployment artifact.
Обе папки создаёт root-owned `nodedc-deploy` при первом Map Gateway artifact. Agent не создаёт их напрямую через SMB и не кладёт в artifact. `docker compose down -v` их не удаляет. Очистка допустима только отдельной явно согласованной root-операцией при остановленном Gateway.
Для реального ion terrain/buildings положите `CESIUM_ION_TOKEN` только в неотслеживаемый `infra/.env` или deployment secret. Browser получает лишь публичный provider URL через same-origin Foundry proxy; Gateway добавляет asset credential только в исходящем private request. Детали API, cache modes и production access boundary описаны в `services/map-gateway/README.md`.
5. Bootstrap local Authentik groups and OIDC applications:
```bash

View File

@ -27,6 +27,13 @@ GROUP_SPECS = [
("nodedc:taskmanager:admin", False),
("nodedc:taskmanager:user", False),
("nodedc:bim:access", False),
# Module Foundry roles travel through the existing Authentik `groups`
# claim and Launcher handoff. `access` remains a backward-compatible
# member role until every existing assignment is migrated.
("nodedc:module-foundry:admin", False),
("nodedc:module-foundry:user", False),
("nodedc:module-foundry:blocked", False),
("nodedc:module-foundry:access", False),
]
APP_SPECS = [
@ -121,6 +128,10 @@ def ensure_user_groups(groups):
user.groups.add(authentik_admins)
for name in groups:
# The bootstrap owner must remain capable of entering Foundry after
# the first provisioning run. Blocking is an explicit operator action,
# never a default membership of the bootstrap principal.
if name != "nodedc:module-foundry:blocked":
user.groups.add(groups[name])
return user

View File

@ -28,16 +28,401 @@ Supported components in this source:
- `tasker`
- `ops-agents`
- `bim-viewer`
- `n8n-private-extension`
- `module-foundry`
- `proxy-contur`
- `dc-amd-proxy`
`n8n-private-extension` is a staging-only trust boundary for reviewed offline
n8n private-node releases. Its artifact may contain exactly one digest-bound
`n8n-nodes-ndc` release with `package.tgz`, `release.json` and
`rollback.json`. The runner validates the inner npm tarball, rejects lifecycle
scripts and runtime dependencies, refuses to overwrite an existing release,
and seals the installed release root-owned/read-only under:
```text
/volume1/docker/nodedc-platform/n8n-private-extensions/releases/n8n-nodes-ndc/<version>-<sha256-prefix>
```
This component has no Compose file, service, container mutation or activation
side effect. In particular, staging does **not** make the node visible to n8n.
Activation remains an Engine-owned change: mount the reviewed immutable release
at `/home/node/.n8n/nodes/node_modules/n8n-nodes-ndc`, atomically switch between
verified releases, restart every n8n process, and accept only after MCP exposes
the package-qualified `n8n-nodes-ndc.*` schemas. The Platform runner cannot
cross that boundary and never runs `npm install` in a live container.
Build a verified offline release artifact:
```bash
node infra/deploy-runner/build-n8n-private-extension-artifact.mjs \
n8n-nodes-ndc-release-YYYYMMDD-NNN
```
The builder is byte-reproducible and accepts exactly the three reviewed NDC
runtime types:
- `n8n-nodes-ndc.ndcDataProductPublish`
- `n8n-nodes-ndc.ndcDataProductRead`
- `n8n-nodes-ndc.ndcFoundryBinding`
Their three opaque capability credential schemas are
`ndcDataProductWriterApi`, `ndcDataProductReaderApi` and
`ndcFoundryBindingApi`. A node description containing `usableAsTool` is
rejected because n8n 2.3.2 would synthesize an additional `*Tool` runtime type
and violate the exact-three activation contract. Run the positive and negative
release-policy suite before publishing an artifact:
```bash
PYTHONDONTWRITEBYTECODE=1 \
python3 infra/deploy-runner/test_n8n_private_extension.py
```
Release/rollback manifests use schema v2. Before a first activation, the
Engine-owned activator must verify and record the current inactive state. That
`verified_inactive` state is an allowed rollback baseline when no previous
verified immutable release exists; later upgrades prefer the previous verified
release. Rollback never deletes or mutates a staged release.
The historical `0.1.0` release remains immutable and must not be overwritten.
Release `0.1.1-994756958861518e` is retained as rejected/inactive: its three
node descriptions used `usableAsTool`, so n8n 2.3.2 exposed six NDC runtime
types instead of the required three. It must not be activated, overwritten or
deleted.
The active predecessor is package version `0.1.4` at immutable release
`0.1.4-59dc9f7882721d6a`. Package `0.1.5` adds the provider-neutral complete
snapshot replace mode used by `map.zones.current.v2`; its Platform staging
remains inert until a separately reviewed Engine-owned transition selects the
exact digest and accepts the updated MCP node schema.
The paired Engine activation is built by
`build-engine-n8n-private-extension-artifact.mjs`. It deliberately does not
copy from or write generated files into the dirty Engine worktree, and it does
not build an image. A fresh transition id is mandatory and previously issued
ids are rejected:
```bash
node infra/deploy-runner/build-engine-n8n-private-extension-artifact.mjs 20260721-005
```
The builder emits a narrowly scoped Compose override plus a strict transition
descriptor. On apply, the runner validates the staged release again, verifies
that the running n8n container and the NAS-local `2.3.2` tag resolve to the
same immutable image ID, and extracts the package into the root-owned,
read-only Engine release tree:
```text
/volume2/nodedc-demo/n8n-private-extensions/releases/n8n-nodes-ndc/0.1.5-3c8ae53f010d7c88/package
```
The override sets `N8N_USER_FOLDER=/home/node`, which is required because the
actual Engine service runs as root while the canonical community package path
is below `/home/node/.n8n`. The live runtime contract remains the successfully
deployed generation-003 contract and does not set `NODE_PATH`. Only the
runner's isolated `node -e` package-loader probe temporarily initializes the
dependency tree bundled inside the exact n8n base image; this reproduces n8n's
own loader without redefining the live Compose state. The override enables
loading but disables reinstall, mounts only the exact release read-only, and
uses both Compose `pull_policy: never` and `docker compose up --pull never`.
No registry access, lifecycle script, database `installed_packages` row or
custom-extension loader is involved.
The runner pins the exact Engine service topology observed in source and
rejects an added worker/webhook generation. Only the single actual `n8n`
service is force-recreated with `--no-deps`; the
Postgres service, `.n8n` data, encryption key and credentials remain intact.
The apply gate verifies readiness, the running image/version, sealed mount,
loader environment, package-loader node/credential sets, scoped loader logs,
restart stability and content-exact pinned Engine MCP catalogs. The runner pins
the complete 434/385 inactive baseline and a digest registry for every
reviewed 437/388 active release, so a same-count substitution of any built-in
or private schema is rejected. Upgrade `0.1.2 -> 0.1.3` is accepted only when
the verified live predecessor, descriptor, package mount and catalog all agree.
Any gate failure after
mutation automatically restores the pre-apply catalogs/descriptor and
force-recreates the previous verified runtime. Staged, sealed and failed
releases are retained. The paired rollback artifact returns `0.1.3` to the
verified immutable `0.1.2` release and its exact catalogs; it does not invent
an inactive baseline for an already-active upgrade.
Run both policy suites before publishing the Engine pair:
```bash
PYTHONDONTWRITEBYTECODE=1 \
python3 infra/deploy-runner/test_n8n_private_extension.py
PYTHONDONTWRITEBYTECODE=1 \
python3 infra/deploy-runner/test_engine_n8n_private_extension.py
```
The source-less Engine MCP control-plane update is a separate exact slice:
```bash
node infra/deploy-runner/build-engine-mcp-control-plane-artifact.mjs 20260717-001
```
Its six-entry registry contains only the Engine Agent gateway, verified graph
patch route, Codex installer source/package and the active node-intelligence
descriptor with the new gateway digest. It force-recreates only the existing
`nodedc-backend`. The node-intelligence image/service, n8n, L1, credentials,
databases and volumes are outside the slice. The runner proves the installed
predecessor digests, exact installer archive/source equality, bounded change
session policy, no-effect/post-write graph barriers, active immutable backend
runtime and descriptor equality. The ordinary overlay backup is the automatic
rollback source; rollback restores the predecessor gateway and descriptor
together before recreating the same backend service.
The successor autonomy/provider-v5 slice keeps MCP authority explicit without
turning every write into a second permission dialogue. MCP tool availability is
the capability boundary, while the user's current objective is the intent
boundary; Engine L2 may act autonomously only inside their intersection. A
graph `plan` remains a mandatory machine barrier whose target, diff, revision
and blockers are inspected by the agent. It is not a repeated approval prompt
after an implementation objective is already authorized. Retries must add new
evidence or change the attempted variant, and three identical failures without
new evidence or state change are a critical stop. The slice also advances the
installer to `0.1.6` and adds `gelios.provider.v5 ->
fleet.positions.current.v4` beside the immutable v4/v3 rollback line:
```bash
node infra/deploy-runner/build-engine-mcp-autonomy-provider-v5-artifact.mjs \
20260720-004
```
Its seven-entry allowlist recreates only `nodedc-backend`; n8n, L1, databases,
credential values and the node-intelligence image remain outside the change.
The runner accepts both the exact target and the exact predecessor after an
automatic rollback, and rejects every mixed state.
For `platform` artifacts, the allowlist includes the versioned Ontology Core,
the frozen legacy Gelios compatibility service, and the provider-neutral
External Data Plane sources. The Gelios service remains reproducible only to
protect its existing database/workflow; it is not a template for a provider
integration. An External Data Plane artifact builds only its image and
force-recreates only `external-data-plane`; the already healthy
`external-data-plane-postgres` container and its Timescale volume are an
independent deploy prerequisite and are never selected by an EDP application
artifact. The reviewed Compose source pins the Timescale image, named volume
and target, internal database-only network, absence of database host ports,
healthy dependency, localhost-only EDP bind and the three read-only
provisioner/trust mounts in the reviewed Compose source. The runner does not
reinterpret version-dependent `docker compose config` JSON as a second deploy
schema. Its canonical enforcement remains the artifact/path allowlist plus
hard-coded build command, selected service set, runtime-secret preparation and
health acceptance. Post-apply acceptance also requires
`database=ready`; the managed Foundry slice additionally requires
`foundryReaderBindingProvisioning=digest+server-resolved-source`. A first-rollout failure removes only the candidate EDP
container without volumes and restores the source overlay. It never contains a
provider credential, provider endpoint,
collection schedule or command
transport. Its contract payload is an exact provider-neutral runtime subset;
`providers/*`, mappings, fixtures and tests are excluded and do not trigger an
EDP rebuild. Database credentials remain root-owned live `.env.synology`
configuration and must not reuse `NODEDC_INTERNAL_ACCESS_TOKEN`.
On the first relevant Platform apply, the root-owned runner creates
`/volume1/docker/nodedc-platform/secrets/external-data-plane-provisioner/token`
atomically in a dedicated UID/GID `11006` directory (directory `0500`, token
`0400`). It is never an `.env` value or an artifact member and is mounted
read-only only into External Data Plane.
Manual one-time binding issuance and digest-only managed writer ensure are
independently disabled by default. The target control-plane operation generates
and stores the capability inside native NDC L2 Credentials and sends only its
digest to EDP; users and MCP consumers receive only an opaque compatible
reference/status. The runner-owned bearer above authenticates only legacy
plaintext issuance and is intentionally rejected by managed ensure/revoke.
Managed requests use a deployment Ed25519 Engine service key; EDP mounts only
the public-key trust directory read-only. On every reviewed Engine apply and on
an EDP runtime apply, this runner creates or validates one matching Ed25519 pair:
the Engine-only private key is `root:root 0400`, while the EDP trust copy is
`root:11006 0440`. Public-only crash state, key mismatch, a non-Ed25519 key,
symlinks and permissive modes fail closed. `plan` discloses both paths without
printing key material. The private key must not be broadened into an L2 graph,
MCP surface, artifact or shared-token boundary.
Module Foundry has a separate Ed25519 managed-provisioner identity for
target-scoped Data Product consumer grants. On a relevant `platform` or
`module-foundry` apply, the runner creates or validates the Foundry-only private
key at
`/volume1/docker/nodedc-platform/secrets/foundry-edp-managed-provisioner/private-key.pem`
as `root:root 0400` and the matching EDP trust copy at
`/volume1/docker/nodedc-platform/trust/foundry-managed-provisioner/public-key.pem`
as `root:11006 0440`. Foundry generates the opaque reader token only inside its
persistent private runtime; its signed EDP request contains only a SHA-256
digest and Data Product id. EDP resolves the unique active writer source scope
server-side and fails closed on missing or ambiguous coverage. No provider,
tenant, connection, token, private key or endpoint is admitted to the Foundry
MCP plan, application state or browser response. The Engine signing identity,
native n8n credentials and legacy issuance bearer cannot call this endpoint.
The reviewed Engine source candidate has dedicated server-derived MCP
plan/apply handling for the exact `ndcDataProductWriterApi` + Data Product
Publish tuple. It is separate from the generic HTTP safe-ref path and accepts no
caller-provided provider/scope/credential identity, capability, generation or
service URL. The production managed flag remains false during staging. In the
explicitly confirmed activation window it is set to true immediately before
the Platform EDP artifact; runner acceptance then requires EDP `/healthz` to
report managed provisioning `enabled`. At that point the old Engine still has
no signer mount, so the endpoint remains usable only after the separately
accepted Engine artifact. Root/UI transfer is emergency
self-hosted diagnostics only, not the user journey or acceptance
path. A provider-specific daemon remains forbidden.
Build the narrow Engine source artifact with:
```bash
node infra/deploy-runner/build-engine-data-product-publish-grant-artifact.mjs \
engine-data-product-publish-grant-YYYYMMDD-NNN
```
The builder includes exactly the pinned provider-security catalog, the
Publish-grant service, Engine Agent scope/gateway wiring, the existing n8n
adapter and the reviewed additive backend runtime overlay. It deliberately
excludes base Compose, frontend/dist, runtime data, tests, native credentials,
the NDC L2 process and the legacy generic credential-sink route/core. The
runner fixes the runtime action to `nodedc-backend` with `--no-deps` and
`--pull never`; n8n, nginx app, database services and volumes are not selected.
Acceptance requires backend health, the active immutable backend identity and
the exact additive mount inventory. Any failure restores the touched source and
recreates only the previous verified backend runtime.
Existing Engine Agents created before Publish-grant support store the original
nine scopes as the complete developer profile. They must not require a new
agent, setup command or device credential when the profile gains a server-owned
capability. Build the compatibility artifact that introduces the durable named
`full-developer` profile with:
```bash
node infra/deploy-runner/build-engine-agent-full-grant-migration-artifact.mjs \
engine-agent-full-grant-migration-YYYYMMDD-NNN
```
This follow-up slice contains exactly
`nodedc-source/server/engineAgents/store.js`. The runner pins its predecessor to
the successfully applied Publish generation, requires the installed Publish
overlay and active immutable backend, recreates only `nodedc-backend`, and
proves the exact candidate SHA, named profile and current expanded runtime
scope view. Store schema v1 is migrated atomically to v2 only when a grant
contains the complete legacy nine-scope developer bundle. From then on the
profile name is the authorization authority and its scope list is derived on
every read, so capabilities deliberately added to `full-developer` immediately
apply to existing full grants without token or store migrations. Partial grants
become `custom` and remain exact; they are never elevated. The artifact does not
touch UI, n8n, credentials, agent tokens, workflow graphs, databases or runtime
payloads.
## Engine L2 node-intelligence transition
The node-intelligence transition is an additive Engine-owned deployment domain.
It does not merge Ontology, provider APIs or Ops into the Engine MCP. It pins the
reviewed upstream `n8n-mcp` implementation at package `2.33.2`, commit
`974a9fb3492fe2c4984ee0549085d531cdc6242a`, and exposes only the safe NDC L2
projection through the existing Engine Agent gateway. Upstream management and
write tools are not forwarded.
Production never clones, pulls, installs or builds this dependency. The builder
saves the reviewed `linux/amd64` image once, embeds that exact archive in a
data-only activation artifact and records both the archive and image-config
digests. The runner validates every inner blob, revision label, entrypoint,
command and platform before an offline `docker image load`; Compose then uses
the fixed tag with `pull_policy: never` and `--pull never`.
Build a fresh activation/rollback pair:
```bash
node infra/deploy-runner/build-engine-node-intelligence-artifacts.mjs \
YYYYMMDD-NNN
```
The activation exact set is:
- `nodedc-source/server/nodeIntelligence`
- `nodedc-source/server/routes/engineAgentGateway.js`
- `nodedc-source/services/node-intelligence`
The runtime adds only `nodedc-node-intelligence` and recreates the existing
`nodedc-backend`; n8n, UI, databases, L1, provider services and volumes are not
selected. The sidecar has no host port, runs as `11007:11007`, has a read-only
root filesystem, drops all capabilities and receives no Engine or provider
credential. Its independent MCP bearer is created by the root-owned runner as a
read-only file and mounted only into the sidecar and backend. It never appears
in an artifact, shared environment file, log or MCP response.
Acceptance requires the exact image/container/mount/security inventory,
immutable backend identity and live authenticated `get_node`, `validate_node`
and `validate_workflow` calls. Any apply failure restores source and runtime
automatically. The separate rollback artifact first removes only the sidecar
without volumes, restores the pinned inactive gateway, and recreates only the
verified backend. Loaded images and failed candidate source are retained for
audit rather than destructively deleted.
Stage the reviewed runner under a unique candidate name first:
```text
/volume1/docker/nodedc-deploy/runner-install/candidates/
nodedc-deploy.engine-node-intelligence-YYYYMMDD-NNN
```
Do not overwrite the canonical staging candidate while another deploy may be in
flight. After the no-lock/no-process gate, promote the exact candidate in a
standalone root step, run `verify-install`, then invoke a fresh process for
activation `plan` and only then `apply`. The generated plan/apply runbook carries
the runner, activation and rollback SHA-256 values and is staged beside the
unique runner candidate.
`module-foundry` is an independent, authenticated application component. Its
artifact contains source and compose infrastructure only; its live
`/volume1/docker/nodedc-platform/module-foundry/source/.env` is root-owned and
never enters an artifact. The component reuses the existing internal platform
credential for Launcher handoff validation and requires that runtime
configuration before its first `apply`.
When the managed reader-grant source is present, Module Foundry acceptance also
requires `/healthz` to report the dedicated Ed25519 provisioner as configured.
The check is source-aware: if an apply rolls back to the prior source, rollback
acceptance uses that prior health contract instead of falsely requiring a
feature which the restored generation does not contain.
The Foundry ↔ Map Gateway signing key is not an application or `.env` setting.
On the first relevant `platform` or `module-foundry` apply, the root-owned
runner creates `/volume1/docker/nodedc-platform/secrets/map-gateway-admin-secret`
atomically (root:gid 1000, mode `0640`). Both containers receive that file only
as a read-only mount. The value is never printed, backed up with source,
included in an artifact, or administered through Foundry.
`proxy-contur` is the canonical VPN egress for selected Map Gateway provider
hosts. Its existing root-owned `PROXY_TOKEN` is copied by the runner into
`/volume1/docker/nodedc-platform/secrets/map-egress-proxy-token` with
`root:gid 1000`, mode `0640`, then mounted read-only only into Map Gateway.
The value is neither printed nor contained in an artifact, Foundry setting, or
browser response. Apply the `proxy-contur` artifact before the Platform Map
Gateway artifact: it creates the private `nodedc-map-egress` Docker network.
`dc-amd-proxy` is the separate, staged connector for the neighbouring AMD VPN
machine. Its active artifact attaches only to the private `nodedc-map-egress`
network, exposes a narrow NAS-LAN pairing port, and has no direct provider
egress. The runner preserves a `0700`, service-user-owned runtime directory
for the one-time paired connector credential and synchronizes the existing
private Map Gateway egress credential as a read-only file. Neither value is
ever placed in an artifact, `.env`, browser response, or runner output. The
separate Platform switch is applied only after the connector and pairing are
verified; it does not alter NAS routes, VPN, DNS, or Tailscale.
Install or update the root-owned live runner on Synology:
```bash
# First verify that no deploy process is active and state/deploy.lock is absent.
sudo install -o root -g root -m 0755 \
/volume1/docker/nodedc-deploy/runner-install/nodedc-deploy \
/usr/local/sbin/nodedc-deploy
sudo /usr/local/sbin/nodedc-deploy verify-install
```
Runner promotion is a standalone admin step, never an app-overlay artifact and
never part of a running apply. A Python process already executing the old file
keeps the old code in memory; always invoke a fresh verified process afterward.
Normal service deploys must still use explicit artifacts:
```bash

View File

@ -0,0 +1,44 @@
#!/usr/bin/env node
import { createHash } from "node:crypto";
import { spawnSync } from "node:child_process";
import { cp, lstat, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { dirname, join, resolve } from "node:path";
import { tmpdir } from "node:os";
import { fileURLToPath } from "node:url";
const scriptDir = dirname(fileURLToPath(import.meta.url));
const platformRoot = resolve(scriptDir, "../..");
const artifactDir = resolve(scriptDir, "../deploy-artifacts");
const [patchId = "dc-amd-proxy-bootstrap-20260715-001", ...extra] = process.argv.slice(2);
if (extra.length || !/^[A-Za-z0-9._-]{1,96}$/.test(patchId)) throw new Error("usage: build-dc-amd-proxy-artifact.mjs [patch-id]");
const files = [
["services/dc-amd-proxy/Dockerfile", "Dockerfile"],
["services/dc-amd-proxy/README.md", "README.md"],
["services/dc-amd-proxy/docker-compose.yml", "docker-compose.yml"],
["services/dc-amd-proxy/package.json", "package.json"],
["services/dc-amd-proxy/server.mjs", "server.mjs"],
];
const stage = await mkdtemp(join(tmpdir(), "nodedc-dc-amd-proxy-artifact-"));
const payload = join(stage, "payload");
const target = join(artifactDir, `nodedc-${patchId}.tgz`);
try {
await mkdir(payload, { recursive: true });
for (const [source, destination] of files) await copySafe(resolve(platformRoot, source), join(payload, destination));
await writeFile(join(stage, "manifest.env"), `id=${patchId}\ncomponent=dc-amd-proxy\ntype=app-overlay\n`, "utf8");
await writeFile(join(stage, "files.txt"), `${files.map(([, destination]) => destination).join("\n")}\n`, "utf8");
await mkdir(artifactDir, { recursive: true });
const tar = spawnSync("python3", ["-c", "import sys,tarfile\nwith tarfile.open(sys.argv[1],'w:gz',format=tarfile.PAX_FORMAT) as a:\n [a.add(n,arcname=n,recursive=True) for n in ('manifest.env','files.txt','payload')]", target], { cwd: stage, encoding: "utf8" });
if (tar.status !== 0) throw new Error(`tar_failed:${tar.stderr || tar.stdout}`);
console.log(JSON.stringify({ ok: true, patchId, artifact: target, sha256: createHash("sha256").update(await readFile(target)).digest("hex") }, null, 2));
} finally {
await rm(stage, { recursive: true, force: true });
}
async function copySafe(source, destination) {
const info = await lstat(source);
if (info.isSymbolicLink() || !info.isFile()) throw new Error(`source_file_rejected:${source}`);
await mkdir(dirname(destination), { recursive: true });
await cp(source, destination, { force: true });
}

View File

@ -0,0 +1,166 @@
#!/usr/bin/env node
import { createHash } from 'node:crypto'
import { spawnSync } from 'node:child_process'
import { lstat, mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { dirname, join, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
const here = dirname(fileURLToPath(import.meta.url))
const platformRoot = resolve(here, '../..')
const workspaceRoot = resolve(platformRoot, '..')
const engineRoot = resolve(workspaceRoot, 'NODEDC_ENGINE_INFRA')
const artifactRoot = resolve(process.env.NODEDC_DEPLOY_ARTIFACT_DIR || join(workspaceRoot, 'deploy-artifacts'))
const [patchId = '', ...extra] = process.argv.slice(2)
const storeRelativePath = 'nodedc-source/server/engineAgents/store.js'
const predecessorSha256 = '52daa43499d6d9a97fe7ffa891edb9212b7791e733f91dd3ca686d42739b7e9a'
const targetSha256 = '2e62654c2dc12905efcc83a9dff45a818dd7b47924a10600160835c4416540e9'
const readerExtendedSourceSha256 = 'debd351fe0b8c72b33b8c79909b8f339cbaf061b970576f3ae72df52ebaa211f'
const ontologyExtendedSourceSha256 = '4cd4bdd5958cfafee184e98a04fe12aa0c1cbe884326beaec63c99f9fff61285'
const frozenTargetArtifact = join(
workspaceRoot,
'deploy-artifacts/nodedc-engine-agent-full-grant-migration-20260717-002.tgz',
)
const frozenTargetArtifactSha256 = 'd104ee6e63d1fccb9069e2b3db5e0446907ee9bf9cb04f2387636c272f803d68'
const previouslyIssuedPatchIds = new Set([
'engine-agent-full-grant-migration-20260717-001',
])
if (extra.length || !/^engine-agent-full-grant-migration-\d{8}-\d{3}$/.test(patchId)) {
throw new Error('usage: build-engine-agent-full-grant-migration-artifact.mjs <fresh-patch-id>')
}
if (previouslyIssuedPatchIds.has(patchId)) {
throw new Error('engine_agent_full_grant_migration_patch_id_already_issued')
}
const source = join(engineRoot, storeRelativePath)
const sourceInfo = await lstat(source)
if (sourceInfo.isSymbolicLink() || !sourceInfo.isFile()) throw new Error('engine_agent_store_source_unsafe')
const sourceBytes = await materializeFrozenTarget(await readFile(source))
if (digest(sourceBytes) !== targetSha256) throw new Error('engine_agent_store_target_sha256_mismatch')
const sourceText = sourceBytes.toString('utf8')
for (const required of [
'const STORE_VERSION = 2',
"export const ENGINE_AGENT_FULL_DEVELOPER_PROFILE = 'full-developer'",
"export const ENGINE_AGENT_CUSTOM_PROFILE = 'custom'",
'const LEGACY_FULL_DEVELOPER_SCOPES = Object.freeze([',
"'engine:l2:data-product-publish-grant:plan'",
"'engine:l2:data-product-publish-grant:write'",
'const migrateLegacyFullDeveloper = sourceVersion === 1',
'LEGACY_FULL_DEVELOPER_SCOPES.every((scope) => scopes.includes(scope))',
'profile === ENGINE_AGENT_FULL_DEVELOPER_PROFILE ? [...ENGINE_AGENT_SCOPES] : scopes',
"throw new Error('engine_agent_store_version_unsupported')",
]) {
if (!sourceText.includes(required)) throw new Error(`engine_agent_store_contract_missing:${required}`)
}
if (/gelios|robot2b/i.test(sourceText)) throw new Error('engine_agent_store_provider_logic_forbidden')
run('node', ['--check', source])
await mkdir(artifactRoot, { recursive: true })
const artifact = join(artifactRoot, `nodedc-${patchId}.tgz`)
await assertFresh(artifact)
const stage = await mkdtemp(join(tmpdir(), 'nodedc-engine-agent-grant-migration-'))
try {
const destination = join(stage, 'payload', storeRelativePath)
await mkdir(dirname(destination), { recursive: true })
await writeFile(destination, sourceBytes)
await writeFile(
join(stage, 'manifest.env'),
`id=${patchId}\ncomponent=engine\ntype=app-overlay\n`,
'utf8',
)
await writeFile(join(stage, 'files.txt'), `${storeRelativePath}\n`, 'utf8')
run('python3', ['-c', canonicalTarScript(), artifact, stage])
const artifactBytes = await readFile(artifact)
console.log(JSON.stringify({
ok: true,
patchId,
artifact,
sha256: digest(artifactBytes),
entries: [storeRelativePath],
predecessorSha256,
targetSha256,
services: ['nodedc-backend'],
excluded: ['n8n', 'app', 'databases', 'credentials', 'runtime-data'],
}, null, 2))
} finally {
await rm(stage, { recursive: true, force: true })
}
async function assertFresh(target) {
try {
await lstat(target)
} catch (error) {
if (error?.code === 'ENOENT') return
throw error
}
throw new Error('engine_agent_full_grant_migration_artifact_already_exists')
}
function canonicalTarScript() {
return [
'import gzip, io, pathlib, sys, tarfile',
'root=pathlib.Path(sys.argv[2])',
"with open(sys.argv[1], 'wb') as out:",
" with gzip.GzipFile(filename='', mode='wb', fileobj=out, compresslevel=9, mtime=0) as gz:",
" with tarfile.open(fileobj=gz, mode='w', format=tarfile.PAX_FORMAT) as tar:",
" for top in ('manifest.env','files.txt','payload'):",
" p=root/top; paths=[p] + (sorted(p.rglob('*')) if p.is_dir() else [])",
' for x in paths:',
" info=tar.gettarinfo(str(x), arcname=x.relative_to(root).as_posix())",
" info.uid=info.gid=0; info.uname=info.gname='root'; info.mtime=0; info.mode=0o755 if info.isdir() else 0o644",
" with (open(x,'rb') if info.isfile() else io.BytesIO()) as src: tar.addfile(info, src if info.isfile() else None)",
].join('\n')
}
function run(command, args) {
const result = spawnSync(command, args, {
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'pipe'],
maxBuffer: 16 * 1024 * 1024,
})
if (result.status !== 0) throw new Error(`${command}_failed:${result.stderr || result.stdout}`)
return result
}
function digest(bytes) {
return createHash('sha256').update(bytes).digest('hex')
}
async function materializeFrozenTarget(bytes) {
const sourceSha256 = digest(bytes)
if (sourceSha256 === targetSha256) return bytes
if (sourceSha256 === ontologyExtendedSourceSha256) {
const artifactBytes = await readFile(frozenTargetArtifact)
if (digest(artifactBytes) !== frozenTargetArtifactSha256) {
throw new Error('engine_agent_store_frozen_target_artifact_sha256_mismatch')
}
const script = [
'import pathlib,sys,tarfile',
'p=pathlib.Path(sys.argv[1])',
"with tarfile.open(p,'r:gz') as t:",
" f=t.extractfile('payload/nodedc-source/server/engineAgents/store.js')",
" if f is None: raise SystemExit('member-unreadable')",
' sys.stdout.buffer.write(f.read())',
].join('\n')
const frozen = Buffer.from(run('python3', ['-c', script, frozenTargetArtifact]).stdout, 'utf8')
if (digest(frozen) !== targetSha256) {
throw new Error('engine_agent_store_frozen_target_sha256_mismatch')
}
return frozen
}
if (sourceSha256 !== readerExtendedSourceSha256) {
throw new Error('engine_agent_store_target_sha256_mismatch')
}
const text = bytes.toString('utf8')
const readerScopes = [
" 'engine:l2:data-product-read-grant:plan',\n",
" 'engine:l2:data-product-read-grant:write',\n",
]
let frozen = text
for (const scope of readerScopes) {
if (!frozen.includes(scope)) throw new Error('engine_agent_store_reader_scope_boundary_missing')
frozen = frozen.replace(scope, '')
}
return Buffer.from(frozen, 'utf8')
}

View File

@ -0,0 +1,154 @@
#!/usr/bin/env node
import { createHash } from 'node:crypto'
import { spawnSync } from 'node:child_process'
import { copyFile, lstat, mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { dirname, join, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
const here = dirname(fileURLToPath(import.meta.url))
const platformRoot = resolve(here, '../..')
const workspaceRoot = resolve(platformRoot, '..')
const engineRoot = resolve(workspaceRoot, 'NODEDC_ENGINE_INFRA')
const artifactRoot = resolve(process.env.NODEDC_DEPLOY_ARTIFACT_DIR || join(workspaceRoot, 'deploy-artifacts'))
const predecessorArtifact = resolve(
process.env.NODEDC_ENGINE_AUTH_RECOVERY_PREDECESSOR
|| '/Volumes/docker/nodedc-deploy/applied/nodedc-engine-restart-safe-auth-20260719-001.tgz',
)
const [patchId = '', ...extra] = process.argv.slice(2)
if (extra.length || !/^engine-auth-redirect-recovery-\d{8}-\d{3}$/.test(patchId)) {
throw new Error('usage: build-engine-auth-redirect-recovery-artifact.mjs engine-auth-redirect-recovery-YYYYMMDD-NNN')
}
const predecessorArtifactSha256 = 'f6830046d7bd2bc32c1dbda8dce7ddf68d15c1e728f594c0f64ee8feec3911b8'
const predecessorManifest = 'id=engine-restart-safe-auth-20260719-001\ncomponent=engine\ntype=app-overlay\n'
const predecessorSha256 = Object.freeze({
'nodedc-source/src/platform/auth/access.ts': '9610dc1622a1c9b383eb3a1569347098002aa058dbbcbb23f096f2622d452222',
'nodedc-source/src/platform/auth/sessionRecovery.ts': 'f931cbb45d64d5d6a2868194e8c73879a408dffca1fdd7e41acfa262441c50d7',
'nodedc-source/dist/index.html': 'b031f6720683f6fb5ccfa59a01414ff2a3efcdf548b1c6aab33e1748ee3f003d',
})
const entries = Object.freeze([
'nodedc-source/src/platform/auth/access.ts',
'nodedc-source/src/platform/auth/sessionRecovery.ts',
'nodedc-source/dist/index.html',
'nodedc-source/dist/assets/index-DJ1CMfu4.js',
])
const candidateSha256 = Object.freeze({
'nodedc-source/src/platform/auth/access.ts': 'c686db2568912a093ea8934e34889254ddd659594f7084025c280732ef627002',
'nodedc-source/src/platform/auth/sessionRecovery.ts': '63f3e2379628dea3b119c4283bc0c3bb10e94199c673b9d9c579bac3c54be98f',
'nodedc-source/dist/index.html': '8894f62ad590168862e81266bc4602410279634b666af72cb14ad80aeb71ea74',
'nodedc-source/dist/assets/index-DJ1CMfu4.js': 'a0aaf72d2ed390142ff2ea03dbcfdd534637e5faefd80d8aa7d705a804abe065',
})
const predecessorInfo = await lstat(predecessorArtifact)
if (predecessorInfo.isSymbolicLink() || !predecessorInfo.isFile()) {
throw new Error('engine_auth_recovery_predecessor_unsafe')
}
if (digest(await readFile(predecessorArtifact)) !== predecessorArtifactSha256) {
throw new Error('engine_auth_recovery_predecessor_archive_mismatch')
}
run('python3', [
'-c', verifyPredecessorScript(), predecessorArtifact,
predecessorArtifactSha256, predecessorManifest, JSON.stringify(predecessorSha256),
])
for (const rel of entries) {
const source = resolve(engineRoot, rel)
const info = await lstat(source)
if (info.isSymbolicLink() || !info.isFile()) throw new Error(`engine_auth_recovery_candidate_unsafe:${rel}`)
if (digest(await readFile(source)) !== candidateSha256[rel]) {
throw new Error(`engine_auth_recovery_candidate_sha256_mismatch:${rel}`)
}
}
const indexHtml = await readFile(resolve(engineRoot, 'nodedc-source/dist/index.html'), 'utf8')
if (!indexHtml.includes('/assets/index-DJ1CMfu4.js') || !indexHtml.includes('/assets/index-Bim2pv1P.css')) {
throw new Error('engine_auth_recovery_dist_entrypoint_mismatch')
}
await mkdir(artifactRoot, { recursive: true })
const artifact = join(artifactRoot, `nodedc-${patchId}.tgz`)
await assertArtifactTargetFresh(artifact)
const stage = await mkdtemp(join(tmpdir(), 'nodedc-engine-auth-redirect-recovery-'))
try {
await writeFile(
join(stage, 'manifest.env'),
`id=${patchId}\ncomponent=engine\ntype=app-overlay\n`,
'utf8',
)
await writeFile(join(stage, 'files.txt'), `${entries.join('\n')}\n`, 'utf8')
for (const rel of entries) {
const target = join(stage, 'payload', rel)
await mkdir(dirname(target), { recursive: true })
await copyFile(resolve(engineRoot, rel), target)
}
run('python3', ['-c', canonicalTarScript(), artifact, stage])
console.log(JSON.stringify({
ok: true,
patchId,
artifact,
sha256: digest(await readFile(artifact)),
predecessorArtifact,
predecessorArtifactSha256,
predecessorSha256,
candidateSha256,
entries,
runtimeServices: ['nodedc-backend', 'app'],
}, null, 2))
} finally {
await rm(stage, { recursive: true, force: true })
}
function verifyPredecessorScript() {
return [
'import hashlib,json,pathlib,sys,tarfile',
'source=pathlib.Path(sys.argv[1]); expected_archive=sys.argv[2]',
'expected_manifest=sys.argv[3].encode(); hashes=json.loads(sys.argv[4])',
"assert hashlib.sha256(source.read_bytes()).hexdigest()==expected_archive, 'archive-sha256'",
"with tarfile.open(source,'r:gz') as tar:",
" assert tar.extractfile('manifest.env').read()==expected_manifest, 'manifest'",
' for rel,wanted in hashes.items():',
" data=tar.extractfile('payload/'+rel).read()",
" assert hashlib.sha256(data).hexdigest()==wanted, 'payload:'+rel",
].join('\n')
}
async function assertArtifactTargetFresh(target) {
try {
await lstat(target)
} catch (error) {
if (error?.code === 'ENOENT') return
throw error
}
throw new Error('engine_auth_recovery_artifact_already_exists')
}
function canonicalTarScript() {
return [
'import gzip,io,pathlib,sys,tarfile',
'root=pathlib.Path(sys.argv[2])',
"with open(sys.argv[1],'wb') as out:",
" with gzip.GzipFile(filename='',mode='wb',fileobj=out,compresslevel=9,mtime=0) as gz:",
" with tarfile.open(fileobj=gz,mode='w',format=tarfile.PAX_FORMAT) as tar:",
" for top in ('manifest.env','files.txt','payload'):",
" p=root/top; paths=[p]+(sorted(p.rglob('*')) if p.is_dir() else [])",
' for x in paths:',
" info=tar.gettarinfo(str(x),arcname=x.relative_to(root).as_posix())",
" info.uid=info.gid=0; info.uname=info.gname='root'; info.mtime=0; info.mode=0o755 if info.isdir() else 0o644",
" with (open(x,'rb') if info.isfile() else io.BytesIO()) as src: tar.addfile(info,src if info.isfile() else None)",
].join('\n')
}
function run(command, args) {
const result = spawnSync(command, args, {
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'pipe'],
maxBuffer: 128 * 1024 * 1024,
})
if (result.status !== 0) throw new Error(`${command}_failed:${result.stderr || result.stdout}`)
}
function digest(bytes) {
return createHash('sha256').update(bytes).digest('hex')
}

View File

@ -0,0 +1,164 @@
#!/usr/bin/env node
import { createHash } from "node:crypto";
import { spawnSync } from "node:child_process";
import { copyFile, lstat, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
const here = dirname(fileURLToPath(import.meta.url));
const workspaceRoot = resolve(here, "../../..");
const engineRoot = resolve(
process.env.NODEDC_ENGINE_SOURCE_ROOT || join(workspaceRoot, "NODEDC_ENGINE_INFRA"),
);
const artifactRoot = resolve(
process.env.NODEDC_DEPLOY_ARTIFACT_DIR || resolve(here, "../deploy-artifacts"),
);
const [patchId = "", ...extra] = process.argv.slice(2);
if (extra.length || !/^engine-composite-provider-v4-\d{8}-\d{3}$/.test(patchId)) {
throw new Error(
"usage: build-engine-composite-provider-v4-artifact.mjs " +
"<engine-composite-provider-v4-YYYYMMDD-NNN>",
);
}
const targetSha256 = Object.freeze({
"nodedc-source/server/assets/provider-packages/v1/catalog.json":
"9c931f9abfcadb5b34a8a854c2efb8fd79913e000eecf0967d1b7c500bf9a56a",
"nodedc-source/server/dataProductPublishGrant/providerCatalog.js":
"689c6fbf695e582d983159973a21142787d1b19bb1d343da4c62c03092ac291f",
"nodedc-source/server/dataProductPublishGrant/service.js":
"83f045f4e0f332644310172ed51bb652d808bf04155b11c02e46bdffc95f7220",
"nodedc-source/server/dataProductPublishGrant/store.js":
"98662da6acd0489a9cae4b726eb61ce89d9b2c788b2ea95433e9c4f02930c095",
});
const entries = Object.freeze(Object.keys(targetSha256));
const artifact = join(artifactRoot, `nodedc-${patchId}.tgz`);
await assertFresh(artifact);
await assertExactSources();
const stage = await mkdtemp(join(tmpdir(), "nodedc-engine-composite-provider-v4-"));
try {
const payload = join(stage, "payload");
for (const relativePath of entries) {
const source = join(engineRoot, relativePath);
const destination = join(payload, relativePath);
await mkdir(dirname(destination), { recursive: true });
await copyFile(source, destination, 0);
}
await writeFile(
join(stage, "manifest.env"),
`id=${patchId}\ncomponent=engine\ntype=app-overlay\n`,
{ encoding: "utf8", flag: "wx", mode: 0o644 },
);
await writeFile(join(stage, "files.txt"), `${entries.join("\n")}\n`, {
encoding: "utf8",
flag: "wx",
mode: 0o644,
});
await mkdir(artifactRoot, { recursive: true });
run("python3", ["-c", canonicalTarScript(), artifact, stage]);
console.log(JSON.stringify({
ok: true,
patchId,
artifact,
sha256: digest(await readFile(artifact)),
entries,
targetSha256,
services: ["nodedc-backend"],
transition: "exact-v3-to-v4",
providerPackage: "gelios.provider.v4",
capabilities: [
"gelios.monitoring_config.current.read",
"gelios.units.current.read",
],
dataProductId: "fleet.positions.current.v3",
credentialValues: "preserved",
untouched: ["n8n", "L1 graph", "Engine UI", "databases"],
}, null, 2));
} finally {
await rm(stage, { recursive: true, force: true });
}
async function assertExactSources() {
for (const [relativePath, expected] of Object.entries(targetSha256)) {
const path = join(engineRoot, relativePath);
const info = await lstat(path);
if (!info.isFile() || info.isSymbolicLink()) {
throw new Error(`engine_composite_provider_source_unsafe:${relativePath}`);
}
const actual = digest(await readFile(path));
if (actual !== expected) {
throw new Error(
`engine_composite_provider_target_mismatch:${relativePath}:` +
`expected=${expected}:actual=${actual}`,
);
}
}
const catalog = JSON.parse(await readFile(join(engineRoot, entries[0]), "utf8"));
const provider = catalog?.packages?.[0];
const capabilityIds = provider?.capabilities?.map((item) => item.id);
const requestUrls = provider?.capabilities?.map((item) => item.request?.url);
if (
catalog?.schemaVersion !== "nodedc.engine.provider-security-catalog/v1" ||
provider?.id !== "gelios.provider.v4" ||
provider?.version !== "4.0.0" ||
provider?.providerCredential?.credentialType !== "httpBearerAuth" ||
JSON.stringify(capabilityIds) !== JSON.stringify([
"gelios.monitoring_config.current.read",
"gelios.units.current.read",
]) ||
JSON.stringify(requestUrls) !== JSON.stringify([
"https://api.geliospro.com/api/v1/users/me/monitoring-config",
"https://api.geliospro.com/api/v1/units?incltrip=true",
]) ||
provider?.capabilities?.some(
(item) => item.dataProductIds?.length !== 1 ||
item.dataProductIds[0] !== "fleet.positions.current.v3",
)
) {
throw new Error("engine_composite_provider_catalog_projection_mismatch");
}
}
async function assertFresh(path) {
try {
await lstat(path);
} catch (error) {
if (error?.code === "ENOENT") return;
throw error;
}
throw new Error("artifact_already_exists");
}
function canonicalTarScript() {
return [
"import gzip,io,pathlib,sys,tarfile",
"root=pathlib.Path(sys.argv[2])",
"with open(sys.argv[1],'xb') as out:",
" with gzip.GzipFile(filename='',mode='wb',fileobj=out,compresslevel=9,mtime=0) as gz:",
" with tarfile.open(fileobj=gz,mode='w',format=tarfile.PAX_FORMAT) as tar:",
" for top in ('manifest.env','files.txt','payload'):",
" p=root/top; paths=[p]+(sorted(p.rglob('*')) if p.is_dir() else [])",
" for x in paths:",
" info=tar.gettarinfo(str(x),arcname=x.relative_to(root).as_posix())",
" info.uid=info.gid=0; info.uname=info.gname='root'; info.mtime=0; info.mode=0o755 if info.isdir() else 0o644",
" with (open(x,'rb') if info.isfile() else io.BytesIO()) as src: tar.addfile(info,src if info.isfile() else None)",
].join("\n");
}
function digest(value) {
return createHash("sha256").update(value).digest("hex");
}
function run(command, args) {
const result = spawnSync(command, args, {
encoding: "utf8",
maxBuffer: 128 * 1024 * 1024,
stdio: ["ignore", "pipe", "pipe"],
});
if (result.status !== 0) {
throw new Error(`${command}_failed:${result.stderr || result.stdout}`);
}
}

View File

@ -0,0 +1,156 @@
#!/usr/bin/env node
import { createHash } from 'node:crypto'
import { spawnSync } from 'node:child_process'
import { lstat, mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { dirname, join, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
const here = dirname(fileURLToPath(import.meta.url))
const platformRoot = resolve(here, '../..')
const workspaceRoot = resolve(platformRoot, '..')
const artifactRoot = resolve(process.env.NODEDC_DEPLOY_ARTIFACT_DIR || join(workspaceRoot, 'deploy-artifacts'))
const sourceArtifact = resolve(
process.env.NODEDC_ENGINE_CREDENTIAL_SINK_RECOVERY_SOURCE
|| '/Volumes/docker/nodedc-deploy/applied/nodedc-engine-credential-sink-20260716-001.tgz',
)
const [patchId = '', ...extra] = process.argv.slice(2)
if (extra.length || !/^engine-credential-sink-recovery-\d{8}-\d{3}$/.test(patchId)) {
throw new Error('usage: build-engine-credential-sink-recovery-artifact.mjs <fresh-recovery-patch-id>')
}
const sourceArtifactSha256 = '0a96add05fe59db8f490927f66e07a84490474a7afb3ef7de51e1d6fd96f86a2'
const sourceManifest = 'id=engine-credential-sink-20260716-001\ncomponent=engine\ntype=app-overlay\n'
const entries = Object.freeze([
'nodedc-source/server/credentialPolicies/ndcPrivateNode.js',
'nodedc-source/server/credentialSink',
'nodedc-source/server/index.js',
'nodedc-source/server/routes/engineAgentGateway.js',
'nodedc-source/server/routes/engineCredentialSink.js',
'nodedc-source/server/routes/n8n.js',
'nodedc-source/server/routes/ndcAgentMcp.js',
'nodedc-source/services/backend/credential-sink/docker-compose.immutable-runtime.yml',
])
const payloadSha256 = Object.freeze({
'nodedc-source/server/credentialPolicies/ndcPrivateNode.js': '723874a02dc7b8a68b22ff2304431cfe64f28933cedf5f1e6cda79b2e1cf704a',
'nodedc-source/server/credentialSink/core.js': '9f0facc41fd398fcd955cffdd486abb126cdcd756c87ffde667fcbe00e2c41d3',
'nodedc-source/server/credentialSink/requestAuth.js': 'f8c9237c3e6f4219dee0d4f956d6e97f76f5bd7ba8a6fd0b4fb21aceffa38138',
'nodedc-source/server/credentialSink/store.js': 'c78dc285a973b6acd8a2330f0310935ad08720b2905454492f292d235abf12c0',
'nodedc-source/server/credentialSink/vendor/engine-credential-sink.mjs': 'b4800eead9bf94793ff1280d06e34aad6b37ef055a9d7f8d83d7fb5f9bd66d8b',
'nodedc-source/server/index.js': 'b2b790b02839570d967a2ca68b00e2724485a99389ac9b3a589a1b22302a36b8',
'nodedc-source/server/routes/engineAgentGateway.js': 'e3450a4e1d5318dbac37627b67804d62804e27e2032c9e90478a1e739cea6d3d',
'nodedc-source/server/routes/engineCredentialSink.js': '9cbb69dbc8cbe6181cd5b0170fe9c4d717b0a173866ca98cba3e3766c0bab94e',
'nodedc-source/server/routes/n8n.js': '783d822e2457d82e890f43bc00c7e33822077dc2841f0511a89ecb210fd36d48',
'nodedc-source/server/routes/ndcAgentMcp.js': 'fbb3342b1a617b956d5b3a6a40d5111aa107c1176b4ff89c37b104995bada081',
'nodedc-source/services/backend/credential-sink/docker-compose.immutable-runtime.yml': '944fa64b08255eb8207b93fd327aebb98ecd9400d37d25fcfa8e3a040ee44afe',
})
const sourceInfo = await lstat(sourceArtifact)
if (sourceInfo.isSymbolicLink() || !sourceInfo.isFile()) {
throw new Error('engine_credential_sink_recovery_source_unsafe')
}
if (digest(await readFile(sourceArtifact)) !== sourceArtifactSha256) {
throw new Error('engine_credential_sink_recovery_source_sha256_mismatch')
}
await mkdir(artifactRoot, { recursive: true })
const artifact = join(artifactRoot, `nodedc-${patchId}.tgz`)
await assertArtifactTargetFresh(artifact)
const stage = await mkdtemp(join(tmpdir(), 'nodedc-engine-credential-sink-recovery-'))
try {
run('python3', [
'-c', verifiedExtractScript(), sourceArtifact, stage,
sourceArtifactSha256, sourceManifest, JSON.stringify(entries), JSON.stringify(payloadSha256),
])
await writeFile(
join(stage, 'manifest.env'),
`id=${patchId}\ncomponent=engine\ntype=app-overlay\n`,
'utf8',
)
await writeFile(join(stage, 'files.txt'), `${entries.join('\n')}\n`, 'utf8')
run('python3', ['-c', canonicalTarScript(), artifact, stage])
console.log(JSON.stringify({
ok: true,
patchId,
artifact,
sha256: digest(await readFile(artifact)),
sourceArtifact,
sourceArtifactSha256,
entries,
payloadSha256,
changed: ['manifest.env:id'],
}, null, 2))
} finally {
await rm(stage, { recursive: true, force: true })
}
async function assertArtifactTargetFresh(target) {
try {
await lstat(target)
} catch (error) {
if (error?.code === 'ENOENT') return
throw error
}
throw new Error('engine_credential_sink_recovery_artifact_already_exists')
}
function verifiedExtractScript() {
return [
'import hashlib,json,pathlib,sys,tarfile',
'source=pathlib.Path(sys.argv[1]); stage=pathlib.Path(sys.argv[2])',
'expected_archive=sys.argv[3]; expected_manifest=sys.argv[4].encode()',
'entries=json.loads(sys.argv[5]); hashes=json.loads(sys.argv[6])',
"raw=source.read_bytes()",
"assert hashlib.sha256(raw).hexdigest()==expected_archive, 'source-sha256'",
"expected_files={'manifest.env','files.txt'}|{'payload/'+name for name in hashes}",
"expected_dirs={'payload'}",
"for name in hashes:",
" p=pathlib.PurePosixPath('payload/'+name)",
" expected_dirs.update(str(parent) for parent in p.parents if str(parent)!='.')",
"with tarfile.open(source,'r:gz') as tar:",
" members=tar.getmembers(); names={member.name for member in members}",
" assert names==expected_files|expected_dirs, 'source-member-set'",
" for member in members:",
" p=pathlib.PurePosixPath(member.name)",
" assert not p.is_absolute() and '..' not in p.parts and '\\\\' not in member.name, 'unsafe-member'",
" assert member.isdir() if member.name in expected_dirs else member.isfile(), 'unsafe-member-type'",
" assert tar.extractfile('manifest.env').read()==expected_manifest, 'source-manifest'",
" expected_list=('\\n'.join(entries)+'\\n').encode()",
" assert tar.extractfile('files.txt').read()==expected_list, 'source-files-list'",
" for name,wanted in hashes.items():",
" data=tar.extractfile('payload/'+name).read()",
" assert hashlib.sha256(data).hexdigest()==wanted, 'payload-sha256:'+name",
" target=stage/'payload'/name; target.parent.mkdir(parents=True,exist_ok=True); target.write_bytes(data)",
].join('\n')
}
function canonicalTarScript() {
return [
'import gzip,io,pathlib,sys,tarfile',
'root=pathlib.Path(sys.argv[2])',
"with open(sys.argv[1],'wb') as out:",
" with gzip.GzipFile(filename='',mode='wb',fileobj=out,compresslevel=9,mtime=0) as gz:",
" with tarfile.open(fileobj=gz,mode='w',format=tarfile.PAX_FORMAT) as tar:",
" for top in ('manifest.env','files.txt','payload'):",
" p=root/top; paths=[p]+(sorted(p.rglob('*')) if p.is_dir() else [])",
" for x in paths:",
" info=tar.gettarinfo(str(x),arcname=x.relative_to(root).as_posix())",
" info.uid=info.gid=0; info.uname=info.gname='root'; info.mtime=0; info.mode=0o755 if info.isdir() else 0o644",
" with (open(x,'rb') if info.isfile() else io.BytesIO()) as src: tar.addfile(info,src if info.isfile() else None)",
].join('\n')
}
function run(command, args) {
const result = spawnSync(command, args, {
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'pipe'],
maxBuffer: 128 * 1024 * 1024,
})
if (result.status !== 0) throw new Error(`${command}_failed:${result.stderr || result.stdout}`)
}
function digest(bytes) {
return createHash('sha256').update(bytes).digest('hex')
}

View File

@ -0,0 +1,297 @@
#!/usr/bin/env node
import { createHash } from 'node:crypto'
import { spawnSync } from 'node:child_process'
import { cp, lstat, mkdir, mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { dirname, join, relative, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
const here = dirname(fileURLToPath(import.meta.url))
const platformRoot = resolve(here, '../..')
const workspaceRoot = resolve(platformRoot, '..')
const engineRoot = resolve(workspaceRoot, 'NODEDC_ENGINE_INFRA')
const artifactRoot = resolve(process.env.NODEDC_DEPLOY_ARTIFACT_DIR || join(workspaceRoot, 'deploy-artifacts'))
const [patchId = '', ...extra] = process.argv.slice(2)
const previouslyIssuedPatchIds = new Set([
'engine-data-product-publish-grant-20260716-001',
'engine-data-product-publish-grant-20260717-001',
'engine-data-product-publish-grant-20260717-002',
])
const compositeProviderCatalogTargetSha256 = '9c931f9abfcadb5b34a8a854c2efb8fd79913e000eecf0967d1b7c500bf9a56a'
if (extra.length || !/^engine-data-product-publish-grant-\d{8}-\d{3}$/.test(patchId)) {
throw new Error('usage: build-engine-data-product-publish-grant-artifact.mjs <fresh-patch-id>')
}
if (previouslyIssuedPatchIds.has(patchId)) throw new Error('engine_publish_grant_patch_id_already_issued')
// Deliberately exclude frontend/dist, runtime data, tests, every credential
// value, and the already-installed credential-sink implementation. The sink
// remains mounted and byte-frozen as the predecessor domain; this artifact
// adds the exact Data Product Publish grant flow beside it.
const entries = Object.freeze([
'nodedc-source/server/assets/provider-packages/v1/catalog.json',
'nodedc-source/server/dataProductPublishGrant',
'nodedc-source/server/engineAgents/store.js',
'nodedc-source/server/routes/engineAgentGateway.js',
'nodedc-source/server/routes/n8n.js',
'nodedc-source/services/backend/data-product-publish-grant/docker-compose.immutable-runtime.yml',
])
const ignoredBasenames = new Set(['.DS_Store', '.git', 'node_modules'])
const credentialSinkPredecessorSha256 = Object.freeze({
'docker-compose.yml': '258cebb64ff1943c939655cc55bdce00fc5c4dced67ec291d84d6df066ace50e',
'nodedc-source/server/credentialPolicies/ndcPrivateNode.js': '723874a02dc7b8a68b22ff2304431cfe64f28933cedf5f1e6cda79b2e1cf704a',
'nodedc-source/server/credentialSink/core.js': '9f0facc41fd398fcd955cffdd486abb126cdcd756c87ffde667fcbe00e2c41d3',
'nodedc-source/server/credentialSink/requestAuth.js': 'f8c9237c3e6f4219dee0d4f956d6e97f76f5bd7ba8a6fd0b4fb21aceffa38138',
'nodedc-source/server/credentialSink/store.js': 'c78dc285a973b6acd8a2330f0310935ad08720b2905454492f292d235abf12c0',
'nodedc-source/server/credentialSink/vendor/engine-credential-sink.mjs': 'b4800eead9bf94793ff1280d06e34aad6b37ef055a9d7f8d83d7fb5f9bd66d8b',
'nodedc-source/server/index.js': 'b2b790b02839570d967a2ca68b00e2724485a99389ac9b3a589a1b22302a36b8',
'nodedc-source/server/routes/engineCredentialSink.js': '9cbb69dbc8cbe6181cd5b0170fe9c4d717b0a173866ca98cba3e3766c0bab94e',
'nodedc-source/server/routes/ndcAgentMcp.js': '534e2c85e1faecc72a00da7ad32d0584ee09b6bd89eeec2221c3b23d80e1e962',
'nodedc-source/services/backend/credential-sink/docker-compose.immutable-runtime.yml': '944fa64b08255eb8207b93fd327aebb98ecd9400d37d25fcfa8e3a040ee44afe',
})
const publishGrantRuntimeOverride = [
'services:',
' nodedc-backend:',
' user: "0:0"',
' environment:',
' ENGINE_DATA_PLANE_BASE_URL: http://external-data-plane:18106',
' ENGINE_EDP_MANAGED_PROVISIONER_PRIVATE_KEY_FILE: /run/nodedc-secrets/engine-edp-managed-provisioner-private.pem',
' ENGINE_CONTROL_PLANE_PUBLISH_GRANT_ROOT: /var/lib/nodedc-control-plane/publish-grants',
' volumes:',
' - type: bind',
' source: /volume2/nodedc-demo/nodedc-control-plane/publish-grants',
' target: /var/lib/nodedc-control-plane/publish-grants',
' bind:',
' create_host_path: false',
' - type: bind',
' source: /volume1/docker/nodedc-platform/secrets/engine-edp-managed-provisioner/private-key.pem',
' target: /run/nodedc-secrets/engine-edp-managed-provisioner-private.pem',
' read_only: true',
' bind:',
' create_host_path: false',
'',
].join('\n')
await assertSourceBoundary()
await mkdir(artifactRoot, { recursive: true })
const artifact = join(artifactRoot, `nodedc-${patchId}.tgz`)
await assertArtifactTargetFresh(artifact)
const stage = await mkdtemp(join(tmpdir(), 'nodedc-engine-publish-grant-artifact-'))
const payload = join(stage, 'payload')
try {
await mkdir(payload, { recursive: true })
for (const entry of entries) {
await copySafe(resolve(engineRoot, entry), join(payload, entry))
}
await writeFile(
join(stage, 'manifest.env'),
`id=${patchId}\ncomponent=engine\ntype=app-overlay\n`,
'utf8',
)
await writeFile(join(stage, 'files.txt'), `${entries.join('\n')}\n`, 'utf8')
run('python3', ['-c', canonicalTarScript(), artifact, stage])
const sha256 = digest(await readFile(artifact))
console.log(JSON.stringify({
ok: true,
patchId,
artifact,
sha256,
entries,
excluded: [
'docker-compose.yml',
'nodedc-source/server/index.js',
'nodedc-source/server/credentialPolicies/ndcPrivateNode.js',
'nodedc-source/server/credentialSink',
'nodedc-source/server/routes/engineCredentialSink.js',
'nodedc-source/server/middleware/demoAccess.js',
'nodedc-source/server/routes/ndcAgentMcp.js',
'nodedc-source/server/data',
'nodedc-source/dist',
'nodedc-source/server/tests',
],
providerPackage: 'gelios.provider.v4',
providerRequests: [
'https://api.geliospro.com/api/v1/users/me/monitoring-config',
'https://api.geliospro.com/api/v1/units?incltrip=true',
],
dataProductId: 'fleet.positions.current.v3',
credentialValues: 'preserved',
preservedPredecessor: Object.keys(credentialSinkPredecessorSha256),
}, null, 2))
} finally {
await rm(stage, { recursive: true, force: true })
}
async function assertSourceBoundary() {
// These files are deliberately excluded from the artifact. Their exact
// installed predecessor is verified by the root-owned runner during plan
// and apply; the local builder only proves that it cannot package a link or
// another unsafe filesystem object in their place.
for (const relativePath of Object.keys(credentialSinkPredecessorSha256)) {
const info = await lstat(join(engineRoot, relativePath))
if (!info.isFile() || info.isSymbolicLink()) {
throw new Error(`engine_credential_sink_predecessor_unsafe:${relativePath}`)
}
}
const runtimeOverridePath = join(
engineRoot,
'nodedc-source/services/backend/data-product-publish-grant/docker-compose.immutable-runtime.yml',
)
if (await readFile(runtimeOverridePath, 'utf8') !== publishGrantRuntimeOverride) {
throw new Error('engine_publish_grant_runtime_override_mismatch')
}
const catalogPath = join(
engineRoot,
'nodedc-source/server/assets/provider-packages/v1/catalog.json',
)
if (digest(await readFile(catalogPath)) !== compositeProviderCatalogTargetSha256) {
throw new Error('engine_composite_provider_catalog_target_mismatch')
}
const indexSource = await readFile(join(engineRoot, 'nodedc-source/server/index.js'), 'utf8')
if (!indexSource.includes("app.use('/api/engine-agent-mcp', engineAgentMcpRouter)")) {
throw new Error('engine_agent_mcp_mount_missing')
}
if (
!indexSource.includes("import engineCredentialSinkRouter from './routes/engineCredentialSink.js'")
|| !indexSource.includes("app.use('/internal/engine-credential-sink', express.json({")
) throw new Error('engine_credential_sink_predecessor_mount_missing')
const gateway = await readFile(join(engineRoot, 'nodedc-source/server/routes/engineAgentGateway.js'), 'utf8')
for (const tool of [
'engine_plan_data_product_publish_grant',
'engine_apply_data_product_publish_grant',
'engine_accept_data_product_publish_grant',
'engine_rollback_data_product_publish_grant',
]) {
if (!gateway.includes(tool)) throw new Error(`engine_publish_grant_tool_missing:${tool}`)
}
const n8nRoute = await readFile(join(engineRoot, 'nodedc-source/server/routes/n8n.js'), 'utf8')
if (n8nRoute.includes("from '../credentialSink/")) {
throw new Error('engine_publish_grant_depends_on_legacy_sink')
}
if (!n8nRoute.includes('engineDataProductPublishGrantN8nAdapter')) {
throw new Error('engine_publish_grant_native_adapter_missing')
}
if (!n8nRoute.includes('engineCredentialSinkN8nAdapter')) {
throw new Error('engine_credential_sink_native_adapter_missing')
}
const grantDirectory = join(engineRoot, 'nodedc-source/server/dataProductPublishGrant')
const grantFiles = (await readdir(grantDirectory, { withFileTypes: true }))
.filter((entry) => entry.isFile())
.map((entry) => entry.name)
.sort()
const expectedGrantFiles = [
'acceptance.js',
'providerCatalog.js',
'service.js',
'signedDataPlaneClient.js',
'store.js',
]
if (JSON.stringify(grantFiles) !== JSON.stringify(expectedGrantFiles)) {
throw new Error('engine_publish_grant_source_set_mismatch')
}
const providerCatalogSource = await readFile(join(grantDirectory, 'providerCatalog.js'), 'utf8')
const grantServiceSource = await readFile(join(grantDirectory, 'service.js'), 'utf8')
const grantStoreSource = await readFile(join(grantDirectory, 'store.js'), 'utf8')
for (const marker of [
'function capabilityRequests(capability)',
'function exactHttpRequestUrl(n8n)',
'capabilityIds',
'providerRequestNodeIds',
'providerCredentialRefs.size !== 1',
]) {
if (!providerCatalogSource.includes(marker)) {
throw new Error(`engine_composite_provider_resolver_missing:${marker}`)
}
}
if (
!grantServiceSource.includes('capabilities: descriptor.capabilityIds')
|| !grantServiceSource.includes('providerRequestNodeIds: descriptor.providerRequestNodeIds')
) {
throw new Error('engine_composite_provider_plan_projection_missing')
}
if (
!grantStoreSource.includes('Array.isArray(raw.capabilityIds)')
|| !grantStoreSource.includes('Array.isArray(raw.providerRequestNodeIds)')
) {
throw new Error('engine_composite_provider_store_projection_missing')
}
for (const entry of entries) {
const source = resolve(engineRoot, entry)
const info = await lstat(source)
if (info.isSymbolicLink() || (!info.isFile() && !info.isDirectory())) {
throw new Error(`engine_artifact_source_unsafe:${entry}`)
}
}
for (const source of [
...expectedGrantFiles.map((name) => join(grantDirectory, name)),
join(engineRoot, 'nodedc-source/server/routes/engineAgentGateway.js'),
join(engineRoot, 'nodedc-source/server/routes/n8n.js'),
join(engineRoot, 'nodedc-source/server/routes/ndcAgentMcp.js'),
join(engineRoot, 'nodedc-source/server/engineAgents/store.js'),
]) run('node', ['--check', source])
}
async function assertArtifactTargetFresh(target) {
try {
await lstat(target)
} catch (error) {
if (error?.code === 'ENOENT') return
throw error
}
throw new Error('engine_publish_grant_artifact_already_exists')
}
async function copySafe(source, destination) {
const info = await lstat(source)
if (info.isSymbolicLink()) throw new Error(`source_symlink_rejected:${source}`)
if (info.isFile()) {
await mkdir(dirname(destination), { recursive: true })
await cp(source, destination, { force: false, verbatimSymlinks: true })
return
}
if (!info.isDirectory()) throw new Error(`source_type_rejected:${source}`)
await mkdir(destination, { recursive: true })
for (const entry of await readdir(source, { withFileTypes: true })) {
if (ignoredBasenames.has(entry.name) || entry.name.startsWith('.env')) continue
const childSource = join(source, entry.name)
if (entry.isSymbolicLink()) {
throw new Error(`source_symlink_rejected:${relative(engineRoot, childSource)}`)
}
await copySafe(childSource, join(destination, entry.name))
}
}
function canonicalTarScript() {
return [
'import gzip, io, pathlib, sys, tarfile',
'root=pathlib.Path(sys.argv[2])',
"with open(sys.argv[1], 'wb') as out:",
" with gzip.GzipFile(filename='', mode='wb', fileobj=out, compresslevel=9, mtime=0) as gz:",
" with tarfile.open(fileobj=gz, mode='w', format=tarfile.PAX_FORMAT) as tar:",
" for top in ('manifest.env','files.txt','payload'):",
" p=root/top; paths=[p] + (sorted(p.rglob('*')) if p.is_dir() else [])",
' for x in paths:',
" info=tar.gettarinfo(str(x), arcname=x.relative_to(root).as_posix())",
" info.uid=info.gid=0; info.uname=info.gname='root'; info.mtime=0; info.mode=0o755 if info.isdir() else 0o644",
" with (open(x,'rb') if info.isfile() else io.BytesIO()) as src: tar.addfile(info, src if info.isfile() else None)",
].join('\n')
}
function run(command, args) {
const result = spawnSync(command, args, {
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'pipe'],
maxBuffer: 128 * 1024 * 1024,
})
if (result.status !== 0) throw new Error(`${command}_failed:${result.stderr || result.stdout}`)
return result
}
function digest(bytes) {
return createHash('sha256').update(bytes).digest('hex')
}

View File

@ -0,0 +1,173 @@
#!/usr/bin/env node
import { createHash } from "node:crypto";
import { spawnSync } from "node:child_process";
import { copyFile, lstat, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
const here = dirname(fileURLToPath(import.meta.url));
const workspaceRoot = resolve(here, "../../..");
const engineRoot = resolve(
process.env.NODEDC_ENGINE_SOURCE_ROOT || join(workspaceRoot, "NODEDC_ENGINE_INFRA"),
);
const artifactRoot = resolve(
process.env.NODEDC_DEPLOY_ARTIFACT_DIR || resolve(here, "../deploy-artifacts"),
);
const [patchId = "", ...extra] = process.argv.slice(2);
if (extra.length || !/^engine-depttrans-zone-authority-v1-\d{8}-\d{3}$/.test(patchId)) {
throw new Error(
"usage: build-engine-depttrans-zone-authority-v1-artifact.mjs " +
"<engine-depttrans-zone-authority-v1-YYYYMMDD-NNN>",
);
}
const targetSha256 = Object.freeze({
"nodedc-source/server/assets/provider-packages/v1/catalog.json":
"1aac712a05e55137d69eedaf363969460ffe866288e8e18e35711967ab232f39",
"nodedc-source/server/assets/provider-packages/v1/depttrans-zone-authority-v1.json":
"1482032178b4816e599df570face9b1dfa8ae1fa2943ac8f941c561e8cb9aa9d",
"nodedc-source/server/dataProductPublishGrant/providerCatalog.js":
"e4c216b0affd89ddbd48abcec86febd48eb12a4c56507851daeb47f2ccd60c9a",
"nodedc-source/server/dataProductPublishGrant/service.js":
"408836564e9a422eb5e648cc24d764f4bfdf7f83d93306742e8615fa8186a642",
"nodedc-source/server/dataProductPublishGrant/store.js":
"ace5971d0772e9a9499f0849e6b754e3677cc2ca3080e573a12e26acf5a6c0c0",
});
const entries = Object.freeze(Object.keys(targetSha256));
const artifact = join(artifactRoot, `nodedc-${patchId}.tgz`);
await assertFresh(artifact);
await assertExactSources();
const stage = await mkdtemp(join(tmpdir(), "nodedc-engine-depttrans-zone-authority-v1-"));
try {
const payload = join(stage, "payload");
for (const relativePath of entries) {
const destination = join(payload, relativePath);
await mkdir(dirname(destination), { recursive: true });
await copyFile(join(engineRoot, relativePath), destination);
}
await writeFile(
join(stage, "manifest.env"),
`id=${patchId}\ncomponent=engine\ntype=app-overlay\n`,
{ encoding: "utf8", flag: "wx", mode: 0o644 },
);
await writeFile(join(stage, "files.txt"), `${entries.join("\n")}\n`, {
encoding: "utf8",
flag: "wx",
mode: 0o644,
});
await mkdir(artifactRoot, { recursive: true });
run("python3", ["-c", canonicalTarScript(), artifact, stage]);
console.log(JSON.stringify({
ok: true,
patchId,
artifact,
sha256: digest(await readFile(artifact)),
entries,
targetSha256,
services: ["nodedc-backend"],
transition: "exact-platform-service-authority-v1",
providerPackage: "moscow-department-of-transport.pmd-slow-zones.v1",
dataProductId: "map.zones.current.v2",
authorityBoundary: "platform-service",
platformService: "nodedc-map-gateway",
credentialValues: "preserved",
untouched: ["n8n", "L1 graph", "Engine UI", "databases", "MCP Nginx"],
}, null, 2));
} finally {
await rm(stage, { recursive: true, force: true });
}
async function assertExactSources() {
for (const [relativePath, expected] of Object.entries(targetSha256)) {
const sourcePath = join(engineRoot, relativePath);
const info = await lstat(sourcePath);
if (!info.isFile() || info.isSymbolicLink()) {
throw new Error(`engine_depttrans_zone_authority_source_unsafe:${relativePath}`);
}
const actual = digest(await readFile(sourcePath));
if (actual !== expected) {
throw new Error(
`engine_depttrans_zone_authority_target_mismatch:${relativePath}:` +
`expected=${expected}:actual=${actual}`,
);
}
}
const catalog = JSON.parse(await readFile(join(engineRoot, entries[0]), "utf8"));
const provider = catalog.packages?.find(
(item) => item.id === "moscow-department-of-transport.pmd-slow-zones.v1",
);
const request = provider?.capabilities?.[0]?.request;
if (
provider?.version !== "1.0.0"
|| provider?.providerId !== "moscow-department-of-transport"
|| provider?.providerCredential !== undefined
|| provider?.capabilities?.[0]?.dataProductIds?.[0] !== "map.zones.current.v2"
|| request?.authorityBoundary !== "platform-service"
|| request?.serviceId !== "nodedc-map-gateway"
|| request?.network !== "engine"
|| request?.url !== "http://map-gateway:18103/internal/zone-sources/v1/profiles/moscow-pmd-slow-zones/current"
) throw new Error("engine_depttrans_zone_authority_catalog_projection_mismatch");
const marker = JSON.parse(await readFile(join(engineRoot, entries[1]), "utf8"));
if (
marker?.schemaVersion !== "nodedc.engine.platform-service-authority/v1"
|| marker?.id !== provider.id
|| marker?.serviceId !== request.serviceId
|| marker?.dataProductId !== provider.capabilities[0].dataProductIds[0]
) throw new Error("engine_depttrans_zone_authority_marker_projection_mismatch");
const resolver = await readFile(join(engineRoot, entries[2]), "utf8");
const service = await readFile(join(engineRoot, entries[3]), "utf8");
const store = await readFile(join(engineRoot, entries[4]), "utf8");
for (const marker of [
"function platformServiceRequestIsExact",
"pinned_platform_service_no_graph_credential",
"platformServiceIds",
"publish_grant_provider_request_path_unreachable",
]) {
if (![resolver, service, store].some((source) => source.includes(marker))) {
throw new Error(`engine_depttrans_zone_authority_marker_missing:${marker}`);
}
}
}
async function assertFresh(path) {
try {
await lstat(path);
} catch (error) {
if (error?.code === "ENOENT") return;
throw error;
}
throw new Error("artifact_already_exists");
}
function canonicalTarScript() {
return [
"import gzip,io,pathlib,sys,tarfile",
"root=pathlib.Path(sys.argv[2])",
"with open(sys.argv[1],'xb') as out:",
" with gzip.GzipFile(filename='',mode='wb',fileobj=out,compresslevel=9,mtime=0) as gz:",
" with tarfile.open(fileobj=gz,mode='w',format=tarfile.PAX_FORMAT) as tar:",
" for top in ('manifest.env','files.txt','payload'):",
" p=root/top; paths=[p]+(sorted(p.rglob('*')) if p.is_dir() else [])",
" for x in paths:",
" info=tar.gettarinfo(str(x),arcname=x.relative_to(root).as_posix())",
" info.uid=info.gid=0; info.uname=info.gname='root'; info.mtime=0; info.mode=0o755 if info.isdir() else 0o644",
" with (open(x,'rb') if info.isfile() else io.BytesIO()) as src: tar.addfile(info,src if info.isfile() else None)",
].join("\n");
}
function digest(value) {
return createHash("sha256").update(value).digest("hex");
}
function run(command, args) {
const result = spawnSync(command, args, {
encoding: "utf8",
maxBuffer: 128 * 1024 * 1024,
stdio: ["ignore", "pipe", "pipe"],
});
if (result.status !== 0) throw new Error(`${command}_failed:${result.stderr || result.stdout}`);
}

View File

@ -0,0 +1,300 @@
#!/usr/bin/env node
import { createHash } from "node:crypto";
import { spawnSync } from "node:child_process";
import {
copyFile,
cp,
lstat,
mkdir,
mkdtemp,
readFile,
rm,
writeFile,
} from "node:fs/promises";
import { tmpdir } from "node:os";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
const here = dirname(fileURLToPath(import.meta.url));
const platformRoot = resolve(here, "../..");
const engineRoot = resolve(
process.env.NODEDC_ENGINE_SOURCE_ROOT || resolve(platformRoot, "../NODEDC_ENGINE_INFRA"),
);
const artifactRoot = resolve(
process.env.NODEDC_DEPLOY_ARTIFACT_DIR || resolve(here, "../deploy-artifacts"),
);
const baselineArtifact = resolve(
here,
"../deploy-artifacts/nodedc-engine-provider-authority-diagnostics-20260723-029.tgz",
);
const baselineArtifactSha256 =
"b4a0a1f707bf9814a4decc3f7f261b8afffe5727af2129c14bcdc76f81675e38";
const descriptorRel =
"nodedc-source/services/node-intelligence/activation.json";
const baselineDescriptorSha256 =
"84b0e15a10cedf334ad04d6f31c908da2dc155503c9974f0eeb75b01e20fb884";
const predecessorGatewaySha256 =
"5331f6dc8dc306f641370a2968eb025ee3e278e27ae9a217e4cfc2901fec369c";
const targetGatewaySha256 =
"4f600a2781be9118bec891fa2a6f20d7f55e0892ef2f06af24059193cebd28f4";
const targetDescriptorSha256 =
"63e7619c6971d02102583bb5d80d33ece293b952f113200fa0b76f0e88c2dd32";
const [patchId = "", ...extra] = process.argv.slice(2);
if (extra.length || !/^engine-l2-closed-loop-\d{8}-\d{3}$/.test(patchId)) {
throw new Error(
"usage: build-engine-l2-closed-loop-artifact.mjs " +
"<engine-l2-closed-loop-YYYYMMDD-NNN>",
);
}
const sourceCommit = "dda405cff27977622af0c218abb4d4420c408536";
const entries = Object.freeze([
"nodedc-source/server/index.js",
"nodedc-source/server/l2/graphRepository.js",
"nodedc-source/server/realtime/ws.js",
"nodedc-source/server/routes/engineAgentGateway.js",
"nodedc-source/server/routes/n8n.js",
"nodedc-source/server/routes/ndcAgentMcp.js",
"nodedc-source/src/App.tsx",
"nodedc-source/src/n8n/N8nSubworkflowHost.tsx",
"nodedc-source/src/realtime/useMultiplayer.ts",
"nodedc-source/src/utils/n8nApi.ts",
"nodedc-source/dist/index.html",
"nodedc-source/dist/assets",
descriptorRel,
]);
const targetSha256 = Object.freeze({
"nodedc-source/server/index.js":
"1896e1cade61579863c50ff3f52f2e81ff27f2a511a9d362db81925ee21cefad",
"nodedc-source/server/l2/graphRepository.js":
"94ad08e1bb7e7be04854f1f911e06ee1acd6e09631f33f4c01e42e230665ac33",
"nodedc-source/server/realtime/ws.js":
"82cfa833e05c2fc6d6049dd4564af06164b5c784fdfb82c243ca67519f4509c2",
"nodedc-source/server/routes/engineAgentGateway.js":
"4f600a2781be9118bec891fa2a6f20d7f55e0892ef2f06af24059193cebd28f4",
"nodedc-source/server/routes/n8n.js":
"752cb1524160adc1c95163c34e139b615c063d2ce8980f2a63879bddbd0f1e08",
"nodedc-source/server/routes/ndcAgentMcp.js":
"353a10291ceb93c3641ece60ec6e809a394be86f5ceb97cb44755178940409dd",
"nodedc-source/src/App.tsx":
"b3e61a89330b774f309660208d05143d299ab582f18765c1c14e2122a62c4d5e",
"nodedc-source/src/n8n/N8nSubworkflowHost.tsx":
"683aa44ba92aeac4879889e2bdb5319282976d7680d620701578830ce9677087",
"nodedc-source/src/realtime/useMultiplayer.ts":
"a4d001d9914098e50a78d8abe0a66344d23680b7a19b3573aa7b6f48c8bb9c35",
"nodedc-source/src/utils/n8nApi.ts":
"6f16d4992c51ffcc1e71a7bd172fd9ceb440d6e1a74d69ef1db62e0ac4230b3c",
"nodedc-source/dist/index.html":
"90d72f8790dc8cf951066b1210447c84d640373117bae23f3a4cb3227fb96d6d",
"nodedc-source/dist/assets/index-Bim2pv1P.css":
"18322addd45c126a7b8396f36b005f3085fddba3e9b346dd2c910f6fa6987ebf",
"nodedc-source/dist/assets/index-CqvJfRRS.js":
"06e6c23b03ea2ba8a4890e1f1714fd08ebaf18d735834200af6ab430af360ca8",
[descriptorRel]: targetDescriptorSha256,
});
const artifact = join(artifactRoot, `nodedc-${patchId}.tgz`);
await assertFresh(artifact);
assertSourceCommit();
await assertExactSources();
const targetDescriptor = await buildTargetDescriptor();
const stage = await mkdtemp(join(tmpdir(), "nodedc-engine-l2-closed-loop-"));
try {
const payload = join(stage, "payload");
for (const relativePath of entries) {
const destination = join(payload, relativePath);
await mkdir(dirname(destination), { recursive: true });
if (relativePath === descriptorRel) {
await writeFile(destination, targetDescriptor, {
encoding: "utf8",
flag: "wx",
mode: 0o644,
});
continue;
}
const source = join(engineRoot, relativePath);
const info = await lstat(source);
if (info.isSymbolicLink()) {
throw new Error(`engine_l2_closed_loop_source_symlink:${relativePath}`);
}
if (info.isDirectory()) {
await cp(source, destination, {
recursive: true,
force: false,
errorOnExist: true,
});
} else if (info.isFile()) {
await copyFile(source, destination);
} else {
throw new Error(`engine_l2_closed_loop_source_unsafe:${relativePath}`);
}
}
await writeFile(
join(stage, "manifest.env"),
`id=${patchId}\ncomponent=engine\ntype=app-overlay\n`,
{ encoding: "utf8", flag: "wx", mode: 0o644 },
);
await writeFile(join(stage, "files.txt"), `${entries.join("\n")}\n`, {
encoding: "utf8",
flag: "wx",
mode: 0o644,
});
await mkdir(artifactRoot, { recursive: true });
run("python3", ["-c", canonicalTarScript(), artifact, stage]);
console.log(JSON.stringify({
ok: true,
patchId,
artifact,
sha256: digest(await readFile(artifact)),
sourceCommit,
entries,
targetSha256,
services: ["nodedc-backend", "app"],
healthchecks: [
"http://127.0.0.1:8080/",
"http://127.0.0.1:3001/health",
],
mcpVersion: "0.7.0",
graphContract: "semantic-revision-cas-v1",
transition: "failed-030-partial-source-reconciliation",
predecessorDescriptorSha256: baselineDescriptorSha256,
targetDescriptorSha256,
predecessorGatewaySha256,
targetGatewaySha256,
recoveryArtifact: "nodedc-engine-l2-closed-loop-20260723-030.tgz",
actorPlanes: ["external_codex_mcp", "ai_workspace", "engine_ui"],
untouched: [
"L2 graph data",
"credentials",
"databases",
"n8n runtime",
"AI Workspace connector",
],
}, null, 2));
} finally {
await rm(stage, { recursive: true, force: true });
}
function assertSourceCommit() {
const actual = run("git", ["-C", engineRoot, "rev-parse", "HEAD"]).stdout.trim();
if (actual !== sourceCommit) {
throw new Error(
`engine_l2_closed_loop_source_commit_mismatch:expected=${sourceCommit}:actual=${actual}`,
);
}
}
async function assertExactSources() {
const sourceEntries = entries.filter((entry) => entry !== descriptorRel);
const expectedPaths = Object.keys(targetSha256)
.filter((entry) => entry !== descriptorRel)
.sort();
const actualPaths = [];
for (const entry of sourceEntries) {
const source = join(engineRoot, entry);
const info = await lstat(source);
if (info.isSymbolicLink() || (!info.isFile() && !info.isDirectory())) {
throw new Error(`engine_l2_closed_loop_source_unsafe:${entry}`);
}
if (info.isDirectory()) {
const result = run("find", [source, "-type", "f", "-print"]);
for (const path of result.stdout.split("\n").filter(Boolean)) {
actualPaths.push(path.slice(engineRoot.length + 1));
}
} else {
actualPaths.push(entry);
}
}
actualPaths.sort();
if (JSON.stringify(actualPaths) !== JSON.stringify(expectedPaths)) {
throw new Error(
`engine_l2_closed_loop_source_set_mismatch:` +
`expected=${expectedPaths.join(",")}:actual=${actualPaths.join(",")}`,
);
}
for (const relativePath of expectedPaths) {
const actual = digest(await readFile(join(engineRoot, relativePath)));
if (actual !== targetSha256[relativePath]) {
throw new Error(
`engine_l2_closed_loop_target_mismatch:${relativePath}:` +
`expected=${targetSha256[relativePath]}:actual=${actual}`,
);
}
}
}
async function buildTargetDescriptor() {
const baselineBytes = await readFile(baselineArtifact);
if (digest(baselineBytes) !== baselineArtifactSha256) {
throw new Error("engine_l2_closed_loop_baseline_artifact_mismatch");
}
const baseline = run("tar", [
"-xOf",
baselineArtifact,
`payload/${descriptorRel}`,
]).stdout;
if (digest(Buffer.from(baseline, "utf8")) !== baselineDescriptorSha256) {
throw new Error("engine_l2_closed_loop_baseline_descriptor_mismatch");
}
const descriptor = JSON.parse(baseline);
if (
descriptor?.schemaVersion !==
"nodedc.engine-node-intelligence-transition/v1" ||
descriptor?.action !== "activate" ||
descriptor?.releaseId !== "2.33.2-974a9fb3492f" ||
descriptor?.source?.gatewaySha256 !== predecessorGatewaySha256 ||
descriptor?.source?.upstreamProjectionSha256 !==
"2dfa6b4f37d9bfa8b92a8109d4060d02dd2634ceb8f7924504b83ccf3fdd1523"
) {
throw new Error("engine_l2_closed_loop_baseline_descriptor_contract_mismatch");
}
descriptor.source.gatewaySha256 = targetGatewaySha256;
const rendered = `${JSON.stringify(descriptor, null, 2)}\n`;
if (digest(Buffer.from(rendered, "utf8")) !== targetDescriptorSha256) {
throw new Error("engine_l2_closed_loop_target_descriptor_mismatch");
}
return rendered;
}
async function assertFresh(path) {
try {
await lstat(path);
} catch (error) {
if (error?.code === "ENOENT") return;
throw error;
}
throw new Error("artifact_already_exists");
}
function canonicalTarScript() {
return [
"import gzip,io,pathlib,sys,tarfile",
"root=pathlib.Path(sys.argv[2])",
"with open(sys.argv[1],'xb') as out:",
" with gzip.GzipFile(filename='',mode='wb',fileobj=out,compresslevel=9,mtime=0) as gz:",
" with tarfile.open(fileobj=gz,mode='w',format=tarfile.PAX_FORMAT) as tar:",
" for top in ('manifest.env','files.txt','payload'):",
" p=root/top; paths=[p]+(sorted(p.rglob('*')) if p.is_dir() else [])",
" for x in paths:",
" info=tar.gettarinfo(str(x),arcname=x.relative_to(root).as_posix())",
" info.uid=info.gid=0; info.uname=info.gname='root'; info.mtime=0; info.mode=0o755 if info.isdir() else 0o644",
" with (open(x,'rb') if info.isfile() else io.BytesIO()) as src: tar.addfile(info,src if info.isfile() else None)",
].join("\n");
}
function digest(value) {
return createHash("sha256").update(value).digest("hex");
}
function run(command, args) {
const result = spawnSync(command, args, {
encoding: "utf8",
maxBuffer: 128 * 1024 * 1024,
stdio: ["ignore", "pipe", "pipe"],
});
if (result.status !== 0) {
throw new Error(`${command}_failed:${result.stderr || result.stdout}`);
}
return result;
}

View File

@ -0,0 +1,157 @@
#!/usr/bin/env node
import { createHash } from 'node:crypto'
import { spawnSync } from 'node:child_process'
import { cp, lstat, mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { dirname, join, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
const here = dirname(fileURLToPath(import.meta.url))
const platformRoot = resolve(here, '../..')
const engineRoot = resolve(
process.env.NODEDC_ENGINE_SOURCE_ROOT || resolve(platformRoot, '../NODEDC_ENGINE_INFRA'),
)
const canonicalArtifactRoot = resolve(here, '../deploy-artifacts')
const artifactRoot = resolve(process.env.NODEDC_DEPLOY_ARTIFACT_DIR || canonicalArtifactRoot)
const [transitionId = '20260720-004', ...extra] = process.argv.slice(2)
if (extra.length || !/^\d{8}-[0-9]{3}$/.test(transitionId)) {
throw new Error('usage: build-engine-mcp-autonomy-provider-v5-artifact.mjs [YYYYMMDD-NNN]')
}
const id = `engine-mcp-autonomy-provider-v5-${transitionId}`
const target = join(artifactRoot, `nodedc-${id}.tgz`)
const predecessorArtifact = join(
canonicalArtifactRoot,
'nodedc-engine-mcp-control-plane-20260718-003.tgz',
)
const predecessorArtifactSha256 = '249aef9527666c562e9648b15737e66cc5c1dc7c0788b58ea714da270b5eb4ba'
const descriptorRel = 'nodedc-source/services/node-intelligence/activation.json'
const gatewayRel = 'nodedc-source/server/routes/engineAgentGateway.js'
const files = [
'nodedc-source/server/assets/engine-agent-npm/bin/nodedc-engine-codex-agent.mjs',
'nodedc-source/server/assets/engine-agent-npm/package.json',
'nodedc-source/server/assets/nodedc-engine-codex-agent-0.1.6.tgz',
'nodedc-source/server/assets/provider-packages/v1/catalog.json',
'nodedc-source/server/engineAgents/store.js',
gatewayRel,
descriptorRel,
]
const expectedSha256 = new Map([
[files[0], 'c15c9da4f90f44a4e9e12f3683127e906d614fb98e562faa0c939505c973e074'],
[files[1], '2ca8dcab0fa04bb1b21add1f75a9be61d5aa97753443ee1917302b4e0da5780a'],
[files[2], 'd007a81cb4e4af569b3c54d3869b0f60b5597e3b531bff232145fa1851d8572a'],
[files[3], '63e0741646197f0b1b3c64a4095e1bc8fb3a95ee6caf20b0293f89d869c9e620'],
[files[4], '4cd4bdd5958cfafee184e98a04fe12aa0c1cbe884326beaec63c99f9fff61285'],
[files[5], '5331f6dc8dc306f641370a2968eb025ee3e278e27ae9a217e4cfc2901fec369c'],
])
await assertFresh(target)
assertSha(await readFile(predecessorArtifact), predecessorArtifactSha256, 'predecessor MCP artifact')
const stage = await mkdtemp(join(tmpdir(), 'nodedc-engine-mcp-autonomy-provider-v5-'))
const payload = join(stage, 'payload')
try {
await mkdir(payload, { recursive: true })
for (const rel of files.slice(0, -1)) {
const source = join(engineRoot, rel)
const stat = await lstat(source)
if (!stat.isFile() || stat.isSymbolicLink()) throw new Error(`source_boundary_invalid:${rel}`)
assertSha(await readFile(source), expectedSha256.get(rel), rel)
await mkdir(dirname(join(payload, rel)), { recursive: true })
await cp(source, join(payload, rel), { force: false })
}
const descriptor = JSON.parse(extractMember(
predecessorArtifact,
`payload/${descriptorRel}`,
))
if (
descriptor?.action !== 'activate'
|| descriptor?.releaseId !== '2.33.2-974a9fb3492f'
|| descriptor?.source?.gatewaySha256 !== '96c726dab5cf1341f74e6e1095d518058ca320e0dd5738e25bdbe75db1f4fc15'
|| descriptor?.source?.upstreamProjectionSha256 !== '761a874b102a938bc6018159ddacdaac71ad6ae08e9f0f8d7f3b58a0165a5131'
) throw new Error('mcp_autonomy_predecessor_descriptor_mismatch')
descriptor.source.gatewaySha256 = expectedSha256.get(gatewayRel)
await mkdir(dirname(join(payload, descriptorRel)), { recursive: true })
await writeFile(join(payload, descriptorRel), `${JSON.stringify(descriptor, null, 2)}\n`, 'utf8')
await writeFile(join(stage, 'manifest.env'), `id=${id}\ncomponent=engine\ntype=app-overlay\n`, 'utf8')
await writeFile(join(stage, 'files.txt'), `${files.join('\n')}\n`, 'utf8')
await mkdir(artifactRoot, { recursive: true })
run('python3', ['-c', canonicalTarScript(), target, stage])
console.log(JSON.stringify({
ok: true,
id,
artifact: target,
artifactSha256: sha(await readFile(target)),
services: ['nodedc-backend'],
mcpVersion: '0.6.0',
installerVersion: '0.1.6',
authority: 'mcp-capability-intersect-user-objective',
retryBoundary: 'three-identical-failures-without-new-evidence',
providerPackages: ['gelios.provider.v4', 'gelios.provider.v5'],
targetDataProduct: 'fleet.positions.current.v4',
preserved: ['n8n', 'L1', 'node-intelligence image', 'databases', 'credential values'],
files,
}, null, 2))
} finally {
await rm(stage, { recursive: true, force: true })
}
async function assertFresh(path) {
try {
await lstat(path)
} catch (error) {
if (error?.code === 'ENOENT') return
throw error
}
throw new Error('artifact_already_exists')
}
function extractMember(archive, member) {
const script = [
'import pathlib,sys,tarfile',
'p=pathlib.Path(sys.argv[1]); name=sys.argv[2]',
"with tarfile.open(p,'r:gz') as t:",
' m=t.getmember(name)',
" if not m.isfile(): raise SystemExit('member-not-file')",
' f=t.extractfile(m)',
" if f is None: raise SystemExit('member-unreadable')",
' sys.stdout.buffer.write(f.read())',
].join('\n')
return run('python3', ['-c', script, archive, member]).stdout
}
function canonicalTarScript() {
return [
'import gzip,io,pathlib,sys,tarfile',
'root=pathlib.Path(sys.argv[2])',
"with open(sys.argv[1],'xb') as out:",
" with gzip.GzipFile(filename='',mode='wb',fileobj=out,compresslevel=9,mtime=0) as gz:",
" with tarfile.open(fileobj=gz,mode='w',format=tarfile.PAX_FORMAT) as tar:",
" for top in ('manifest.env','files.txt','payload'):",
" p=root/top; paths=[p]+(sorted(p.rglob('*')) if p.is_dir() else [])",
' for x in paths:',
' info=tar.gettarinfo(str(x),arcname=x.relative_to(root).as_posix())',
" info.uid=info.gid=0; info.uname=info.gname='root'; info.mtime=0; info.mode=0o755 if info.isdir() else 0o644",
" with (open(x,'rb') if info.isfile() else io.BytesIO()) as src: tar.addfile(info,src if info.isfile() else None)",
].join('\n')
}
function assertSha(bytes, expected, label) {
const actual = sha(bytes)
if (!expected || actual !== expected) throw new Error(`${label.replaceAll(' ', '_')}_sha256_mismatch:${actual}`)
}
function sha(bytes) {
return createHash('sha256').update(bytes).digest('hex')
}
function run(command, args) {
const result = spawnSync(command, args, {
encoding: 'utf8',
maxBuffer: 128 * 1024 * 1024,
stdio: ['ignore', 'pipe', 'pipe'],
})
if (result.status !== 0) throw new Error(`${command}_failed:${result.stderr || result.stdout}`)
return result
}

View File

@ -0,0 +1,186 @@
#!/usr/bin/env node
import { createHash } from "node:crypto";
import { spawnSync } from "node:child_process";
import { copyFile, lstat, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
const scriptDir = dirname(fileURLToPath(import.meta.url));
const workspaceRoot = resolve(scriptDir, "../../..");
const engineRoot = resolve(
process.env.NODEDC_ENGINE_SOURCE_ROOT
|| join(workspaceRoot, "NODEDC_ENGINE_INFRA"),
);
const artifactDir = resolve(
process.env.NODEDC_DEPLOY_ARTIFACT_DIR || resolve(scriptDir, "../deploy-artifacts"),
);
const [patchId = "engine-mcp-classified-aspects-20260724-039", ...extra] =
process.argv.slice(2);
if (extra.length || !/^engine-mcp-classified-aspects-\d{8}-\d{3}$/.test(patchId)) {
throw new Error(
"usage: build-engine-mcp-classified-aspects-artifact.mjs "
+ "[engine-mcp-classified-aspects-YYYYMMDD-NNN]",
);
}
const files = Object.freeze([
"nodedc-source/server/l2ExecutionPlan/compiler.js",
"nodedc-source/server/assets/execution-plans/v1/catalog.json",
"nodedc-source/server/assets/provider-packages/v1/catalog.json",
]);
const expectedSha256 = Object.freeze({
"nodedc-source/server/l2ExecutionPlan/compiler.js":
"4854a62fb44cb3dd715f2cb8728740575453a666f5c31abe9d41bc2562be5e95",
"nodedc-source/server/assets/execution-plans/v1/catalog.json":
"2e70b607b4a347592ce2f4732f6da0781ac94ec4829ac336021cb501fdafe9aa",
"nodedc-source/server/assets/provider-packages/v1/catalog.json":
"2fce1c623a8acf9c01a43463e6d38eda71ad28379a2d91b2ca40f31f8dccf3c6",
});
const artifact = join(artifactDir, `nodedc-${patchId}.tgz`);
const checksum = `${artifact}.sha256`;
const stage = await mkdtemp(join(tmpdir(), "nodedc-engine-classified-aspects-"));
await assertFresh(artifact);
await assertEngineBoundary();
try {
for (const relativePath of files) {
const source = join(engineRoot, relativePath);
const info = await lstat(source);
if (!info.isFile() || info.isSymbolicLink()) {
throw new Error(`source_file_rejected:${relativePath}`);
}
const destination = join(stage, "payload", relativePath);
await mkdir(dirname(destination), { recursive: true });
await copyFile(source, destination);
}
await writeFile(
join(stage, "manifest.env"),
`id=${patchId}\ncomponent=engine\ntype=app-overlay\n`,
"utf8",
);
await writeFile(join(stage, "files.txt"), `${files.join("\n")}\n`, "utf8");
await mkdir(artifactDir, { recursive: true });
run("python3", ["-c", canonicalTarScript(), artifact, stage]);
const sha256 = digest(await readFile(artifact));
await writeFile(checksum, `${sha256} ${artifact.split("/").at(-1)}\n`, "utf8");
console.log(JSON.stringify({
ok: true,
patchId,
artifact,
checksum,
sha256,
services: ["nodedc-backend", "app"],
engineCorePolicy: "provider-neutral-scalar-visibility-guards",
providerPackage: "gelios.provider.v10",
files,
expectedSha256,
untouched: [
"live L2 graphs",
"n8n L1",
"Engine UI source and dist",
"node-intelligence",
"databases",
"credentials",
"embedded Codex",
],
}, null, 2));
} finally {
await rm(stage, { recursive: true, force: true });
}
async function assertEngineBoundary() {
for (const relativePath of files) {
const actual = digest(await readFile(join(engineRoot, relativePath)));
if (actual !== expectedSha256[relativePath]) {
throw new Error(
`classified_aspect_target_mismatch:${relativePath}:`
+ `expected=${expectedSha256[relativePath]}:actual=${actual}`,
);
}
}
const compiler = await readFile(join(engineRoot, files[0]), "utf8");
if (
!compiler.includes("guardMatches")
|| !compiler.includes("'1.3.0'")
|| /gelios|fleet\.units\.contacts/i.test(compiler)
) {
throw new Error("classified_aspect_compiler_boundary_invalid");
}
const executionCatalog = JSON.parse(
await readFile(join(engineRoot, files[1]), "utf8"),
);
const providerPackage = executionCatalog?.packages?.find(
(entry) => entry?.id === "gelios.provider.v10",
);
if (
providerPackage?.version !== "10.0.0"
|| !executionCatalog?.runtime?.compilerVersions?.includes("1.3.0")
|| !providerPackage?.profiles?.some(
(profile) =>
profile?.dataProductId === "fleet.units.contacts.current.v1",
)
) {
throw new Error("classified_aspect_execution_catalog_invalid");
}
const securityCatalog = JSON.parse(
await readFile(join(engineRoot, files[2]), "utf8"),
);
const securityPackage = securityCatalog?.packages?.find(
(entry) => entry?.id === "gelios.provider.v10",
);
if (
securityPackage?.version !== "10.0.0"
|| securityPackage?.capabilities?.length !== 1
|| JSON.stringify(securityPackage.capabilities[0]?.dataProductIds)
!== JSON.stringify(["fleet.units.contacts.current.v1"])
) {
throw new Error("classified_aspect_security_catalog_invalid");
}
}
async function assertFresh(path) {
try {
await lstat(path);
} catch (error) {
if (error?.code === "ENOENT") return;
throw error;
}
throw new Error("artifact_already_exists");
}
function canonicalTarScript() {
return [
"import gzip,io,pathlib,sys,tarfile",
"root=pathlib.Path(sys.argv[2])",
"with open(sys.argv[1],'wb') as out:",
" with gzip.GzipFile(filename='',mode='wb',fileobj=out,compresslevel=9,mtime=0) as gz:",
" with tarfile.open(fileobj=gz,mode='w',format=tarfile.PAX_FORMAT) as tar:",
" for top in ('manifest.env','files.txt','payload'):",
" p=root/top; paths=[p]+(sorted(p.rglob('*')) if p.is_dir() else [])",
" for x in paths:",
" info=tar.gettarinfo(str(x),arcname=x.relative_to(root).as_posix())",
" info.uid=info.gid=0; info.uname=info.gname='root'; info.mtime=0; info.mode=0o755 if info.isdir() else 0o644",
" with (open(x,'rb') if info.isfile() else io.BytesIO()) as src: tar.addfile(info,src if info.isfile() else None)",
].join("\n");
}
function digest(value) {
return createHash("sha256").update(value).digest("hex");
}
function run(command, args) {
const result = spawnSync(command, args, {
encoding: "utf8",
maxBuffer: 64 * 1024 * 1024,
});
if (result.status !== 0) {
throw new Error(`${command}_failed:${result.stderr || result.stdout}`);
}
}

View File

@ -0,0 +1,177 @@
#!/usr/bin/env node
import { createHash } from "node:crypto";
import { spawnSync } from "node:child_process";
import { cp, lstat, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
const here = dirname(fileURLToPath(import.meta.url));
const platformRoot = resolve(here, "../..");
const engineRoot = resolve(
process.env.NODEDC_ENGINE_SOURCE_ROOT || resolve(platformRoot, "../NODEDC_ENGINE_INFRA"),
);
const canonicalArtifactRoot = resolve(here, "../deploy-artifacts");
const artifactRoot = resolve(process.env.NODEDC_DEPLOY_ARTIFACT_DIR || canonicalArtifactRoot);
const [transitionId = "20260718-003", ...extra] = process.argv.slice(2);
if (extra.length || !/^\d{8}-[0-9]{3}$/.test(transitionId)) {
throw new Error("usage: build-engine-mcp-control-plane-artifact.mjs [YYYYMMDD-NNN]");
}
const id = `engine-mcp-control-plane-${transitionId}`;
const target = join(artifactRoot, `nodedc-${id}.tgz`);
const nodeIntelligenceArtifact = join(
canonicalArtifactRoot,
"nodedc-engine-node-intelligence-20260717-001.tgz",
);
const nodeIntelligenceArtifactSha256 = "d126afaa0c714fef26362aad4749e3f4647f551d6eb975f0133cdf9ff2e6fc4f";
const descriptorRel = "nodedc-source/services/node-intelligence/activation.json";
const gatewayRel = "nodedc-source/server/routes/engineAgentGateway.js";
const upstreamProjectionRel = "nodedc-source/server/nodeIntelligence/upstreamProjection.js";
const files = [
"nodedc-source/server/assets/engine-agent-npm/bin/nodedc-engine-codex-agent.mjs",
"nodedc-source/server/assets/engine-agent-npm/package.json",
"nodedc-source/server/assets/nodedc-engine-codex-agent-0.1.4.tgz",
"nodedc-source/server/dataProductPublishGrant/providerCatalog.js",
"nodedc-source/server/dataProductPublishGrant/signedDataPlaneClient.js",
"nodedc-source/server/dataProductReadGrant/acceptance.js",
"nodedc-source/server/dataProductReadGrant/service.js",
"nodedc-source/server/dataProductReadGrant/store.js",
"nodedc-source/server/engineAgents/store.js",
upstreamProjectionRel,
gatewayRel,
"nodedc-source/server/routes/n8n.js",
"nodedc-source/server/routes/ndcAgentMcp.js",
"nodedc-source/services/backend/data-product-read-grant/docker-compose.immutable-runtime.yml",
"nodedc-source/server/dataProductPublishGrant/service.js",
"nodedc-source/server/dataProductPublishGrant/store.js",
descriptorRel,
];
const expectedSha256 = new Map([
[files[0], "521098de69fe288a56bd4158c849c1055ba24be08e19f7a4111618d0e8138445"],
[files[1], "cbe113e9b10bb84b9ccbffa3e261908e58a8430c11abe2cf4fd5304741b04597"],
[files[2], "e74c0136d346f904b42589d75cb11a952b4d2f21153f1b057fba28e9acf4f95f"],
[files[3], "901b8fad80018ce177b34ced804b39cb140a47e831414057f484296b373c651d"],
[files[4], "c2a13d5eb49937fe70ec6451d0465cac54c19c7fae44448db099079473ec02fc"],
[files[5], "202dbe575b2f2e1c584aa7e6e99e38fa84f12496feb454e3dbd3f98e2d0396dd"],
[files[6], "65b8cdd6b603333bac1feb303e9791feb1e9da39dc9b5bfc3df3961273b0052e"],
[files[7], "c3fcdc59a794f57d92e3d2ac713ee28341347cebd25364c4060beaa3dc2151de"],
[files[8], "debd351fe0b8c72b33b8c79909b8f339cbaf061b970576f3ae72df52ebaa211f"],
[files[9], "761a874b102a938bc6018159ddacdaac71ad6ae08e9f0f8d7f3b58a0165a5131"],
[files[10], "96c726dab5cf1341f74e6e1095d518058ca320e0dd5738e25bdbe75db1f4fc15"],
[files[11], "f293a7794405badbabd2bf7ef088f96fe1167d9e249f05cbfc8af280a0d3e8f8"],
[files[12], "534e2c85e1faecc72a00da7ad32d0584ee09b6bd89eeec2221c3b23d80e1e962"],
[files[13], "952df0cb2ac477e644f5f3a9d64b4872ce1da33356eeab0bec17dcb2931cf653"],
[files[14], "ded3832c9677345a988448ae8e69cef254fd9bf39d2de20cffa295c1feedcd7c"],
[files[15], "a92303b2732e21f68c1ac732fa26e4983cbadf3515741cee983b916a283d754c"],
]);
await assertFresh(target);
assertSha(await readFile(nodeIntelligenceArtifact), nodeIntelligenceArtifactSha256, "node intelligence artifact");
const stage = await mkdtemp(join(tmpdir(), "nodedc-engine-mcp-control-plane-"));
const payload = join(stage, "payload");
try {
await mkdir(payload, { recursive: true });
for (const rel of files.slice(0, -1)) {
const source = join(engineRoot, rel);
const stat = await lstat(source);
if (!stat.isFile() || stat.isSymbolicLink()) throw new Error(`source_boundary_invalid:${rel}`);
assertSha(await readFile(source), expectedSha256.get(rel), rel);
await mkdir(dirname(join(payload, rel)), { recursive: true });
await cp(source, join(payload, rel), { force: false });
}
const descriptorText = extractMember(
nodeIntelligenceArtifact,
`payload/${descriptorRel}`,
);
const descriptor = JSON.parse(descriptorText);
if (
descriptor?.action !== "activate"
|| descriptor?.releaseId !== "2.33.2-974a9fb3492f"
|| descriptor?.source?.gatewaySha256 !== "9642c76fd5765a8a20629c8d09e16579a1c3b2cfb7bdbab38cf55af469d8908f"
) throw new Error("node_intelligence_predecessor_descriptor_mismatch");
descriptor.source.gatewaySha256 = expectedSha256.get(gatewayRel);
descriptor.source.upstreamProjectionSha256 = expectedSha256.get(upstreamProjectionRel);
await mkdir(dirname(join(payload, descriptorRel)), { recursive: true });
await writeFile(join(payload, descriptorRel), `${JSON.stringify(descriptor, null, 2)}\n`, "utf8");
await writeFile(join(stage, "manifest.env"), `id=${id}\ncomponent=engine\ntype=app-overlay\n`, "utf8");
await writeFile(join(stage, "files.txt"), `${files.join("\n")}\n`, "utf8");
await mkdir(artifactRoot, { recursive: true });
run("python3", ["-c", canonicalTarScript(), target, stage]);
const artifactSha256 = sha(await readFile(target));
console.log(JSON.stringify({
ok: true,
id,
artifact: target,
artifactSha256,
services: ["nodedc-backend"],
predecessorGatewaySha256: "9642c76fd5765a8a20629c8d09e16579a1c3b2cfb7bdbab38cf55af469d8908f",
targetGatewaySha256: expectedSha256.get(gatewayRel),
mcpVersion: "0.5.0",
installerVersion: "0.1.4",
files,
}, null, 2));
} finally {
await rm(stage, { recursive: true, force: true });
}
async function assertFresh(path) {
try {
await lstat(path);
} catch (error) {
if (error?.code === "ENOENT") return;
throw error;
}
throw new Error("artifact_already_exists");
}
function extractMember(archive, member) {
const script = [
"import pathlib,sys,tarfile",
"p=pathlib.Path(sys.argv[1]); name=sys.argv[2]",
"with tarfile.open(p,'r:gz') as t:",
" m=t.getmember(name)",
" if not m.isfile(): raise SystemExit('member-not-file')",
" f=t.extractfile(m)",
" if f is None: raise SystemExit('member-unreadable')",
" sys.stdout.buffer.write(f.read())",
].join("\n");
return run("python3", ["-c", script, archive, member]).stdout;
}
function canonicalTarScript() {
return [
"import gzip,io,pathlib,sys,tarfile",
"root=pathlib.Path(sys.argv[2])",
"with open(sys.argv[1],'wb') as out:",
" with gzip.GzipFile(filename='',mode='wb',fileobj=out,compresslevel=9,mtime=0) as gz:",
" with tarfile.open(fileobj=gz,mode='w',format=tarfile.PAX_FORMAT) as tar:",
" for top in ('manifest.env','files.txt','payload'):",
" p=root/top; paths=[p]+(sorted(p.rglob('*')) if p.is_dir() else [])",
" for x in paths:",
" info=tar.gettarinfo(str(x),arcname=x.relative_to(root).as_posix())",
" info.uid=info.gid=0; info.uname=info.gname='root'; info.mtime=0; info.mode=0o755 if info.isdir() else 0o644",
" with (open(x,'rb') if info.isfile() else io.BytesIO()) as src: tar.addfile(info,src if info.isfile() else None)",
].join("\n");
}
function assertSha(bytes, expected, label) {
const actual = sha(bytes);
if (!expected || actual !== expected) throw new Error(`${label.replaceAll(" ", "_")}_sha256_mismatch:${actual}`);
}
function sha(bytes) {
return createHash("sha256").update(bytes).digest("hex");
}
function run(command, args) {
const result = spawnSync(command, args, {
encoding: "utf8",
maxBuffer: 128 * 1024 * 1024,
stdio: ["ignore", "pipe", "pipe"],
});
if (result.status !== 0) throw new Error(`${command}_failed:${result.stderr || result.stdout}`);
return result;
}

View File

@ -0,0 +1,289 @@
#!/usr/bin/env node
import { createHash } from "node:crypto";
import { spawnSync } from "node:child_process";
import { copyFile, lstat, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
const here = dirname(fileURLToPath(import.meta.url));
const workspaceRoot = resolve(here, "../../..");
const engineRoot = resolve(
process.env.NODEDC_ENGINE_SOURCE_ROOT || join(workspaceRoot, "NODEDC_ENGINE_INFRA"),
);
const artifactRoot = resolve(
process.env.NODEDC_DEPLOY_ARTIFACT_DIR || resolve(here, "../deploy-artifacts"),
);
const baselineArtifact = resolve(
here,
"../deploy-artifacts/nodedc-engine-mcp-telemetry-catalog-20260723-035.tgz",
);
const baselineArtifactSha256 =
"4ce1563612499a19d653a174a4d73632e0aaac7dacd2eca4e7c01c87586b1155";
const nodeIntelligenceDescriptorPath =
"nodedc-source/services/node-intelligence/activation.json";
const predecessorNodeIntelligenceDescriptorSha256 =
"25ed3efd858aaf82c242dba501f0acc0c6c3dc91e8845707a4d3325750eab59f";
const targetNodeIntelligenceDescriptorSha256 =
"03b2ba120c3929e9cf99940ddc927082de376ceff762895a84103144202aef42";
const predecessorGatewaySha256 =
"69bfc91e913a3fad04e13aca86efb9d62f73c0c7d1f8f7200907494b29fa9e8d";
const targetGatewaySha256 =
"9020cf49a3558c0497fed4ecfd81367cbc11b5b249881804c29fe437900c71f8";
const [patchId = "", ...extra] = process.argv.slice(2);
if (
extra.length
|| !/^engine-mcp-execution-plan-materialization-\d{8}-\d{3}$/.test(patchId)
) {
throw new Error(
"usage: build-engine-mcp-execution-plan-materialization-artifact.mjs "
+ "<engine-mcp-execution-plan-materialization-YYYYMMDD-NNN>",
);
}
const descriptorPath =
"nodedc-source/server/deployTransitions/executionPlanMaterializationV1.json";
const targetSha256 = Object.freeze({
"nodedc-source/server/routes/n8n.js":
"391fc81228fdacd6efc6c0868991a3485f71869708e49ac15819db6ccce1bfce",
"nodedc-source/server/routes/engineAgentGateway.js":
targetGatewaySha256,
"nodedc-source/server/l2ExecutionPlan/catalog.js":
"c35b4c7ad9319aabbf1366a11ff52a6e99d961893bafdfcb4e84fc5f24fc04be",
"nodedc-source/server/l2ExecutionPlan/compiler.js":
"beb3f664073f9a372432643936a04d0cb0028cd695f759c68bd053e9cd892fe4",
"nodedc-source/server/l2ExecutionPlan/materializer.js":
"ee3bcfd06b3a5fa46800df974a2dedfdd55eeaf662329f837486c011a9bd713e",
"nodedc-source/server/assets/execution-plans/v1/catalog.json":
"a153e040e0a592bad4375b98d9a1d83d148923aa0f0fd47d225b92eda281c4e9",
[descriptorPath]:
"52c0152cff49c251be5581a9209d2e63ba710c16e815bdd6ece24f7c9dd7e480",
[nodeIntelligenceDescriptorPath]:
targetNodeIntelligenceDescriptorSha256,
});
const entries = Object.freeze(Object.keys(targetSha256));
const artifact = join(artifactRoot, `nodedc-${patchId}.tgz`);
await assertFresh(artifact);
await assertExactSources();
const targetNodeIntelligenceDescriptor =
await buildTargetNodeIntelligenceDescriptor();
const stage = await mkdtemp(
join(tmpdir(), "nodedc-engine-mcp-execution-plan-materialization-"),
);
try {
const payload = join(stage, "payload");
for (const relativePath of entries) {
const destination = join(payload, relativePath);
await mkdir(dirname(destination), { recursive: true });
if (relativePath === nodeIntelligenceDescriptorPath) {
await writeFile(destination, targetNodeIntelligenceDescriptor, {
encoding: "utf8",
flag: "wx",
mode: 0o644,
});
continue;
}
await copyFile(join(engineRoot, relativePath), destination);
}
await writeFile(
join(stage, "manifest.env"),
`id=${patchId}\ncomponent=engine\ntype=app-overlay\n`,
{ encoding: "utf8", flag: "wx", mode: 0o644 },
);
await writeFile(join(stage, "files.txt"), `${entries.join("\n")}\n`, {
encoding: "utf8",
flag: "wx",
mode: 0o644,
});
await mkdir(artifactRoot, { recursive: true });
run("python3", ["-c", canonicalTarScript(), artifact, stage]);
console.log(JSON.stringify({
ok: true,
patchId,
artifact,
sha256: digest(await readFile(artifact)),
entries,
targetSha256,
services: ["nodedc-backend"],
transition: "telemetry-catalog-v1-to-execution-plan-materialization-v1",
mcpVersion: "0.9.0",
mcpSurface: "external-codex",
mcpTools: [
"engine_plan_l2_execution_plan_materialization",
"engine_apply_l2_execution_plan_materialization",
],
providerLogicAuthority: "trusted-provider-package",
unmanagedGraphPolicy: "explicit-adoption-required",
nodeIntelligenceRelease: "2.33.2-974a9fb3492f",
predecessorGatewaySha256,
targetGatewaySha256,
predecessorNodeIntelligenceDescriptorSha256,
targetNodeIntelligenceDescriptorSha256,
untouched: [
"live L2 graphs",
"n8n workflow data",
"L1",
"Engine UI",
"node-intelligence image",
"databases",
"credentials",
"MCP Nginx",
"embedded AI Workspace",
],
}, null, 2));
} finally {
await rm(stage, { recursive: true, force: true });
}
async function assertExactSources() {
for (const [relativePath, expected] of Object.entries(targetSha256)) {
if (relativePath === nodeIntelligenceDescriptorPath) continue;
const sourcePath = join(engineRoot, relativePath);
const info = await lstat(sourcePath);
if (!info.isFile() || info.isSymbolicLink()) {
throw new Error(
`engine_mcp_execution_plan_materialization_source_unsafe:${relativePath}`,
);
}
const actual = digest(await readFile(sourcePath));
if (actual !== expected) {
throw new Error(
`engine_mcp_execution_plan_materialization_target_mismatch:${relativePath}:`
+ `expected=${expected}:actual=${actual}`,
);
}
}
const gatewaySource = await readFile(
join(engineRoot, "nodedc-source/server/routes/engineAgentGateway.js"),
"utf8",
);
for (const marker of [
"const ENGINE_AGENT_MCP_VERSION = '0.9.0'",
"name: 'engine_plan_l2_execution_plan_materialization'",
"name: 'engine_apply_l2_execution_plan_materialization'",
"'execution-plan.apply'",
]) {
if (!gatewaySource.includes(marker)) {
throw new Error(
`engine_mcp_execution_plan_materialization_gateway_marker_missing:${marker}`,
);
}
}
if (/gelios|robot2b/i.test(
await readFile(
join(engineRoot, "nodedc-source/server/l2ExecutionPlan/compiler.js"),
"utf8",
),
)) {
throw new Error("engine_mcp_execution_plan_materialization_provider_hardcode");
}
const descriptor = JSON.parse(
await readFile(join(engineRoot, descriptorPath), "utf8"),
);
if (
descriptor?.schemaVersion !== "nodedc.engine.deploy-transition/v1"
|| descriptor?.id !== "engine-mcp-l2-execution-plan-materialization-v1"
|| descriptor?.mcpVersion !== "0.9.0"
|| descriptor?.plan?.tool
!== "engine_plan_l2_execution_plan_materialization"
|| descriptor?.apply?.tool
!== "engine_apply_l2_execution_plan_materialization"
|| descriptor?.providerLogicAuthority !== "trusted-provider-package"
|| descriptor?.unmanagedGraphPolicy !== "explicit-adoption-required"
|| descriptor?.embeddedCodexChanged !== false
) {
throw new Error(
"engine_mcp_execution_plan_materialization_descriptor_contract_mismatch",
);
}
}
async function buildTargetNodeIntelligenceDescriptor() {
const baselineBytes = await readFile(baselineArtifact);
if (digest(baselineBytes) !== baselineArtifactSha256) {
throw new Error(
"engine_mcp_execution_plan_materialization_baseline_artifact_mismatch",
);
}
const baseline = run("tar", [
"-xOf",
baselineArtifact,
`payload/${nodeIntelligenceDescriptorPath}`,
]).stdout;
if (
digest(Buffer.from(baseline, "utf8"))
!== predecessorNodeIntelligenceDescriptorSha256
) {
throw new Error(
"engine_mcp_execution_plan_materialization_baseline_descriptor_mismatch",
);
}
const descriptor = JSON.parse(baseline);
if (
descriptor?.schemaVersion
!== "nodedc.engine-node-intelligence-transition/v1"
|| descriptor?.action !== "activate"
|| descriptor?.releaseId !== "2.33.2-974a9fb3492f"
|| descriptor?.source?.gatewaySha256 !== predecessorGatewaySha256
) {
throw new Error(
"engine_mcp_execution_plan_materialization_baseline_contract_mismatch",
);
}
descriptor.source.gatewaySha256 = targetGatewaySha256;
const rendered = `${JSON.stringify(descriptor, null, 2)}\n`;
if (
digest(Buffer.from(rendered, "utf8"))
!== targetNodeIntelligenceDescriptorSha256
) {
throw new Error(
"engine_mcp_execution_plan_materialization_target_descriptor_mismatch",
);
}
return rendered;
}
async function assertFresh(path) {
try {
await lstat(path);
} catch (error) {
if (error?.code === "ENOENT") return;
throw error;
}
throw new Error("artifact_already_exists");
}
function canonicalTarScript() {
return [
"import gzip,io,pathlib,sys,tarfile",
"root=pathlib.Path(sys.argv[2])",
"with open(sys.argv[1],'xb') as out:",
" with gzip.GzipFile(filename='',mode='wb',fileobj=out,compresslevel=9,mtime=0) as gz:",
" with tarfile.open(fileobj=gz,mode='w',format=tarfile.PAX_FORMAT) as tar:",
" for top in ('manifest.env','files.txt','payload'):",
" p=root/top; paths=[p]+(sorted(p.rglob('*')) if p.is_dir() else [])",
" for x in paths:",
" info=tar.gettarinfo(str(x),arcname=x.relative_to(root).as_posix())",
" info.uid=info.gid=0; info.uname=info.gname='root'; info.mtime=0; info.mode=0o755 if info.isdir() else 0o644",
" with (open(x,'rb') if info.isfile() else io.BytesIO()) as src: tar.addfile(info,src if info.isfile() else None)",
].join("\n");
}
function digest(value) {
return createHash("sha256").update(value).digest("hex");
}
function run(command, args) {
const result = spawnSync(command, args, {
encoding: "utf8",
maxBuffer: 128 * 1024 * 1024,
stdio: ["ignore", "pipe", "pipe"],
});
if (result.status !== 0) {
throw new Error(`${command}_failed:${result.stderr || result.stdout}`);
}
return result;
}

View File

@ -0,0 +1,268 @@
#!/usr/bin/env node
import { createHash } from "node:crypto";
import { spawnSync } from "node:child_process";
import {
copyFile,
lstat,
mkdir,
mkdtemp,
readFile,
rm,
writeFile,
} from "node:fs/promises";
import { tmpdir } from "node:os";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
const here = dirname(fileURLToPath(import.meta.url));
const workspaceRoot = resolve(here, "../../..");
const engineRoot = resolve(
process.env.NODEDC_ENGINE_SOURCE_ROOT || join(workspaceRoot, "NODEDC_ENGINE_INFRA"),
);
const artifactRoot = resolve(
process.env.NODEDC_DEPLOY_ARTIFACT_DIR || resolve(here, "../deploy-artifacts"),
);
const descriptorPath =
"nodedc-source/server/deployTransitions/executionPlanModuleOwnershipV3.json";
const activationPath =
"nodedc-source/services/node-intelligence/activation.json";
const targetSha256 = Object.freeze({
"nodedc-source/server/l2ExecutionPlan/materializer.js":
"dabf0073520049d7a04d1962b29e591ae092b87b287a50534ad2d98b03ae683c",
"nodedc-source/server/routes/engineAgentGateway.js":
"17c5f502b3ceacc45278eef7418d08e1e55a8b3e46e00942e559d25566fdba41",
"nodedc-source/server/assets/execution-plans/v1/catalog.json":
"7e34b93aa30b06cd52853b48013baef2970cc27e4fddc333a1011dc73ea8c08a",
[descriptorPath]:
"d187d539a219fec453e06c55c3f5486ef66059371b4ae9b145266307b2af9889",
[activationPath]:
"4cdeb85ebb2e43f088f095f75b7fc31aabd8ab81c8ac96ded4aafd7dbd8e30bd",
});
const entries = Object.freeze(Object.keys(targetSha256));
const [patchId = "", ...extra] = process.argv.slice(2);
if (
extra.length
|| !/^engine-mcp-execution-plan-module-ownership-\d{8}-\d{3}$/.test(patchId)
) {
throw new Error(
"usage: build-engine-mcp-execution-plan-module-ownership-artifact.mjs "
+ "<engine-mcp-execution-plan-module-ownership-YYYYMMDD-NNN>",
);
}
const artifact = join(artifactRoot, `nodedc-${patchId}.tgz`);
await assertFresh(artifact);
await assertExactSources();
const stage = await mkdtemp(
join(tmpdir(), "nodedc-engine-mcp-execution-plan-module-ownership-"),
);
try {
const payload = join(stage, "payload");
for (const relativePath of entries) {
const destination = join(payload, relativePath);
await mkdir(dirname(destination), { recursive: true });
await copyFile(join(engineRoot, relativePath), destination);
}
await writeFile(
join(stage, "manifest.env"),
`id=${patchId}\ncomponent=engine\ntype=app-overlay\n`,
{ encoding: "utf8", flag: "wx", mode: 0o644 },
);
await writeFile(join(stage, "files.txt"), `${entries.join("\n")}\n`, {
encoding: "utf8",
flag: "wx",
mode: 0o644,
});
await mkdir(artifactRoot, { recursive: true });
run("python3", ["-c", canonicalTarScript(), artifact, stage]);
console.log(JSON.stringify({
ok: true,
patchId,
artifact,
sha256: digest(await readFile(artifact)),
entries,
targetSha256,
services: ["nodedc-backend"],
transition: "execution-plan-telemetry-runtime-v2-to-module-ownership-v3",
mcpVersion: "0.10.0",
mcpSurface: "external-codex",
compilerVersions: ["1.1.0", "1.2.0"],
materializationStrategies: [
"create_or_reconcile_owned",
"adopt_existing",
"adopt_existing_module",
],
moduleOwnership: {
nodeBindings: "exact-one-to-one",
retireBoundary: "closed",
sharedManualWebhook: "compatible-existing-configuration-preserved",
providerCredentialNodes: "engine-managed",
publisherNodes: "engine-managed",
},
providerPackage: {
added: "gelios.provider.v9",
legacyPreserved: "gelios.provider.v8",
},
untouched: [
"live L2 graphs",
"n8n workflow data",
"L1",
"Engine UI",
"node-intelligence image",
"databases",
"credentials",
"MCP Nginx",
"embedded AI Workspace",
],
}, null, 2));
} finally {
await rm(stage, { recursive: true, force: true });
}
async function assertExactSources() {
for (const [relativePath, expected] of Object.entries(targetSha256)) {
const sourcePath = join(engineRoot, relativePath);
const info = await lstat(sourcePath);
if (!info.isFile() || info.isSymbolicLink()) {
throw new Error(
`engine_mcp_execution_plan_module_ownership_source_unsafe:${relativePath}`,
);
}
const actual = digest(await readFile(sourcePath));
if (actual !== expected) {
throw new Error(
`engine_mcp_execution_plan_module_ownership_target_mismatch:${relativePath}:`
+ `expected=${expected}:actual=${actual}`,
);
}
}
const materializer = await readFile(
join(engineRoot, "nodedc-source/server/l2ExecutionPlan/materializer.js"),
"utf8",
);
for (const marker of [
"nodedc.engine.materialized-execution-plan-module/v1",
"adopt_existing_module",
"preserveBoundNodeIds",
"execution_plan_module_retire_boundary_not_closed",
"manual_webhook_same_method",
]) {
if (!materializer.includes(marker)) {
throw new Error(
`engine_mcp_execution_plan_module_ownership_marker_missing:${marker}`,
);
}
}
if (/gelios|robot2b/i.test(materializer)) {
throw new Error("engine_mcp_execution_plan_module_ownership_provider_hardcode");
}
const gateway = await readFile(
join(engineRoot, "nodedc-source/server/routes/engineAgentGateway.js"),
"utf8",
);
if (
!gateway.includes("const ENGINE_AGENT_MCP_VERSION = '0.10.0'")
|| !gateway.includes("preserveBoundNodeIds")
|| !gateway.includes("adopt_existing_module")
) {
throw new Error(
"engine_mcp_execution_plan_module_ownership_gateway_contract_mismatch",
);
}
const catalog = JSON.parse(await readFile(
join(engineRoot, "nodedc-source/server/assets/execution-plans/v1/catalog.json"),
"utf8",
));
const geliosV9 = catalog?.packages?.find(
(providerPackage) => providerPackage?.id === "gelios.provider.v9",
);
if (
geliosV9?.contractDigest
!== "sha256:7dd4ce4a45ce76e884ffa1e304bfaa521f552e9a45e535930f2d17b882ae23ed"
|| !catalog?.packages?.some(
(providerPackage) => providerPackage?.id === "gelios.provider.v8",
)
) {
throw new Error(
"engine_mcp_execution_plan_module_ownership_catalog_contract_mismatch",
);
}
const descriptor = JSON.parse(
await readFile(join(engineRoot, descriptorPath), "utf8"),
);
if (
descriptor?.schemaVersion !== "nodedc.engine.deploy-transition/v1"
|| descriptor?.id !== "engine-mcp-l2-execution-plan-module-ownership-v3"
|| descriptor?.mcpVersion !== "0.10.0"
|| descriptor?.moduleOwnership?.adoptionStrategy !== "adopt_existing_module"
|| descriptor?.sharedBoundary?.configurationAuthority !== "existing-graph"
|| descriptor?.providerPackageTrust?.addedPackageId !== "gelios.provider.v9"
|| descriptor?.providerPackageTrust?.legacyPackagePreserved !== true
|| descriptor?.engineProviderHardcode !== false
|| descriptor?.embeddedCodexChanged !== false
) {
throw new Error(
"engine_mcp_execution_plan_module_ownership_descriptor_contract_mismatch",
);
}
const activation = JSON.parse(
await readFile(join(engineRoot, activationPath), "utf8"),
);
if (
activation?.action !== "activate"
|| activation?.releaseId !== "2.33.2-974a9fb3492f"
|| activation?.source?.gatewaySha256
!== targetSha256["nodedc-source/server/routes/engineAgentGateway.js"]
) {
throw new Error(
"engine_mcp_execution_plan_module_ownership_activation_contract_mismatch",
);
}
}
async function assertFresh(path) {
try {
await lstat(path);
} catch (error) {
if (error?.code === "ENOENT") return;
throw error;
}
throw new Error("artifact_already_exists");
}
function canonicalTarScript() {
return [
"import gzip,io,pathlib,sys,tarfile",
"root=pathlib.Path(sys.argv[2])",
"with open(sys.argv[1],'xb') as out:",
" with gzip.GzipFile(filename='',mode='wb',fileobj=out,compresslevel=9,mtime=0) as gz:",
" with tarfile.open(fileobj=gz,mode='w',format=tarfile.PAX_FORMAT) as tar:",
" for top in ('manifest.env','files.txt','payload'):",
" p=root/top; paths=[p]+(sorted(p.rglob('*')) if p.is_dir() else [])",
" for x in paths:",
" info=tar.gettarinfo(str(x),arcname=x.relative_to(root).as_posix())",
" info.uid=info.gid=0; info.uname=info.gname='root'; info.mtime=0; info.mode=0o755 if info.isdir() else 0o644",
" with (open(x,'rb') if info.isfile() else io.BytesIO()) as src: tar.addfile(info,src if info.isfile() else None)",
].join("\n");
}
function digest(value) {
return createHash("sha256").update(value).digest("hex");
}
function run(command, args) {
const result = spawnSync(command, args, {
encoding: "utf8",
maxBuffer: 128 * 1024 * 1024,
stdio: ["ignore", "pipe", "pipe"],
});
if (result.status !== 0) {
throw new Error(`${command}_failed:${result.stderr || result.stdout}`);
}
return result;
}

View File

@ -0,0 +1,224 @@
#!/usr/bin/env node
import { createHash } from "node:crypto";
import { spawnSync } from "node:child_process";
import {
copyFile,
lstat,
mkdir,
mkdtemp,
readFile,
rm,
writeFile,
} from "node:fs/promises";
import { tmpdir } from "node:os";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
const here = dirname(fileURLToPath(import.meta.url));
const workspaceRoot = resolve(here, "../../..");
const engineRoot = resolve(
process.env.NODEDC_ENGINE_SOURCE_ROOT || join(workspaceRoot, "NODEDC_ENGINE_INFRA"),
);
const artifactRoot = resolve(
process.env.NODEDC_DEPLOY_ARTIFACT_DIR || resolve(here, "../deploy-artifacts"),
);
const descriptorPath =
"nodedc-source/server/deployTransitions/executionPlanTelemetryRuntimeV2.json";
const targetSha256 = Object.freeze({
"nodedc-source/server/l2ExecutionPlan/compiler.js":
"6b783ad15c26dc7de0645082c8a426002d943138c70bf31a240247b16a33b6a0",
"nodedc-source/server/assets/execution-plans/v1/catalog.json":
"5bdfc92284a7c836ff326e3a89b559110e376b32efd34b5f23f2bec272745781",
[descriptorPath]:
"68b275efb6303284336d8b637c966c24d891a4bd0b3fec4fb83247359929ef79",
});
const entries = Object.freeze(Object.keys(targetSha256));
const [patchId = "", ...extra] = process.argv.slice(2);
if (
extra.length
|| !/^engine-mcp-execution-plan-telemetry-runtime-\d{8}-\d{3}$/.test(patchId)
) {
throw new Error(
"usage: build-engine-mcp-execution-plan-telemetry-runtime-artifact.mjs "
+ "<engine-mcp-execution-plan-telemetry-runtime-YYYYMMDD-NNN>",
);
}
const artifact = join(artifactRoot, `nodedc-${patchId}.tgz`);
await assertFresh(artifact);
await assertExactSources();
const stage = await mkdtemp(
join(tmpdir(), "nodedc-engine-mcp-execution-plan-telemetry-runtime-"),
);
try {
const payload = join(stage, "payload");
for (const relativePath of entries) {
const destination = join(payload, relativePath);
await mkdir(dirname(destination), { recursive: true });
await copyFile(join(engineRoot, relativePath), destination);
}
await writeFile(
join(stage, "manifest.env"),
`id=${patchId}\ncomponent=engine\ntype=app-overlay\n`,
{ encoding: "utf8", flag: "wx", mode: 0o644 },
);
await writeFile(join(stage, "files.txt"), `${entries.join("\n")}\n`, {
encoding: "utf8",
flag: "wx",
mode: 0o644,
});
await mkdir(artifactRoot, { recursive: true });
run("python3", ["-c", canonicalTarScript(), artifact, stage]);
console.log(JSON.stringify({
ok: true,
patchId,
artifact,
sha256: digest(await readFile(artifact)),
entries,
targetSha256,
services: ["nodedc-backend"],
transition: "execution-plan-materialization-v1-to-telemetry-runtime-v2",
mcpVersion: "0.9.0",
mcpSurface: "external-codex",
compilerVersions: ["1.1.0", "1.2.0"],
legacyRuntimePreserved: true,
telemetryAuthority: [
"trusted-telemetry-projection",
"visible-sensor-definition",
],
unprojectedParameters: "discarded",
rawProviderPayloadAtPublish: "forbidden",
providerLogicAuthority: "trusted-provider-package",
untouched: [
"live L2 graphs",
"n8n workflow data",
"L1",
"Engine UI",
"node-intelligence image and descriptor",
"databases",
"credentials",
"MCP Nginx",
"embedded AI Workspace",
],
}, null, 2));
} finally {
await rm(stage, { recursive: true, force: true });
}
async function assertExactSources() {
for (const [relativePath, expected] of Object.entries(targetSha256)) {
const sourcePath = join(engineRoot, relativePath);
const info = await lstat(sourcePath);
if (!info.isFile() || info.isSymbolicLink()) {
throw new Error(
`engine_mcp_execution_plan_telemetry_runtime_source_unsafe:${relativePath}`,
);
}
const actual = digest(await readFile(sourcePath));
if (actual !== expected) {
throw new Error(
`engine_mcp_execution_plan_telemetry_runtime_target_mismatch:${relativePath}:`
+ `expected=${expected}:actual=${actual}`,
);
}
}
const compiler = await readFile(
join(engineRoot, "nodedc-source/server/l2ExecutionPlan/compiler.js"),
"utf8",
);
for (const marker of [
"if (compilerVersion === '1.1.0')",
"if (compilerVersion !== '1.2.0')",
'"msgParam", "msg_param"',
"descriptor.telemetryProjection",
'"message-param"',
"convertedSensorValue",
"visibleSensorDefinition",
]) {
if (!compiler.includes(marker)) {
throw new Error(
`engine_mcp_execution_plan_telemetry_runtime_marker_missing:${marker}`,
);
}
}
if (/gelios|robot2b/i.test(compiler)) {
throw new Error("engine_mcp_execution_plan_telemetry_runtime_provider_hardcode");
}
const catalog = JSON.parse(await readFile(
join(
engineRoot,
"nodedc-source/server/assets/execution-plans/v1/catalog.json",
),
"utf8",
));
if (
JSON.stringify(catalog?.runtime?.compilerVersions)
!== JSON.stringify(["1.1.0", "1.2.0"])
) {
throw new Error("engine_mcp_execution_plan_telemetry_runtime_catalog_mismatch");
}
const descriptor = JSON.parse(
await readFile(join(engineRoot, descriptorPath), "utf8"),
);
if (
descriptor?.schemaVersion !== "nodedc.engine.deploy-transition/v1"
|| descriptor?.id
!== "engine-mcp-l2-execution-plan-telemetry-runtime-v2"
|| descriptor?.mcpVersion !== "0.9.0"
|| JSON.stringify(descriptor?.compilerTransition?.supported)
!== JSON.stringify(["1.1.0", "1.2.0"])
|| descriptor?.compilerTransition?.legacyRuntimePreserved !== true
|| descriptor?.providerLogicAuthority !== "trusted-provider-package"
|| descriptor?.engineProviderHardcode !== false
|| descriptor?.embeddedCodexChanged !== false
) {
throw new Error(
"engine_mcp_execution_plan_telemetry_runtime_descriptor_contract_mismatch",
);
}
}
async function assertFresh(path) {
try {
await lstat(path);
} catch (error) {
if (error?.code === "ENOENT") return;
throw error;
}
throw new Error("artifact_already_exists");
}
function canonicalTarScript() {
return [
"import gzip,io,pathlib,sys,tarfile",
"root=pathlib.Path(sys.argv[2])",
"with open(sys.argv[1],'xb') as out:",
" with gzip.GzipFile(filename='',mode='wb',fileobj=out,compresslevel=9,mtime=0) as gz:",
" with tarfile.open(fileobj=gz,mode='w',format=tarfile.PAX_FORMAT) as tar:",
" for top in ('manifest.env','files.txt','payload'):",
" p=root/top; paths=[p]+(sorted(p.rglob('*')) if p.is_dir() else [])",
" for x in paths:",
" info=tar.gettarinfo(str(x),arcname=x.relative_to(root).as_posix())",
" info.uid=info.gid=0; info.uname=info.gname='root'; info.mtime=0; info.mode=0o755 if info.isdir() else 0o644",
" with (open(x,'rb') if info.isfile() else io.BytesIO()) as src: tar.addfile(info,src if info.isfile() else None)",
].join("\n");
}
function digest(value) {
return createHash("sha256").update(value).digest("hex");
}
function run(command, args) {
const result = spawnSync(command, args, {
encoding: "utf8",
maxBuffer: 128 * 1024 * 1024,
stdio: ["ignore", "pipe", "pipe"],
});
if (result.status !== 0) {
throw new Error(`${command}_failed:${result.stderr || result.stdout}`);
}
return result;
}

View File

@ -0,0 +1,176 @@
#!/usr/bin/env node
import { createHash } from "node:crypto";
import { spawnSync } from "node:child_process";
import { copyFile, lstat, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
const here = dirname(fileURLToPath(import.meta.url));
const workspaceRoot = resolve(here, "../../..");
const engineRoot = resolve(
process.env.NODEDC_ENGINE_SOURCE_ROOT || join(workspaceRoot, "NODEDC_ENGINE_INFRA"),
);
const artifactRoot = resolve(
process.env.NODEDC_DEPLOY_ARTIFACT_DIR || resolve(here, "../deploy-artifacts"),
);
const [patchId = "", ...extra] = process.argv.slice(2);
if (
extra.length
|| !/^engine-mcp-execution-profile-decoder-\d{8}-\d{3}$/.test(patchId)
) {
throw new Error(
"usage: build-engine-mcp-execution-profile-decoder-artifact.mjs "
+ "<engine-mcp-execution-profile-decoder-YYYYMMDD-NNN>",
);
}
const descriptorPath =
"nodedc-source/server/deployTransitions/executionProfileDecoderV1.json";
const targetSha256 = Object.freeze({
"nodedc-source/server/routes/n8n.js":
"1c2427c1d5830c40b1e8ae05f3d683fc39d07d7e0fe2431b0f6efbbb1d3fcb88",
[descriptorPath]:
"93e431902e9bcd3b828a82ed6b42b48d939051f21bf8824dafcf2addac8a711c",
});
const entries = Object.freeze(Object.keys(targetSha256));
const artifact = join(artifactRoot, `nodedc-${patchId}.tgz`);
await assertFresh(artifact);
await assertExactSources();
const stage = await mkdtemp(join(tmpdir(), "nodedc-engine-mcp-profile-decoder-"));
try {
const payload = join(stage, "payload");
for (const relativePath of entries) {
const destination = join(payload, relativePath);
await mkdir(dirname(destination), { recursive: true });
await copyFile(join(engineRoot, relativePath), destination);
}
await writeFile(
join(stage, "manifest.env"),
`id=${patchId}\ncomponent=engine\ntype=app-overlay\n`,
{ encoding: "utf8", flag: "wx", mode: 0o644 },
);
await writeFile(join(stage, "files.txt"), `${entries.join("\n")}\n`, {
encoding: "utf8",
flag: "wx",
mode: 0o644,
});
await mkdir(artifactRoot, { recursive: true });
run("python3", ["-c", canonicalTarScript(), artifact, stage]);
console.log(JSON.stringify({
ok: true,
patchId,
artifact,
sha256: digest(await readFile(artifact)),
entries,
targetSha256,
services: ["nodedc-backend"],
transition: "exact-flatted-numeric-string-preservation",
mcpSurface: "external-codex",
mcpTool: "engine_get_node_output_profile",
valuesIncluded: false,
rawExecutionDataIncluded: false,
untouched: [
"L2 graph",
"n8n",
"L1",
"Engine UI",
"databases",
"MCP Nginx",
"embedded AI Workspace",
],
}, null, 2));
} finally {
await rm(stage, { recursive: true, force: true });
}
async function assertExactSources() {
for (const [relativePath, expected] of Object.entries(targetSha256)) {
const sourcePath = join(engineRoot, relativePath);
const info = await lstat(sourcePath);
if (!info.isFile() || info.isSymbolicLink()) {
throw new Error(`engine_mcp_profile_decoder_source_unsafe:${relativePath}`);
}
const actual = digest(await readFile(sourcePath));
if (actual !== expected) {
throw new Error(
`engine_mcp_profile_decoder_target_mismatch:${relativePath}:`
+ `expected=${expected}:actual=${actual}`,
);
}
}
const source = await readFile(
join(engineRoot, "nodedc-source/server/routes/n8n.js"),
"utf8",
);
for (const marker of [
"const compactReference = Symbol('n8nCompactReference')",
"Top-level string entries are primitive values.",
"valuesIncluded: false,",
]) {
if (!source.includes(marker)) {
throw new Error(`engine_mcp_profile_decoder_marker_missing:${marker}`);
}
}
const descriptor = JSON.parse(await readFile(join(engineRoot, descriptorPath), "utf8"));
const expectedDescriptor = {
schemaVersion: "nodedc.engine.deploy-transition/v1",
id: "engine-mcp-execution-profile-decoder-v1",
component: "engine",
scope: "external-mcp-observability",
sourcePath: "nodedc-source/server/routes/n8n.js",
behavior: "preserve-top-level-numeric-string-primitives-in-flatted-execution-data",
acceptance: {
tool: "engine_get_node_output_profile",
valuesIncluded: false,
rawExecutionDataIncluded: false,
},
};
if (JSON.stringify(descriptor) !== JSON.stringify(expectedDescriptor)) {
throw new Error("engine_mcp_profile_decoder_descriptor_contract_mismatch");
}
}
async function assertFresh(path) {
try {
await lstat(path);
} catch (error) {
if (error?.code === "ENOENT") return;
throw error;
}
throw new Error("artifact_already_exists");
}
function canonicalTarScript() {
return [
"import gzip,io,pathlib,sys,tarfile",
"root=pathlib.Path(sys.argv[2])",
"with open(sys.argv[1],'xb') as out:",
" with gzip.GzipFile(filename='',mode='wb',fileobj=out,compresslevel=9,mtime=0) as gz:",
" with tarfile.open(fileobj=gz,mode='w',format=tarfile.PAX_FORMAT) as tar:",
" for top in ('manifest.env','files.txt','payload'):",
" p=root/top; paths=[p]+(sorted(p.rglob('*')) if p.is_dir() else [])",
" for x in paths:",
" info=tar.gettarinfo(str(x),arcname=x.relative_to(root).as_posix())",
" info.uid=info.gid=0; info.uname=info.gname='root'; info.mtime=0; info.mode=0o755 if info.isdir() else 0o644",
" with (open(x,'rb') if info.isfile() else io.BytesIO()) as src: tar.addfile(info,src if info.isfile() else None)",
].join("\n");
}
function digest(value) {
return createHash("sha256").update(value).digest("hex");
}
function run(command, args) {
const result = spawnSync(command, args, {
encoding: "utf8",
maxBuffer: 128 * 1024 * 1024,
stdio: ["ignore", "pipe", "pipe"],
});
if (result.status !== 0) {
throw new Error(`${command}_failed:${result.stderr || result.stdout}`);
}
}

View File

@ -0,0 +1,162 @@
#!/usr/bin/env node
import { createHash } from "node:crypto";
import { spawnSync } from "node:child_process";
import { copyFile, lstat, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
const scriptDir = dirname(fileURLToPath(import.meta.url));
const workspaceRoot = resolve(scriptDir, "../../..");
const engineRoot = resolve(
process.env.NODEDC_ENGINE_SOURCE_ROOT || join(workspaceRoot, "NODEDC_ENGINE_INFRA"),
);
const artifactDir = resolve(
process.env.NODEDC_DEPLOY_ARTIFACT_DIR || resolve(scriptDir, "../deploy-artifacts"),
);
const [patchId = "engine-mcp-l1-credential-provenance-20260724-044", ...extra] =
process.argv.slice(2);
if (
extra.length
|| !/^engine-mcp-l1-credential-provenance-\d{8}-\d{3}$/.test(patchId)
) {
throw new Error(
"usage: build-engine-mcp-l1-credential-provenance-artifact.mjs "
+ "[engine-mcp-l1-credential-provenance-YYYYMMDD-NNN]",
);
}
const expectedSha256 = Object.freeze({
"nodedc-source/server/routes/n8n.js":
"af07edcf784c420855134ab9178f020d259a1cac703cd643418ab7b4eb94dbd3",
"nodedc-source/server/deployTransitions/l1CredentialProvenanceV2.json":
"5887da6e5cb611450e03a110be9786acbe47ae1b00217b8bf09e0967f71f6a3d",
});
const files = Object.freeze(Object.keys(expectedSha256));
const artifact = join(artifactDir, `nodedc-${patchId}.tgz`);
const checksum = `${artifact}.sha256`;
const stage = await mkdtemp(join(tmpdir(), "nodedc-engine-l1-credential-provenance-"));
await assertFresh(artifact);
await assertExactSources();
try {
for (const relativePath of files) {
const destination = join(stage, "payload", relativePath);
await mkdir(dirname(destination), { recursive: true });
await copyFile(join(engineRoot, relativePath), destination);
}
await writeFile(
join(stage, "manifest.env"),
`id=${patchId}\ncomponent=engine\ntype=app-overlay\n`,
"utf8",
);
await writeFile(join(stage, "files.txt"), `${files.join("\n")}\n`, "utf8");
await mkdir(artifactDir, { recursive: true });
run("python3", ["-c", canonicalTarScript(), artifact, stage]);
const sha256 = digest(await readFile(artifact));
await writeFile(checksum, `${sha256} ${artifact.split("/").at(-1)}\n`, "utf8");
console.log(JSON.stringify({
ok: true,
patchId,
artifact,
checksum,
sha256,
services: ["nodedc-backend"],
mcpVersion: "0.11.0",
tool: "engine_list_l2_credential_refs",
credentialScope: "same-l1-workflow",
localProvenanceSources: ["manual", "workflow-ref", "credentials-file"],
referencedSourceRequiresSyncPayload: true,
crossL1Sharing: false,
managedGrants: "target-local",
credentialValuesIncluded: false,
files,
}, null, 2));
} finally {
await rm(stage, { recursive: true, force: true });
}
async function assertExactSources() {
for (const [relativePath, expected] of Object.entries(expectedSha256)) {
const sourcePath = join(engineRoot, relativePath);
const info = await lstat(sourcePath);
if (!info.isFile() || info.isSymbolicLink()) {
throw new Error(`engine_l1_credential_provenance_source_unsafe:${relativePath}`);
}
const actual = digest(await readFile(sourcePath));
if (actual !== expected) {
throw new Error(
`engine_l1_credential_provenance_target_mismatch:${relativePath}:`
+ `expected=${expected}:actual=${actual}`,
);
}
}
const route = await readFile(
join(engineRoot, "nodedc-source/server/routes/n8n.js"),
"utf8",
);
const descriptor = JSON.parse(await readFile(
join(
engineRoot,
"nodedc-source/server/deployTransitions/l1CredentialProvenanceV2.json",
),
"utf8",
));
if (
!route.includes("function engineAgentCredentialMayProveL1Provenance")
|| !route.includes("return isGlobalRegistryEntryAllowed(entry)")
|| !route.includes("engineAgentCredentialMayProveL1Provenance(item)")
|| descriptor?.id !== "engine-mcp-l1-credential-provenance-v2"
|| descriptor?.visibilityProof?.referencedSourceRequiresSyncPayload !== true
|| descriptor?.visibilityProof?.logicalKeyEqualityRequired !== true
|| descriptor?.binding?.applyOperation !== "assignCredentialRef"
|| descriptor?.crossL1Sharing !== false
|| descriptor?.candidateBoundary?.managedGrants !== "target-local"
) {
throw new Error("engine_l1_credential_provenance_runtime_boundary_invalid");
}
}
async function assertFresh(path) {
try {
await lstat(path);
} catch (error) {
if (error?.code === "ENOENT") return;
throw error;
}
throw new Error("artifact_already_exists");
}
function canonicalTarScript() {
return [
"import gzip,io,pathlib,sys,tarfile",
"root=pathlib.Path(sys.argv[2])",
"with open(sys.argv[1],'xb') as out:",
" with gzip.GzipFile(filename='',mode='wb',fileobj=out,compresslevel=9,mtime=0) as gz:",
" with tarfile.open(fileobj=gz,mode='w',format=tarfile.PAX_FORMAT) as tar:",
" for top in ('manifest.env','files.txt','payload'):",
" p=root/top; paths=[p]+(sorted(p.rglob('*')) if p.is_dir() else [])",
" for x in paths:",
" info=tar.gettarinfo(str(x),arcname=x.relative_to(root).as_posix())",
" info.uid=info.gid=0; info.uname=info.gname='root'; info.mtime=0; info.mode=0o755 if info.isdir() else 0o644",
" with (open(x,'rb') if info.isfile() else io.BytesIO()) as src: tar.addfile(info,src if info.isfile() else None)",
].join("\n");
}
function digest(value) {
return createHash("sha256").update(value).digest("hex");
}
function run(command, args) {
const result = spawnSync(command, args, {
encoding: "utf8",
maxBuffer: 128 * 1024 * 1024,
});
if (result.status !== 0) {
throw new Error(`${command}_failed:${result.stderr || result.stdout}`);
}
}

View File

@ -0,0 +1,159 @@
#!/usr/bin/env node
import { createHash } from "node:crypto";
import { spawnSync } from "node:child_process";
import { copyFile, lstat, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
const scriptDir = dirname(fileURLToPath(import.meta.url));
const workspaceRoot = resolve(scriptDir, "../../..");
const engineRoot = resolve(
process.env.NODEDC_ENGINE_SOURCE_ROOT || join(workspaceRoot, "NODEDC_ENGINE_INFRA"),
);
const artifactDir = resolve(
process.env.NODEDC_DEPLOY_ARTIFACT_DIR || resolve(scriptDir, "../deploy-artifacts"),
);
const [patchId = "engine-mcp-l1-credential-reuse-20260724-043", ...extra] =
process.argv.slice(2);
if (
extra.length
|| !/^engine-mcp-l1-credential-reuse-\d{8}-\d{3}$/.test(patchId)
) {
throw new Error(
"usage: build-engine-mcp-l1-credential-reuse-artifact.mjs "
+ "[engine-mcp-l1-credential-reuse-YYYYMMDD-NNN]",
);
}
const expectedSha256 = Object.freeze({
"nodedc-source/server/routes/n8n.js":
"6620e4bdd573e9f6b636a4b059eafef76d59da2fdb2183038fa5ec95357d8478",
"nodedc-source/server/deployTransitions/l1CredentialReuseV1.json":
"2ada49ef8bb2f146a9ea8d3c9ab5ee55f6a4e1b17e0f4b62a16f27368bd0eb05",
});
const files = Object.freeze(Object.keys(expectedSha256));
const artifact = join(artifactDir, `nodedc-${patchId}.tgz`);
const checksum = `${artifact}.sha256`;
const stage = await mkdtemp(join(tmpdir(), "nodedc-engine-l1-credential-reuse-"));
await assertFresh(artifact);
await assertExactSources();
try {
for (const relativePath of files) {
const destination = join(stage, "payload", relativePath);
await mkdir(dirname(destination), { recursive: true });
await copyFile(join(engineRoot, relativePath), destination);
}
await writeFile(
join(stage, "manifest.env"),
`id=${patchId}\ncomponent=engine\ntype=app-overlay\n`,
"utf8",
);
await writeFile(join(stage, "files.txt"), `${files.join("\n")}\n`, "utf8");
await mkdir(artifactDir, { recursive: true });
run("python3", ["-c", canonicalTarScript(), artifact, stage]);
const sha256 = digest(await readFile(artifact));
await writeFile(checksum, `${sha256} ${artifact.split("/").at(-1)}\n`, "utf8");
console.log(JSON.stringify({
ok: true,
patchId,
artifact,
checksum,
sha256,
services: ["nodedc-backend"],
mcpVersion: "0.11.0",
tool: "engine_list_l2_credential_refs",
credentialScope: "same-l1-workflow",
crossL1Sharing: false,
managedGrants: "target-local",
credentialValuesIncluded: false,
files,
}, null, 2));
} finally {
await rm(stage, { recursive: true, force: true });
}
async function assertExactSources() {
for (const [relativePath, expected] of Object.entries(expectedSha256)) {
const sourcePath = join(engineRoot, relativePath);
const info = await lstat(sourcePath);
if (!info.isFile() || info.isSymbolicLink()) {
throw new Error(`engine_l1_credential_reuse_source_unsafe:${relativePath}`);
}
const actual = digest(await readFile(sourcePath));
if (actual !== expected) {
throw new Error(
`engine_l1_credential_reuse_target_mismatch:${relativePath}:`
+ `expected=${expected}:actual=${actual}`,
);
}
}
const route = await readFile(
join(engineRoot, "nodedc-source/server/routes/n8n.js"),
"utf8",
);
const descriptor = JSON.parse(await readFile(
join(
engineRoot,
"nodedc-source/server/deployTransitions/l1CredentialReuseV1.json",
),
"utf8",
));
if (
!route.includes("function buildEngineAgentL1CredentialContext")
|| !route.includes("candidateScope === 'l1' ? l1Context.graph : graph")
|| !route.includes("managed writer/reader grants")
|| descriptor?.id !== "engine-mcp-l1-credential-reuse-v1"
|| descriptor?.visibilityProof?.scope !== "same-l1-workflow"
|| descriptor?.binding?.applyOperation !== "assignCredentialRef"
|| descriptor?.crossL1Sharing !== false
|| descriptor?.managedGrants !== "target-local"
) {
throw new Error("engine_l1_credential_reuse_runtime_boundary_invalid");
}
}
async function assertFresh(path) {
try {
await lstat(path);
} catch (error) {
if (error?.code === "ENOENT") return;
throw error;
}
throw new Error("artifact_already_exists");
}
function canonicalTarScript() {
return [
"import gzip,io,pathlib,sys,tarfile",
"root=pathlib.Path(sys.argv[2])",
"with open(sys.argv[1],'xb') as out:",
" with gzip.GzipFile(filename='',mode='wb',fileobj=out,compresslevel=9,mtime=0) as gz:",
" with tarfile.open(fileobj=gz,mode='w',format=tarfile.PAX_FORMAT) as tar:",
" for top in ('manifest.env','files.txt','payload'):",
" p=root/top; paths=[p]+(sorted(p.rglob('*')) if p.is_dir() else [])",
" for x in paths:",
" info=tar.gettarinfo(str(x),arcname=x.relative_to(root).as_posix())",
" info.uid=info.gid=0; info.uname=info.gname='root'; info.mtime=0; info.mode=0o755 if info.isdir() else 0o644",
" with (open(x,'rb') if info.isfile() else io.BytesIO()) as src: tar.addfile(info,src if info.isfile() else None)",
].join("\n");
}
function digest(value) {
return createHash("sha256").update(value).digest("hex");
}
function run(command, args) {
const result = spawnSync(command, args, {
encoding: "utf8",
maxBuffer: 128 * 1024 * 1024,
});
if (result.status !== 0) {
throw new Error(`${command}_failed:${result.stderr || result.stdout}`);
}
}

View File

@ -0,0 +1,193 @@
#!/usr/bin/env node
import { createHash } from "node:crypto";
import { spawnSync } from "node:child_process";
import { copyFile, lstat, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
const scriptDir = dirname(fileURLToPath(import.meta.url));
const workspaceRoot = resolve(scriptDir, "../../..");
const engineRoot = resolve(
process.env.NODEDC_ENGINE_SOURCE_ROOT || join(workspaceRoot, "NODEDC_ENGINE_INFRA"),
);
const artifactDir = resolve(
process.env.NODEDC_DEPLOY_ARTIFACT_DIR || resolve(scriptDir, "../deploy-artifacts"),
);
const [patchId = "engine-mcp-normalized-identity-search-20260724-041", ...extra] =
process.argv.slice(2);
if (
extra.length
|| !/^engine-mcp-normalized-identity-search-\d{8}-\d{3}$/.test(patchId)
) {
throw new Error(
"usage: build-engine-mcp-normalized-identity-search-artifact.mjs "
+ "[engine-mcp-normalized-identity-search-YYYYMMDD-NNN]",
);
}
const expectedSha256 = Object.freeze({
"nodedc-source/server/routes/n8n.js":
"a2bc69c72f68e57ed27120d0c88f4ffe6310bbf0c4360c8b0dba0953ccaaf522",
"nodedc-source/server/routes/engineAgentGateway.js":
"4a9524fd954320277042c783b7b19cbb2172f075f27652c0eebfd743ffc47872",
"nodedc-source/server/l2ExecutionPlan/compiler.js":
"01958d541c778002d33e3aead0cfe02df2084eb7222ce941cc7149d73a10f135",
"nodedc-source/server/assets/execution-plans/v1/catalog.json":
"2b8e5ee3d73d16f3cd6e34d1f7394946a6270a17b0e9e6511f12921ba2561fd3",
"nodedc-source/server/assets/provider-packages/v1/catalog.json":
"fe5256fd2ba295819daecc8d2acae34687e807978a6730dfa20703cb3bab0c1b",
"nodedc-source/server/deployTransitions/normalizedIdentitySearchV1.json":
"41738185fe103642912b0aa1c29c51860970c38f9f916cc3725e79e258b9ea7e",
"nodedc-source/services/node-intelligence/activation.json":
"3e7aeb1d28eb291461f79cd656ece6488bc6d372124088e92f53bf89c3373f61",
});
const files = Object.freeze(Object.keys(expectedSha256));
const artifact = join(artifactDir, `nodedc-${patchId}.tgz`);
const checksum = `${artifact}.sha256`;
const stage = await mkdtemp(join(tmpdir(), "nodedc-engine-normalized-identity-"));
await assertFresh(artifact);
await assertExactSources();
try {
for (const relativePath of files) {
const destination = join(stage, "payload", relativePath);
await mkdir(dirname(destination), { recursive: true });
await copyFile(join(engineRoot, relativePath), destination);
}
await writeFile(
join(stage, "manifest.env"),
`id=${patchId}\ncomponent=engine\ntype=app-overlay\n`,
"utf8",
);
await writeFile(join(stage, "files.txt"), `${files.join("\n")}\n`, "utf8");
await mkdir(artifactDir, { recursive: true });
run("python3", ["-c", canonicalTarScript(), artifact, stage]);
const sha256 = digest(await readFile(artifact));
await writeFile(checksum, `${sha256} ${artifact.split("/").at(-1)}\n`, "utf8");
console.log(JSON.stringify({
ok: true,
patchId,
artifact,
checksum,
sha256,
services: ["nodedc-backend"],
mcpVersion: "0.11.0",
mcpSurface: "external-codex",
tool: "engine_find_normalized_subjects",
providerPackage: "gelios.provider.v11",
compilerVersionAdded: "1.4.0",
rawProviderPayload: "forbidden",
commandSurface: "absent",
files,
}, null, 2));
} finally {
await rm(stage, { recursive: true, force: true });
}
async function assertExactSources() {
for (const [relativePath, expected] of Object.entries(expectedSha256)) {
const sourcePath = join(engineRoot, relativePath);
const info = await lstat(sourcePath);
if (!info.isFile() || info.isSymbolicLink()) {
throw new Error(`engine_normalized_identity_source_unsafe:${relativePath}`);
}
const actual = digest(await readFile(sourcePath));
if (actual !== expected) {
throw new Error(
`engine_normalized_identity_target_mismatch:${relativePath}:`
+ `expected=${expected}:actual=${actual}`,
);
}
}
const gateway = await readFile(
join(engineRoot, "nodedc-source/server/routes/engineAgentGateway.js"),
"utf8",
);
const route = await readFile(
join(engineRoot, "nodedc-source/server/routes/n8n.js"),
"utf8",
);
const compiler = await readFile(
join(engineRoot, "nodedc-source/server/l2ExecutionPlan/compiler.js"),
"utf8",
);
if (
!gateway.includes("const ENGINE_AGENT_MCP_VERSION = '0.11.0'")
|| !gateway.includes("engine_find_normalized_subjects")
|| !route.includes("normalized-fact-search")
|| !route.includes("rawExecutionDataIncluded: false")
|| !compiler.includes("boundedStringList")
|| !compiler.includes("boundedNamedValues")
|| /gelios|robot2b/i.test(compiler)
) {
throw new Error("engine_normalized_identity_runtime_boundary_invalid");
}
const transition = JSON.parse(await readFile(
join(
engineRoot,
"nodedc-source/server/deployTransitions/normalizedIdentitySearchV1.json",
),
"utf8",
));
const activation = JSON.parse(await readFile(
join(engineRoot, "nodedc-source/services/node-intelligence/activation.json"),
"utf8",
));
if (
transition?.id !== "engine-mcp-normalized-identity-search-v1"
|| transition?.normalizedFactSearch?.commandSurface !== false
|| transition?.providerPackageTrust?.addedPackageId !== "gelios.provider.v11"
|| activation?.source?.gatewaySha256
!== expectedSha256["nodedc-source/server/routes/engineAgentGateway.js"]
|| activation?.predecessor?.gatewaySha256
!== "6b8c80fa997ef7c438d199a6ee942c5e37aebc4057667af417897fa636131db4"
) {
throw new Error("engine_normalized_identity_transition_invalid");
}
}
async function assertFresh(path) {
try {
await lstat(path);
} catch (error) {
if (error?.code === "ENOENT") return;
throw error;
}
throw new Error("artifact_already_exists");
}
function canonicalTarScript() {
return [
"import gzip,io,pathlib,sys,tarfile",
"root=pathlib.Path(sys.argv[2])",
"with open(sys.argv[1],'xb') as out:",
" with gzip.GzipFile(filename='',mode='wb',fileobj=out,compresslevel=9,mtime=0) as gz:",
" with tarfile.open(fileobj=gz,mode='w',format=tarfile.PAX_FORMAT) as tar:",
" for top in ('manifest.env','files.txt','payload'):",
" p=root/top; paths=[p]+(sorted(p.rglob('*')) if p.is_dir() else [])",
" for x in paths:",
" info=tar.gettarinfo(str(x),arcname=x.relative_to(root).as_posix())",
" info.uid=info.gid=0; info.uname=info.gname='root'; info.mtime=0; info.mode=0o755 if info.isdir() else 0o644",
" with (open(x,'rb') if info.isfile() else io.BytesIO()) as src: tar.addfile(info,src if info.isfile() else None)",
].join("\n");
}
function digest(value) {
return createHash("sha256").update(value).digest("hex");
}
function run(command, args) {
const result = spawnSync(command, args, {
encoding: "utf8",
maxBuffer: 128 * 1024 * 1024,
});
if (result.status !== 0) {
throw new Error(`${command}_failed:${result.stderr || result.stdout}`);
}
}

View File

@ -0,0 +1,161 @@
#!/usr/bin/env node
import { createHash } from 'node:crypto'
import { spawnSync } from 'node:child_process'
import { cp, lstat, mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { dirname, join, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
const here = dirname(fileURLToPath(import.meta.url))
const platformRoot = resolve(here, '../..')
const engineRoot = resolve(
process.env.NODEDC_ENGINE_SOURCE_ROOT || resolve(platformRoot, '../NODEDC_ENGINE_INFRA'),
)
const canonicalArtifactRoot = resolve(here, '../deploy-artifacts')
const artifactRoot = resolve(process.env.NODEDC_DEPLOY_ARTIFACT_DIR || canonicalArtifactRoot)
const [transitionId = '20260718-005', ...extra] = process.argv.slice(2)
if (extra.length || !/^\d{8}-[0-9]{3}$/.test(transitionId)) {
throw new Error('usage: build-engine-mcp-ontology-sdk-artifact.mjs [YYYYMMDD-NNN]')
}
const id = `engine-mcp-control-plane-${transitionId}`
const target = join(artifactRoot, `nodedc-${id}.tgz`)
const predecessorArtifact = join(
canonicalArtifactRoot,
'nodedc-engine-mcp-control-plane-20260718-003.tgz',
)
const predecessorArtifactSha256 = '249aef9527666c562e9648b15737e66cc5c1dc7c0788b58ea714da270b5eb4ba'
const descriptorRel = 'nodedc-source/services/node-intelligence/activation.json'
const gatewayRel = 'nodedc-source/server/routes/engineAgentGateway.js'
const files = [
'nodedc-source/server/assets/engine-agent-npm/bin/nodedc-engine-codex-agent.mjs',
'nodedc-source/server/assets/engine-agent-npm/package.json',
'nodedc-source/server/assets/nodedc-engine-codex-agent-0.1.5.tgz',
'nodedc-source/server/assets/provider-packages/v1/catalog.json',
'nodedc-source/server/dataProductPublishGrant/providerCatalog.js',
'nodedc-source/server/engineAgents/store.js',
gatewayRel,
descriptorRel,
]
const expectedSha256 = new Map([
[files[0], 'adff3e474c914680f7d36b204d1dc03e69dc8065fff1f80b6713526cfc970137'],
[files[1], '0741647e4f7f58f69f3021d367484609a8e1eb7b8727d6ad9639bd9906b7f455'],
[files[2], '72a1b2d41a12a298eaae63ba3334c7c66b6ca53de6a3f03a48607d4ff6da42fb'],
[files[3], '4800e1a1c2af5e4893b1a403c04e91f55689383457caff833edc9e35e8e7e37a'],
[files[4], 'd5511a8bd3b4238af88a89537661c9ca4c0126bca7b5ad225985e27ccdb2c64c'],
[files[5], '4cd4bdd5958cfafee184e98a04fe12aa0c1cbe884326beaec63c99f9fff61285'],
[files[6], '25fa013ddbfd7d0c7ece8c792daec5f345062757c85eb56cfbff45bf1e812152'],
])
await assertFresh(target)
assertSha(await readFile(predecessorArtifact), predecessorArtifactSha256, 'predecessor MCP artifact')
const stage = await mkdtemp(join(tmpdir(), 'nodedc-engine-mcp-ontology-sdk-'))
const payload = join(stage, 'payload')
try {
await mkdir(payload, { recursive: true })
for (const rel of files.slice(0, -1)) {
const source = join(engineRoot, rel)
const stat = await lstat(source)
if (!stat.isFile() || stat.isSymbolicLink()) throw new Error(`source_boundary_invalid:${rel}`)
assertSha(await readFile(source), expectedSha256.get(rel), rel)
await mkdir(dirname(join(payload, rel)), { recursive: true })
await cp(source, join(payload, rel), { force: false })
}
const descriptor = JSON.parse(extractMember(
predecessorArtifact,
`payload/${descriptorRel}`,
))
if (
descriptor?.action !== 'activate'
|| descriptor?.releaseId !== '2.33.2-974a9fb3492f'
|| descriptor?.source?.gatewaySha256 !== '96c726dab5cf1341f74e6e1095d518058ca320e0dd5738e25bdbe75db1f4fc15'
|| descriptor?.source?.upstreamProjectionSha256 !== '761a874b102a938bc6018159ddacdaac71ad6ae08e9f0f8d7f3b58a0165a5131'
) throw new Error('mcp_control_plane_predecessor_descriptor_mismatch')
descriptor.source.gatewaySha256 = expectedSha256.get(gatewayRel)
await mkdir(dirname(join(payload, descriptorRel)), { recursive: true })
await writeFile(join(payload, descriptorRel), `${JSON.stringify(descriptor, null, 2)}\n`, 'utf8')
await writeFile(join(stage, 'manifest.env'), `id=${id}\ncomponent=engine\ntype=app-overlay\n`, 'utf8')
await writeFile(join(stage, 'files.txt'), `${files.join('\n')}\n`, 'utf8')
await mkdir(artifactRoot, { recursive: true })
run('python3', ['-c', canonicalTarScript(), target, stage])
const artifactSha256 = sha(await readFile(target))
console.log(JSON.stringify({
ok: true,
id,
artifact: target,
artifactSha256,
services: ['nodedc-backend'],
predecessorGatewaySha256: '96c726dab5cf1341f74e6e1095d518058ca320e0dd5738e25bdbe75db1f4fc15',
targetGatewaySha256: expectedSha256.get(gatewayRel),
mcpVersion: '0.6.0',
installerVersion: '0.1.5',
ontologyMcp: 'separate-read-only-proxy',
providerPackage: 'gelios.provider.v2',
providerCredential: 'httpQueryAuth',
preserved: ['n8n', 'L1', 'node-intelligence image', 'databases', 'provider credential values'],
files,
}, null, 2))
} finally {
await rm(stage, { recursive: true, force: true })
}
async function assertFresh(path) {
try {
await lstat(path)
} catch (error) {
if (error?.code === 'ENOENT') return
throw error
}
throw new Error('artifact_already_exists')
}
function extractMember(archive, member) {
const script = [
'import pathlib,sys,tarfile',
'p=pathlib.Path(sys.argv[1]); name=sys.argv[2]',
"with tarfile.open(p,'r:gz') as t:",
' m=t.getmember(name)',
" if not m.isfile(): raise SystemExit('member-not-file')",
' f=t.extractfile(m)',
" if f is None: raise SystemExit('member-unreadable')",
' sys.stdout.buffer.write(f.read())',
].join('\n')
return run('python3', ['-c', script, archive, member]).stdout
}
function canonicalTarScript() {
return [
'import gzip,io,pathlib,sys,tarfile',
'root=pathlib.Path(sys.argv[2])',
"with open(sys.argv[1],'wb') as out:",
" with gzip.GzipFile(filename='',mode='wb',fileobj=out,compresslevel=9,mtime=0) as gz:",
" with tarfile.open(fileobj=gz,mode='w',format=tarfile.PAX_FORMAT) as tar:",
" for top in ('manifest.env','files.txt','payload'):",
" p=root/top; paths=[p]+(sorted(p.rglob('*')) if p.is_dir() else [])",
' for x in paths:',
' info=tar.gettarinfo(str(x),arcname=x.relative_to(root).as_posix())',
" info.uid=info.gid=0; info.uname=info.gname='root'; info.mtime=0; info.mode=0o755 if info.isdir() else 0o644",
" with (open(x,'rb') if info.isfile() else io.BytesIO()) as src: tar.addfile(info,src if info.isfile() else None)",
].join('\n')
}
function assertSha(bytes, expected, label) {
const actual = sha(bytes)
if (!expected || actual !== expected) throw new Error(`${label.replaceAll(' ', '_')}_sha256_mismatch:${actual}`)
}
function sha(bytes) {
return createHash('sha256').update(bytes).digest('hex')
}
function run(command, args) {
const result = spawnSync(command, args, {
encoding: 'utf8',
maxBuffer: 128 * 1024 * 1024,
stdio: ['ignore', 'pipe', 'pipe'],
})
if (result.status !== 0) throw new Error(`${command}_failed:${result.stderr || result.stdout}`)
return result
}

View File

@ -0,0 +1,267 @@
#!/usr/bin/env node
import { createHash } from "node:crypto";
import { spawnSync } from "node:child_process";
import { copyFile, lstat, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
const here = dirname(fileURLToPath(import.meta.url));
const workspaceRoot = resolve(here, "../../..");
const engineRoot = resolve(
process.env.NODEDC_ENGINE_SOURCE_ROOT || join(workspaceRoot, "NODEDC_ENGINE_INFRA"),
);
const artifactRoot = resolve(
process.env.NODEDC_DEPLOY_ARTIFACT_DIR || resolve(here, "../deploy-artifacts"),
);
const baselineArtifact = resolve(
here,
"../deploy-artifacts/nodedc-engine-l2-closed-loop-20260723-031.tgz",
);
const baselineArtifactSha256 =
"33103bcf8a5f020855f3a095306b94158d9a452344ac7313120b1624a745e47e";
const nodeIntelligenceDescriptorPath =
"nodedc-source/services/node-intelligence/activation.json";
const predecessorNodeIntelligenceDescriptorSha256 =
"63e7619c6971d02102583bb5d80d33ece293b952f113200fa0b76f0e88c2dd32";
const targetNodeIntelligenceDescriptorSha256 =
"25ed3efd858aaf82c242dba501f0acc0c6c3dc91e8845707a4d3325750eab59f";
const predecessorGatewaySha256 =
"4f600a2781be9118bec891fa2a6f20d7f55e0892ef2f06af24059193cebd28f4";
const targetGatewaySha256 =
"69bfc91e913a3fad04e13aca86efb9d62f73c0c7d1f8f7200907494b29fa9e8d";
const [patchId = "", ...extra] = process.argv.slice(2);
if (
extra.length
|| !/^engine-mcp-telemetry-catalog-\d{8}-\d{3}$/.test(patchId)
) {
throw new Error(
"usage: build-engine-mcp-telemetry-catalog-artifact.mjs "
+ "<engine-mcp-telemetry-catalog-YYYYMMDD-NNN>",
);
}
const descriptorPath =
"nodedc-source/server/deployTransitions/telemetryReadingCatalogV1.json";
const targetSha256 = Object.freeze({
"nodedc-source/server/routes/n8n.js":
"903245ae363e9b9ac161498f17988a38876a0e0ac8d80f3fa1b0112ceb7fe906",
"nodedc-source/server/routes/engineAgentGateway.js":
targetGatewaySha256,
[descriptorPath]:
"b25ab8b6e6ad8ac24614c4630cc3635abe453464466d5a72238b05e48a24d882",
[nodeIntelligenceDescriptorPath]:
targetNodeIntelligenceDescriptorSha256,
});
const entries = Object.freeze(Object.keys(targetSha256));
const artifact = join(artifactRoot, `nodedc-${patchId}.tgz`);
await assertFresh(artifact);
await assertExactSources();
const targetNodeIntelligenceDescriptor =
await buildTargetNodeIntelligenceDescriptor();
const stage = await mkdtemp(join(tmpdir(), "nodedc-engine-mcp-telemetry-catalog-"));
try {
const payload = join(stage, "payload");
for (const relativePath of entries) {
const destination = join(payload, relativePath);
await mkdir(dirname(destination), { recursive: true });
if (relativePath === nodeIntelligenceDescriptorPath) {
await writeFile(destination, targetNodeIntelligenceDescriptor, {
encoding: "utf8",
flag: "wx",
mode: 0o644,
});
continue;
}
await copyFile(join(engineRoot, relativePath), destination);
}
await writeFile(
join(stage, "manifest.env"),
`id=${patchId}\ncomponent=engine\ntype=app-overlay\n`,
{ encoding: "utf8", flag: "wx", mode: 0o644 },
);
await writeFile(join(stage, "files.txt"), `${entries.join("\n")}\n`, {
encoding: "utf8",
flag: "wx",
mode: 0o644,
});
await mkdir(artifactRoot, { recursive: true });
run("python3", ["-c", canonicalTarScript(), artifact, stage]);
console.log(JSON.stringify({
ok: true,
patchId,
artifact,
sha256: digest(await readFile(artifact)),
entries,
targetSha256,
services: ["nodedc-backend"],
transition: "execution-profile-decoder-v1-to-telemetry-catalog-v1",
mcpVersion: "0.8.0",
mcpSurface: "external-codex",
mcpTool: "engine_get_telemetry_reading_catalog",
readingValuesIncluded: false,
rawExecutionDataIncluded: false,
nodeIntelligenceRelease: "2.33.2-974a9fb3492f",
predecessorGatewaySha256,
targetGatewaySha256,
predecessorNodeIntelligenceDescriptorSha256,
targetNodeIntelligenceDescriptorSha256,
untouched: [
"L2 graph",
"n8n",
"L1",
"Engine UI",
"node-intelligence image",
"databases",
"credentials",
"MCP Nginx",
"embedded AI Workspace",
],
}, null, 2));
} finally {
await rm(stage, { recursive: true, force: true });
}
async function assertExactSources() {
for (const [relativePath, expected] of Object.entries(targetSha256)) {
if (relativePath === nodeIntelligenceDescriptorPath) continue;
const sourcePath = join(engineRoot, relativePath);
const info = await lstat(sourcePath);
if (!info.isFile() || info.isSymbolicLink()) {
throw new Error(`engine_mcp_telemetry_catalog_source_unsafe:${relativePath}`);
}
const actual = digest(await readFile(sourcePath));
if (actual !== expected) {
throw new Error(
`engine_mcp_telemetry_catalog_target_mismatch:${relativePath}:`
+ `expected=${expected}:actual=${actual}`,
);
}
}
const n8nSource = await readFile(
join(engineRoot, "nodedc-source/server/routes/n8n.js"),
"utf8",
);
for (const marker of [
"function toSafeTelemetryReadingCatalog",
"key === 'sensor_readings' && pathName.endsWith('.attributes')",
"rawExecutionDataIncluded: false,",
]) {
if (!n8nSource.includes(marker)) {
throw new Error(`engine_mcp_telemetry_catalog_marker_missing:${marker}`);
}
}
const gatewaySource = await readFile(
join(engineRoot, "nodedc-source/server/routes/engineAgentGateway.js"),
"utf8",
);
for (const marker of [
"const ENGINE_AGENT_MCP_VERSION = '0.8.0'",
"name: 'engine_get_telemetry_reading_catalog'",
"/telemetry-reading-catalog?",
]) {
if (!gatewaySource.includes(marker)) {
throw new Error(`engine_mcp_telemetry_catalog_gateway_marker_missing:${marker}`);
}
}
const descriptor = JSON.parse(await readFile(join(engineRoot, descriptorPath), "utf8"));
if (
descriptor?.schemaVersion !== "nodedc.engine.deploy-transition/v1"
|| descriptor?.id !== "engine-mcp-telemetry-reading-catalog-v1"
|| descriptor?.mcpVersion !== "0.8.0"
|| descriptor?.readsOnly !== "normalized-attributes.sensor_readings"
|| descriptor?.neverReturns?.join(",")
!== "reading-value,raw-execution-data,raw-provider-payload,credential-shaped-data"
) {
throw new Error("engine_mcp_telemetry_catalog_descriptor_contract_mismatch");
}
}
async function buildTargetNodeIntelligenceDescriptor() {
const baselineBytes = await readFile(baselineArtifact);
if (digest(baselineBytes) !== baselineArtifactSha256) {
throw new Error("engine_mcp_telemetry_catalog_baseline_artifact_mismatch");
}
const baseline = run("tar", [
"-xOf",
baselineArtifact,
`payload/${nodeIntelligenceDescriptorPath}`,
]).stdout;
if (
digest(Buffer.from(baseline, "utf8"))
!== predecessorNodeIntelligenceDescriptorSha256
) {
throw new Error(
"engine_mcp_telemetry_catalog_baseline_descriptor_mismatch",
);
}
const descriptor = JSON.parse(baseline);
if (
descriptor?.schemaVersion
!== "nodedc.engine-node-intelligence-transition/v1"
|| descriptor?.action !== "activate"
|| descriptor?.releaseId !== "2.33.2-974a9fb3492f"
|| descriptor?.source?.gatewaySha256 !== predecessorGatewaySha256
) {
throw new Error(
"engine_mcp_telemetry_catalog_baseline_descriptor_contract_mismatch",
);
}
descriptor.source.gatewaySha256 = targetGatewaySha256;
const rendered = `${JSON.stringify(descriptor, null, 2)}\n`;
if (
digest(Buffer.from(rendered, "utf8"))
!== targetNodeIntelligenceDescriptorSha256
) {
throw new Error(
"engine_mcp_telemetry_catalog_target_descriptor_mismatch",
);
}
return rendered;
}
async function assertFresh(path) {
try {
await lstat(path);
} catch (error) {
if (error?.code === "ENOENT") return;
throw error;
}
throw new Error("artifact_already_exists");
}
function canonicalTarScript() {
return [
"import gzip,io,pathlib,sys,tarfile",
"root=pathlib.Path(sys.argv[2])",
"with open(sys.argv[1],'xb') as out:",
" with gzip.GzipFile(filename='',mode='wb',fileobj=out,compresslevel=9,mtime=0) as gz:",
" with tarfile.open(fileobj=gz,mode='w',format=tarfile.PAX_FORMAT) as tar:",
" for top in ('manifest.env','files.txt','payload'):",
" p=root/top; paths=[p]+(sorted(p.rglob('*')) if p.is_dir() else [])",
" for x in paths:",
" info=tar.gettarinfo(str(x),arcname=x.relative_to(root).as_posix())",
" info.uid=info.gid=0; info.uname=info.gname='root'; info.mtime=0; info.mode=0o755 if info.isdir() else 0o644",
" with (open(x,'rb') if info.isfile() else io.BytesIO()) as src: tar.addfile(info,src if info.isfile() else None)",
].join("\n");
}
function digest(value) {
return createHash("sha256").update(value).digest("hex");
}
function run(command, args) {
const result = spawnSync(command, args, {
encoding: "utf8",
maxBuffer: 128 * 1024 * 1024,
stdio: ["ignore", "pipe", "pipe"],
});
if (result.status !== 0) {
throw new Error(`${command}_failed:${result.stderr || result.stdout}`);
}
return result;
}

View File

@ -0,0 +1,489 @@
#!/usr/bin/env node
import { createHash } from "node:crypto";
import { spawnSync } from "node:child_process";
import { cp, lstat, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { createRequire, Module } from "node:module";
import { tmpdir } from "node:os";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
const here = dirname(fileURLToPath(import.meta.url));
const platformRoot = resolve(here, "../..");
const engineRoot = resolve(platformRoot, "../NODEDC_ENGINE_INFRA");
const artifactRoot = resolve(process.env.NODEDC_DEPLOY_ARTIFACT_DIR || join(here, "../deploy-artifacts"));
const stageArtifact = resolve(
process.env.NODEDC_N8N_EXTENSION_STAGE_ARTIFACT
|| join(artifactRoot, "nodedc-n8n-private-extension-n8n-nodes-ndc-replace-replay-v1-20260721-009.tgz"),
);
const predecessorArtifact = resolve(
process.env.NODEDC_N8N_EXTENSION_PREDECESSOR_ARTIFACT
|| join(artifactRoot, "nodedc-engine-n8n-private-extension-20260721-005.tgz"),
);
const stageArtifactSha256 = "4984d4937e2c8de1ecbf5b836cf7fafac131cc0dc64d49c06034507d0cb94577";
const predecessorArtifactSha256 = "037d4790b03c0b245b543fe2a841d28f3cfc7822b9a12f2bff9324bf1328b395";
const releaseId = "0.1.6-25cc2d7a52a0d0ee";
const packageVersion = "0.1.6";
const packageSha256 = "25cc2d7a52a0d0ee288b7a4b3de9f436489c010f234498a94bcccd114d7355af";
const n8nVersion = "2.3.2";
const baseImage = "docker.n8n.io/n8nio/n8n:2.3.2";
const architecture = "amd64";
const generatedAt = "2026-07-21T12:00:00.000Z";
const previouslyIssuedTransitionIds = new Set([
"20260715-002",
"20260716-003",
"20260717-004",
"20260717-005",
"20260718-006",
"20260718-007",
"20260718-008",
"20260721-005",
"20260721-008",
]);
const transitionId = readTransitionId(process.argv.slice(2), process.env.NODEDC_N8N_TRANSITION_ID);
const activationId = `engine-n8n-private-extension-${transitionId}`;
const rollbackId = `engine-n8n-private-extension-rollback-${transitionId}`;
const transitionRoot = "nodedc-source/services/n8n/private-extensions";
const descriptorRel = `${transitionRoot}/ndc-activation.json`;
const overrideRel = `${transitionRoot}/docker-compose.ndc-private-extension.yml`;
const schemaRoot = "nodedc-source/server/assets/n8n/schema/v2.3.2";
const nodesCatalogRel = `${schemaRoot}/nodes.catalog.json`;
const credentialsCatalogRel = `${schemaRoot}/credentials.catalog.json`;
const metaRel = `${schemaRoot}/meta.json`;
const iconRoot = "nodedc-source/server/assets/n8n/icons";
const iconRel = `${iconRoot}/ndc.svg`;
const darkIconRel = `${iconRoot}/ndc.dark.svg`;
const runtimePackagePath = "/home/node/.n8n/nodes/node_modules/n8n-nodes-ndc";
const expectedNodeTypes = [
"n8n-nodes-ndc.ndcDataProductPublish",
"n8n-nodes-ndc.ndcDataProductRead",
"n8n-nodes-ndc.ndcFoundryBinding",
];
const expectedCredentialTypes = [
"ndcDataProductWriterApi",
"ndcDataProductReaderApi",
"ndcFoundryBindingApi",
"ndcProviderRotatingAccessApi",
];
const predecessorCredentialTypes = [...expectedCredentialTypes];
const nodeModules = [
["dist/nodes/NdcDataProductPublish/NdcDataProductPublish.node.js", "NdcDataProductPublish"],
["dist/nodes/NdcDataProductRead/NdcDataProductRead.node.js", "NdcDataProductRead"],
["dist/nodes/NdcFoundryBinding/NdcFoundryBinding.node.js", "NdcFoundryBinding"],
];
const credentialModules = [
["dist/credentials/NdcDataProductWriterApi.credentials.js", "NdcDataProductWriterApi"],
["dist/credentials/NdcDataProductReaderApi.credentials.js", "NdcDataProductReaderApi"],
["dist/credentials/NdcFoundryBindingApi.credentials.js", "NdcFoundryBindingApi"],
["dist/credentials/NdcProviderRotatingAccessApi.credentials.js", "NdcProviderRotatingAccessApi"],
];
await mkdir(artifactRoot, { recursive: true });
await assertArtifactTargetFresh(join(artifactRoot, `nodedc-${activationId}.tgz`));
await assertArtifactTargetFresh(join(artifactRoot, `nodedc-${rollbackId}.tgz`));
assertSha(await readFile(stageArtifact), stageArtifactSha256, "staging artifact");
assertSha(await readFile(predecessorArtifact), predecessorArtifactSha256, "predecessor artifact");
assertEngineBaseline(await readFile(join(engineRoot, "docker-compose.yml"), "utf8"));
const work = await mkdtemp(join(tmpdir(), "nodedc-engine-n8n-sealed-"));
try {
extractArchive(stageArtifact, work);
const stagedRelease = join(work, "payload", "releases", "n8n-nodes-ndc", releaseId);
const release = JSON.parse(await readFile(join(stagedRelease, "release.json"), "utf8"));
assertRelease(release);
assertSha(await readFile(join(stagedRelease, "package.tgz")), packageSha256, "private package");
const unpacked = join(work, "unpacked");
await mkdir(unpacked);
extractArchive(join(stagedRelease, "package.tgz"), unpacked);
const packageRoot = join(unpacked, "package");
const packageJson = JSON.parse(await readFile(join(packageRoot, "package.json"), "utf8"));
assertPackage(packageJson);
const devNodeModules = join(platformRoot, "packages", "n8n-nodes-ndc", "node_modules");
const nodePath = String(process.env.NODE_PATH || "").split(":").filter(Boolean);
if (!nodePath.includes(devNodeModules)) nodePath.unshift(devNodeModules);
process.env.NODE_PATH = nodePath.join(":");
Module._initPaths();
const packageRequire = createRequire(join(packageRoot, "package.json"));
const privateNodes = nodeModules.map(([path, className], index) => {
const NodeClass = packageRequire(join(packageRoot, path))[className];
if (typeof NodeClass !== "function") throw new Error(`node_class_missing:${className}`);
const description = structuredClone(new NodeClass().description);
description.name = expectedNodeTypes[index];
description.icon = { light: "file:ndc.svg", dark: "file:ndc.dark.svg" };
if (Object.prototype.hasOwnProperty.call(description, "usableAsTool")) {
throw new Error(`tool_variant_forbidden:${description.name}`);
}
return description;
});
const privateCredentials = credentialModules.map(([path, className]) => {
const CredentialClass = packageRequire(join(packageRoot, path))[className];
if (typeof CredentialClass !== "function") throw new Error(`credential_class_missing:${className}`);
const description = structuredClone(new CredentialClass());
description.icon = { light: "file:ndc.svg", dark: "file:ndc.dark.svg" };
return description;
});
assertExact(privateNodes.map((item) => item.name), expectedNodeTypes, "node types");
assertExact(privateCredentials.map((item) => item.name), expectedCredentialTypes, "credential types");
assertPublishNodeV2(privateNodes[0]);
const predecessorDescriptor = JSON.parse(predecessorArtifactFile(descriptorRel));
const predecessorNodes = JSON.parse(predecessorArtifactFile(nodesCatalogRel));
const predecessorCredentials = JSON.parse(predecessorArtifactFile(credentialsCatalogRel));
const predecessorMeta = JSON.parse(predecessorArtifactFile(metaRel));
assertPredecessorCatalogs(
predecessorDescriptor,
predecessorNodes,
predecessorCredentials,
predecessorMeta,
);
const baselineNodes = predecessorNodes.filter(
(item) => !String(item?.name || "").startsWith("n8n-nodes-ndc."),
);
const baselineCredentials = predecessorCredentials.filter(
(item) => !expectedCredentialTypes.includes(String(item?.name || "")),
);
assertBaselineCatalogs(baselineNodes, baselineCredentials);
const activeNodes = [...baselineNodes, ...privateNodes];
const activeCredentials = [...baselineCredentials, ...privateCredentials];
const activeMeta = {
n8nVersion,
generatedAt,
source: `n8n-core+n8n-nodes-ndc@${packageVersion}`,
nodeCount: activeNodes.length,
credentialCount: activeCredentials.length,
};
const target = {
releaseId,
packageVersion,
packageSha256,
};
const predecessor = {
releaseId: predecessorDescriptor.releaseId,
packageVersion: predecessorDescriptor.packageVersion,
packageSha256: predecessorDescriptor.packageSha256,
};
const activationDescriptor = descriptor(
target,
predecessor.releaseId,
predecessor.releaseId,
expectedCredentialTypes,
);
const rollbackDescriptor = descriptor(
predecessor,
releaseId,
releaseId,
predecessorCredentialTypes,
);
const override = composeOverride(target);
const rollbackOverride = composeOverride(predecessor);
const generatedRoot = join(work, "generated-engine-payload");
await writeJson(join(generatedRoot, nodesCatalogRel), activeNodes);
await writeJson(join(generatedRoot, credentialsCatalogRel), activeCredentials);
await writeJson(join(generatedRoot, metaRel), activeMeta);
await writeJson(join(generatedRoot, descriptorRel), activationDescriptor);
await writeFile(join(generatedRoot, overrideRel), override, "utf8");
await mkdir(join(generatedRoot, iconRoot), { recursive: true });
await cp(join(packageRoot, "dist/icons/ndc.svg"), join(generatedRoot, iconRel), { force: false });
await cp(join(packageRoot, "dist/icons/ndc.dark.svg"), join(generatedRoot, darkIconRel), { force: false });
const activationEntries = [
descriptorRel,
overrideRel,
nodesCatalogRel,
credentialsCatalogRel,
metaRel,
iconRel,
darkIconRel,
];
const activationArtifact = await buildArtifact(work, activationId, activationEntries, async (payload) => {
for (const rel of activationEntries) {
await cp(join(generatedRoot, rel), join(payload, rel), { recursive: true, force: false });
}
});
const rollbackEntries = [...activationEntries];
const rollbackArtifact = await buildArtifact(work, rollbackId, rollbackEntries, async (payload) => {
await writeJson(join(payload, descriptorRel), rollbackDescriptor);
await writeFile(join(payload, overrideRel), rollbackOverride, "utf8");
await writeJson(join(payload, nodesCatalogRel), predecessorNodes);
await writeJson(join(payload, credentialsCatalogRel), predecessorCredentials);
await writeJson(join(payload, metaRel), predecessorMeta);
await mkdir(join(payload, iconRoot), { recursive: true });
await cp(join(engineRoot, iconRel), join(payload, iconRel), { force: false });
await cp(join(engineRoot, darkIconRel), join(payload, darkIconRel), { force: false });
});
console.log(JSON.stringify({
ok: true,
transitionId,
releaseId,
packageSha256,
nodeTypes: expectedNodeTypes,
credentialTypes: expectedCredentialTypes,
activation: activationArtifact,
rollback: rollbackArtifact,
}, null, 2));
} finally {
await rm(work, { recursive: true, force: true });
}
function descriptor(target, expectedCurrent, rollbackBaseline, credentialTypes) {
return {
schemaVersion: "nodedc.engine-n8n-private-extension-transition/v1",
action: "activate",
releaseId: target.releaseId,
packageVersion: target.packageVersion,
packageSha256: target.packageSha256,
n8nVersion,
baseImage,
baseImageArchitecture: architecture,
baseImageIdentityPolicy: "running-container-and-local-tag-must-match",
sealedReleaseRelativePath: `n8n-private-extensions/releases/n8n-nodes-ndc/${target.releaseId}/package`,
composeOverride: overrideRel,
runtimePackagePath,
topologyServices: ["n8n"],
expectedCurrent,
expectedNodeTypes,
expectedCredentialTypes: credentialTypes,
rollbackBaseline,
};
}
function readTransitionId(args, environmentValue) {
if (args.length > 1) throw new Error("transition_id_argument_count_invalid");
const argumentValue = args[0] || "";
const envValue = String(environmentValue || "").trim();
if (argumentValue && envValue && argumentValue !== envValue) {
throw new Error("transition_id_sources_conflict");
}
const value = argumentValue || envValue;
if (!value) throw new Error("transition_id_required");
const match = /^(\d{4})(\d{2})(\d{2})-([0-9]{3})$/.exec(value);
if (!match || match[4] === "000") throw new Error("transition_id_invalid");
const year = Number(match[1]);
const month = Number(match[2]);
const day = Number(match[3]);
const parsed = new Date(Date.UTC(year, month - 1, day));
if (parsed.getUTCFullYear() !== year
|| parsed.getUTCMonth() !== month - 1
|| parsed.getUTCDate() !== day) {
throw new Error("transition_id_invalid");
}
if (previouslyIssuedTransitionIds.has(value)) {
throw new Error("transition_id_already_issued");
}
return value;
}
async function assertArtifactTargetFresh(path) {
try {
await lstat(path);
} catch (error) {
if (error?.code === "ENOENT") return;
throw error;
}
throw new Error("transition_artifact_already_exists");
}
function composeOverride(target) {
const health = "const http=require('http');const req=http.get('http://127.0.0.1:5678/healthz/readiness',r=>{r.resume();process.exit(r.statusCode===200?0:1)});req.on('error',()=>process.exit(1));req.setTimeout(4000,()=>{req.destroy();process.exit(1)});";
const sealedReleaseRelativePath = `n8n-private-extensions/releases/n8n-nodes-ndc/${target.releaseId}/package`;
return [
"services:",
" n8n:",
` image: ${baseImage}`,
" platform: linux/amd64",
" pull_policy: never",
" environment:",
" N8N_USER_FOLDER: /home/node",
" N8N_COMMUNITY_PACKAGES_ENABLED: \"true\"",
" N8N_COMMUNITY_PACKAGES_PREVENT_LOADING: \"false\"",
" N8N_REINSTALL_MISSING_PACKAGES: \"false\"",
" volumes:",
` - /volume2/nodedc-demo/${sealedReleaseRelativePath}:${runtimePackagePath}:ro`,
" healthcheck:",
` test: ${JSON.stringify(["CMD", "node", "-e", health])}`,
" interval: 10s",
" timeout: 5s",
" retries: 30",
" start_period: 30s",
" labels:",
` nodedc.n8n-private-extension.release: ${target.releaseId}`,
` nodedc.n8n-private-extension.package-sha256: ${target.packageSha256}`,
"",
].join("\n");
}
async function buildArtifact(workRoot, id, entries, populate) {
const stage = join(workRoot, id);
const payload = join(stage, "payload");
await mkdir(payload, { recursive: true });
await populate(payload);
await writeFile(join(stage, "manifest.env"), `id=${id}\ncomponent=engine\ntype=app-overlay\n`, "utf8");
await writeFile(join(stage, "files.txt"), `${entries.join("\n")}\n`, "utf8");
const artifact = join(artifactRoot, `nodedc-${id}.tgz`);
run("python3", ["-c", canonicalTarScript(), artifact, stage]);
return { id, artifact, sha256: sha(await readFile(artifact)), entries };
}
function assertRelease(value) {
if (value?.releaseId !== releaseId
|| value?.package?.name !== "n8n-nodes-ndc"
|| value?.package?.version !== packageVersion
|| value?.package?.sha256 !== packageSha256
|| value?.storage?.relativePath !== `releases/n8n-nodes-ndc/${releaseId}`) {
throw new Error("staged_release_identity_mismatch");
}
}
function assertPackage(value) {
if (value.name !== "n8n-nodes-ndc" || value.version !== packageVersion || value.private !== true) {
throw new Error("package_identity_mismatch");
}
if (value.dependencies !== undefined) throw new Error("runtime_dependencies_forbidden");
for (const name of ["preinstall", "install", "postinstall", "prepare", "prepack", "postpack"]) {
if (value.scripts?.[name] !== undefined) throw new Error(`lifecycle_forbidden:${name}`);
}
assertExact(value.n8n?.nodes, nodeModules.map(([path]) => path), "package nodes");
assertExact(value.n8n?.credentials, credentialModules.map(([path]) => path), "package credentials");
}
function assertPredecessorCatalogs(descriptorValue, nodes, credentials, meta) {
if (descriptorValue?.action !== "activate"
|| descriptorValue?.releaseId !== "0.1.5-3c8ae53f010d7c88"
|| descriptorValue?.packageVersion !== "0.1.5"
|| descriptorValue?.packageSha256 !== "3c8ae53f010d7c88b6e90cb8fc4929e7d0880089ad8c3a9edb876ce034786743") {
throw new Error("predecessor_descriptor_mismatch");
}
if (!Array.isArray(nodes) || nodes.length !== 437
|| !Array.isArray(credentials) || credentials.length !== 389
|| meta?.n8nVersion !== n8nVersion
|| meta?.source !== "n8n-core+n8n-nodes-ndc@0.1.5"
|| meta?.nodeCount !== 437
|| meta?.credentialCount !== 389) {
throw new Error("predecessor_catalog_mismatch");
}
assertExact(
nodes.filter((item) => String(item?.name || "").startsWith("n8n-nodes-ndc.")).map((item) => item.name),
expectedNodeTypes,
"predecessor node types",
);
assertExact(
credentials.filter((item) => predecessorCredentialTypes.includes(String(item?.name || ""))).map((item) => item.name),
predecessorCredentialTypes,
"predecessor credential types",
);
}
function assertPublishNodeV2(node) {
if (JSON.stringify(node?.version) !== JSON.stringify([1, 2])) {
throw new Error("publish_node_versions_mismatch");
}
const property = node?.properties?.find((item) => item?.name === "publishMode");
const values = property?.options?.map((item) => item?.value);
if (JSON.stringify(values) !== JSON.stringify(["upsert", "replace"])
|| !property?.displayOptions?.show?.["@version"]?.includes(2)) {
throw new Error("publish_node_replace_contract_missing");
}
}
function assertBaselineCatalogs(nodes, credentials) {
if (!Array.isArray(nodes) || nodes.length !== 434 || nodes.some((item) => String(item?.name || "").startsWith("n8n-nodes-ndc."))) {
throw new Error("baseline_node_catalog_mismatch");
}
if (!Array.isArray(credentials) || credentials.length !== 385
|| credentials.some((item) => expectedCredentialTypes.includes(String(item?.name || "")))) {
throw new Error("baseline_credential_catalog_mismatch");
}
}
function assertEngineBaseline(compose) {
const exactImage = `image: docker.n8n.io/n8nio/n8n:\${N8N_IMAGE_TAG:-${n8nVersion}}`;
if (!compose.includes(exactImage)) throw new Error("engine_n8n_version_mismatch");
if ((compose.match(/^ n8n:\s*$/gm) || []).length !== 1) throw new Error("engine_n8n_topology_mismatch");
if (/^ n8n-(?:worker|webhook)|^ (?:worker|webhook):/gm.test(compose)) throw new Error("unexpected_n8n_process_service");
if (compose.includes("N8N_CUSTOM_EXTENSIONS") || compose.includes("CUSTOM.")) throw new Error("custom_extension_loader_forbidden");
}
function assertExact(actual, expected, label) {
if (!Array.isArray(actual) || JSON.stringify(actual) !== JSON.stringify(expected)) {
throw new Error(`${label.replaceAll(" ", "_")}_mismatch`);
}
}
function assertSha(bytes, expected, label) {
const actual = sha(bytes);
if (actual !== expected) throw new Error(`${label.replaceAll(" ", "_")}_sha256_mismatch:${actual}`);
}
function predecessorArtifactFile(rel) {
const script = [
"import pathlib,sys,tarfile",
"archive=pathlib.Path(sys.argv[1])",
"member='payload/'+sys.argv[2]",
"with tarfile.open(archive,'r:gz') as source:",
" extracted=source.extractfile(member)",
" if extracted is None: raise SystemExit('predecessor member missing')",
" sys.stdout.buffer.write(extracted.read())",
].join("\n");
return run("python3", ["-c", script, predecessorArtifact, rel]).stdout;
}
async function writeJson(path, value) {
await mkdir(dirname(path), { recursive: true });
await writeFile(path, `${JSON.stringify(value, null, 2)}\n`, "utf8");
}
function extractArchive(archive, destination) {
const script = [
"import pathlib, sys, tarfile",
"src=pathlib.Path(sys.argv[1]); dst=pathlib.Path(sys.argv[2]).resolve()",
"with tarfile.open(src, 'r:gz') as tf:",
" for m in tf:",
" p=pathlib.PurePosixPath(m.name)",
" if p.is_absolute() or '..' in p.parts or any(x.startswith('._') for x in p.parts) or not (m.isfile() or m.isdir()): raise SystemExit('unsafe archive member')",
" target=dst.joinpath(*p.parts)",
" target.mkdir(parents=True, exist_ok=True) if m.isdir() else target.parent.mkdir(parents=True, exist_ok=True)",
" if m.isfile():",
" source=tf.extractfile(m)",
" with open(target, 'xb') as out: out.write(source.read())",
].join("\n");
run("python3", ["-c", script, archive, destination]);
}
function canonicalTarScript() {
return [
"import gzip, io, pathlib, sys, tarfile",
"root=pathlib.Path(sys.argv[2])",
"with open(sys.argv[1], 'wb') as out:",
" with gzip.GzipFile(filename='', mode='wb', fileobj=out, compresslevel=9, mtime=0) as gz:",
" with tarfile.open(fileobj=gz, mode='w', format=tarfile.PAX_FORMAT) as tar:",
" for top in ('manifest.env','files.txt','payload'):",
" p=root/top; paths=[p] + (sorted(p.rglob('*')) if p.is_dir() else [])",
" for x in paths:",
" info=tar.gettarinfo(str(x), arcname=x.relative_to(root).as_posix())",
" info.uid=info.gid=0; info.uname=info.gname='root'; info.mtime=0; info.mode=0o755 if info.isdir() else 0o644",
" with (open(x,'rb') if info.isfile() else io.BytesIO()) as src: tar.addfile(info, src if info.isfile() else None)",
].join("\n");
}
function sha(bytes) {
return createHash("sha256").update(bytes).digest("hex");
}
function run(command, args, cwd) {
const result = spawnSync(command, args, {
cwd,
encoding: "utf8",
maxBuffer: 128 * 1024 * 1024,
stdio: ["ignore", "pipe", "pipe"],
});
if (result.status !== 0) throw new Error(`${command}_failed:${result.stderr || result.stdout}`);
return result;
}

View File

@ -0,0 +1,414 @@
#!/usr/bin/env node
import { createHash } from "node:crypto";
import { spawnSync } from "node:child_process";
import {
copyFile,
lstat,
mkdir,
mkdtemp,
readFile,
readdir,
rm,
writeFile,
} from "node:fs/promises";
import { createReadStream } from "node:fs";
import { tmpdir } from "node:os";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
const here = dirname(fileURLToPath(import.meta.url));
const platformRoot = resolve(here, "../..");
const engineRoot = resolve(
process.env.NODEDC_ENGINE_ROOT || join(platformRoot, "../NODEDC_ENGINE_INFRA"),
);
const artifactRoot = resolve(
process.env.NODEDC_DEPLOY_ARTIFACT_DIR || join(here, "../deploy-artifacts"),
);
const releaseId = "2.33.2-974a9fb3492f";
const upstreamCommit = "974a9fb3492fe2c4984ee0549085d531cdc6242a";
const imageTag = `nodedc/engine-node-intelligence:${releaseId}`;
const predecessorGatewaySha256 = "6b8c80fa997ef7c438d199a6ee942c5e37aebc4057667af417897fa636131db4";
const predecessorComposeSha256 = "258cebb64ff1943c939655cc55bdce00fc5c4dced67ec291d84d6df066ace50e";
const sourceRootRel = "nodedc-source/server/nodeIntelligence";
const gatewayRel = "nodedc-source/server/routes/engineAgentGateway.js";
const serviceRootRel = "nodedc-source/services/node-intelligence";
const overrideRel = `${serviceRootRel}/docker-compose.immutable-runtime.yml`;
const descriptorRel = `${serviceRootRel}/activation.json`;
const imageArchiveRel = `${serviceRootRel}/image/engine-node-intelligence.tar`;
const activationEntries = [sourceRootRel, gatewayRel, serviceRootRel];
const rollbackEntries = [gatewayRel, serviceRootRel];
const transitionId = readTransitionId(process.argv.slice(2));
const activationId = `engine-node-intelligence-${transitionId}`;
const rollbackId = `engine-node-intelligence-rollback-${transitionId}`;
const activationTarget = join(artifactRoot, `nodedc-${activationId}.tgz`);
const rollbackTarget = join(artifactRoot, `nodedc-${rollbackId}.tgz`);
const commandsTarget = join(
artifactRoot,
`engine-node-intelligence-${transitionId}-plan-apply.txt`,
);
await mkdir(artifactRoot, { recursive: true });
for (const path of [
activationTarget,
`${activationTarget}.sha256`,
rollbackTarget,
`${rollbackTarget}.sha256`,
commandsTarget,
]) {
await assertFresh(path);
}
await assertEngineInputs();
const image = inspectLocalImage();
const work = await mkdtemp(join(tmpdir(), "nodedc-engine-node-intelligence-"));
try {
const imageArchive = join(work, "engine-node-intelligence.tar");
run("docker", ["image", "save", "--output", imageArchive, imageTag]);
const archive = inspectImageArchive(imageArchive);
const archiveSha256 = await hashFile(imageArchive);
const source = {
gatewaySha256: await hashFile(join(engineRoot, gatewayRel)),
catalogSha256: await hashFile(join(engineRoot, sourceRootRel, "catalog.js")),
upstreamClientSha256: await hashFile(join(engineRoot, sourceRootRel, "upstreamMcpClient.js")),
upstreamProjectionSha256: await hashFile(join(engineRoot, sourceRootRel, "upstreamProjection.js")),
composeOverrideSha256: await hashFile(join(engineRoot, overrideRel)),
readmeSha256: await hashFile(join(engineRoot, serviceRootRel, "README.md")),
};
const activationDescriptor = descriptor({
action: "activate",
expectedCurrent: "inactive",
gatewayPredecessor: predecessorGatewaySha256,
source,
image: {
tag: imageTag,
archiveRelativePath: imageArchiveRel,
archiveSha256,
configSha256: archive.configSha256,
architecture: "amd64",
os: "linux",
},
});
await buildArtifact(
work,
activationId,
activationEntries,
activationTarget,
async (payload) => {
await copyExactDirectory(join(engineRoot, sourceRootRel), join(payload, sourceRootRel));
await copyExactFile(join(engineRoot, gatewayRel), join(payload, gatewayRel));
await copyExactFile(join(engineRoot, serviceRootRel, "README.md"), join(payload, serviceRootRel, "README.md"));
await copyExactFile(join(engineRoot, overrideRel), join(payload, overrideRel));
await copyExactFile(imageArchive, join(payload, imageArchiveRel));
await writeJson(join(payload, descriptorRel), activationDescriptor);
},
);
const rollbackReadme = [
"# NDC Engine node intelligence (inactive rollback)",
"",
`This exact runner-owned descriptor deactivates ${releaseId}.`,
"The pinned image and source are retained for audit; the Compose overlay is absent,",
"the sidecar is removed, and the verified immutable backend is recreated alone.",
"",
].join("\n");
const rollbackReadmeSha256 = hashBytes(Buffer.from(rollbackReadme, "utf8"));
const rollbackDescriptor = descriptor({
action: "rollback-inactive",
expectedCurrent: releaseId,
gatewayPredecessor: source.gatewaySha256,
source: {
gatewaySha256: predecessorGatewaySha256,
readmeSha256: rollbackReadmeSha256,
},
image: {
tag: imageTag,
configSha256: archive.configSha256,
architecture: "amd64",
os: "linux",
},
});
await buildArtifact(
work,
rollbackId,
rollbackEntries,
rollbackTarget,
async (payload) => {
await writeGitFile(gatewayRel, join(payload, gatewayRel));
await mkdir(join(payload, serviceRootRel), { recursive: true });
await writeFile(join(payload, serviceRootRel, "README.md"), rollbackReadme, "utf8");
await writeJson(join(payload, descriptorRel), rollbackDescriptor);
},
);
const runnerSha256 = await hashFile(join(here, "nodedc-deploy"));
const activationSha256 = await hashFile(activationTarget);
const rollbackSha256 = await hashFile(rollbackTarget);
await writeFile(
`${activationTarget}.sha256`,
`${activationSha256} ${activationTarget.split("/").at(-1)}\n`,
"utf8",
);
await writeFile(
`${rollbackTarget}.sha256`,
`${rollbackSha256} ${rollbackTarget.split("/").at(-1)}\n`,
"utf8",
);
await writeFile(
commandsTarget,
commandPlan({ runnerSha256, activationSha256, rollbackSha256 }),
"utf8",
);
process.stdout.write(`${JSON.stringify({
ok: true,
transitionId,
releaseId,
upstreamCommit,
image: {
tag: imageTag,
localId: image.Id,
archiveSha256,
configSha256: archive.configSha256,
architecture: archive.architecture,
os: archive.os,
},
runner: { sha256: runnerSha256 },
activation: { artifact: activationTarget, sha256: activationSha256, entries: activationEntries },
rollback: { artifact: rollbackTarget, sha256: rollbackSha256, entries: rollbackEntries },
commands: commandsTarget,
}, null, 2)}\n`);
} finally {
await rm(work, { recursive: true, force: true });
}
function readTransitionId(args) {
if (args.length !== 1) throw new Error("usage: build-engine-node-intelligence-artifacts.mjs YYYYMMDD-NNN");
const value = String(args[0] || "").trim();
const match = /^(\d{4})(\d{2})(\d{2})-([0-9]{3})$/.exec(value);
if (!match || match[4] === "000") throw new Error("transition_id_invalid");
const parsed = new Date(Date.UTC(Number(match[1]), Number(match[2]) - 1, Number(match[3])));
if (
parsed.getUTCFullYear() !== Number(match[1])
|| parsed.getUTCMonth() !== Number(match[2]) - 1
|| parsed.getUTCDate() !== Number(match[3])
) throw new Error("transition_id_invalid");
return value;
}
async function assertFresh(path) {
try {
await lstat(path);
} catch (error) {
if (error?.code === "ENOENT") return;
throw error;
}
throw new Error(`target_already_exists:${path}`);
}
async function assertEngineInputs() {
const baselineGateway = Buffer.from(run("git", ["show", `HEAD:${gatewayRel}`], engineRoot).stdout, "utf8");
if (hashBytes(baselineGateway) !== predecessorGatewaySha256) throw new Error("gateway_predecessor_mismatch");
if (await hashFile(join(engineRoot, "docker-compose.yml")) !== predecessorComposeSha256) {
throw new Error("compose_predecessor_mismatch");
}
const sourceFiles = (await readdir(join(engineRoot, sourceRootRel))).sort();
if (JSON.stringify(sourceFiles) !== JSON.stringify(["catalog.js", "upstreamMcpClient.js", "upstreamProjection.js"])) {
throw new Error("node_intelligence_source_exact_set_mismatch");
}
const serviceFiles = (await readdir(join(engineRoot, serviceRootRel))).sort();
if (JSON.stringify(serviceFiles) !== JSON.stringify(["README.md", "docker-compose.immutable-runtime.yml"])) {
throw new Error("node_intelligence_service_exact_set_mismatch");
}
const gateway = await readFile(join(engineRoot, gatewayRel), "utf8");
for (const tool of [
"engine_get_node_intelligence_status",
"engine_get_node_guidance",
"engine_validate_node_configuration",
"engine_validate_l2_deep",
]) {
if (!gateway.includes(tool)) throw new Error(`gateway_tool_missing:${tool}`);
}
const override = await readFile(join(engineRoot, overrideRel), "utf8");
for (const exact of [
`image: ${imageTag}`,
"pull_policy: never",
'user: "11007:11007"',
"AUTH_TOKEN_FILE: /run/nodedc-secrets/engine-node-intelligence-auth-token",
"ENGINE_NODE_INTELLIGENCE_AUTH_TOKEN_FILE: /run/nodedc-secrets/engine-node-intelligence-auth-token",
"read_only: true",
"no-new-privileges:true",
]) {
if (!override.includes(exact)) throw new Error(`compose_contract_missing:${exact}`);
}
if (/^\s*(?:AUTH_TOKEN|N8N_API_URL|N8N_API_KEY):/m.test(override)) {
throw new Error("compose_runtime_authority_forbidden");
}
}
function inspectLocalImage() {
const raw = run("docker", ["image", "inspect", imageTag]).stdout;
const images = JSON.parse(raw);
if (!Array.isArray(images) || images.length !== 1) throw new Error("image_inspect_shape_mismatch");
const image = images[0];
const config = image.Config || {};
const labels = config.Labels || {};
if (
!/^sha256:[a-f0-9]{64}$/.test(String(image.Id || ""))
|| image.Architecture !== "amd64"
|| image.Os !== "linux"
|| !(image.RepoTags || []).includes(imageTag)
|| labels["org.opencontainers.image.revision"] !== upstreamCommit
|| JSON.stringify(config.Entrypoint) !== JSON.stringify(["/usr/local/bin/docker-entrypoint.sh"])
|| JSON.stringify(config.Cmd) !== JSON.stringify(["node", "dist/mcp/index.js"])
|| (config.Env || []).some((value) => /^(?:AUTH_TOKEN|N8N_API_URL|N8N_API_KEY)=/.test(String(value)))
) throw new Error("image_identity_mismatch");
return image;
}
function inspectImageArchive(path) {
const script = [
"import hashlib,json,pathlib,sys,tarfile",
"p=pathlib.Path(sys.argv[1])",
"with tarfile.open(p,'r:') as tf:",
" m=json.loads(tf.extractfile('manifest.json').read())",
" if not isinstance(m,list) or len(m)!=1: raise SystemExit('manifest-set')",
" r=m[0]",
` if r.get('RepoTags')!=[${JSON.stringify(imageTag)}]: raise SystemExit('tag')`,
" n=r.get('Config','')",
" raw=tf.extractfile(n).read()",
" digest=n.rsplit('/',1)[-1]",
" if hashlib.sha256(raw).hexdigest()!=digest: raise SystemExit('config-digest')",
" c=json.loads(raw)",
" cfg=c.get('config') or {}",
` if c.get('architecture')!='amd64' or c.get('os')!='linux' or (cfg.get('Labels') or {}).get('org.opencontainers.image.revision')!=${JSON.stringify(upstreamCommit)}: raise SystemExit('identity')`,
" if cfg.get('Entrypoint')!=['/usr/local/bin/docker-entrypoint.sh'] or cfg.get('Cmd')!=['node','dist/mcp/index.js']: raise SystemExit('command')",
" print(json.dumps({'configSha256':digest,'architecture':c['architecture'],'os':c['os']}))",
].join("\n");
return JSON.parse(run("python3", ["-c", script, path]).stdout);
}
function descriptor({ action, expectedCurrent, gatewayPredecessor, source, image }) {
return {
schemaVersion: "nodedc.engine-node-intelligence-transition/v1",
action,
releaseId,
expectedCurrent,
upstream: {
package: "n8n-mcp",
version: "2.33.2",
commit: upstreamCommit,
},
image,
source,
predecessor: {
gatewaySha256: gatewayPredecessor,
composeSha256: predecessorComposeSha256,
backendRuntime: "verified-derived-retry",
},
};
}
async function buildArtifact(workRoot, id, entries, target, populate) {
const stage = join(workRoot, id);
const payload = join(stage, "payload");
await mkdir(payload, { recursive: true });
await populate(payload);
await writeFile(join(stage, "manifest.env"), `id=${id}\ncomponent=engine\ntype=app-overlay\n`, "utf8");
await writeFile(join(stage, "files.txt"), `${entries.join("\n")}\n`, "utf8");
run("python3", ["-c", canonicalTarScript(), target, stage]);
}
async function copyExactDirectory(source, target) {
await mkdir(target, { recursive: true });
for (const name of (await readdir(source)).sort()) {
await copyExactFile(join(source, name), join(target, name));
}
}
async function copyExactFile(source, target) {
const sourceStat = await lstat(source);
if (!sourceStat.isFile() || sourceStat.isSymbolicLink()) throw new Error(`source_file_unsafe:${source}`);
await mkdir(dirname(target), { recursive: true });
await copyFile(source, target, 0);
}
async function writeGitFile(relativePath, target) {
const value = run("git", ["show", `HEAD:${relativePath}`], engineRoot).stdout;
await mkdir(dirname(target), { recursive: true });
await writeFile(target, value, "utf8");
}
async function writeJson(path, value) {
await mkdir(dirname(path), { recursive: true });
await writeFile(path, `${JSON.stringify(value, null, 2)}\n`, "utf8");
}
function canonicalTarScript() {
return [
"import gzip,io,pathlib,sys,tarfile",
"root=pathlib.Path(sys.argv[2])",
"with open(sys.argv[1],'wb') as out:",
" with gzip.GzipFile(filename='',mode='wb',fileobj=out,compresslevel=9,mtime=0) as gz:",
" with tarfile.open(fileobj=gz,mode='w',format=tarfile.PAX_FORMAT) as tar:",
" for top in ('manifest.env','files.txt','payload'):",
" p=root/top; paths=[p]+(sorted(p.rglob('*')) if p.is_dir() else [])",
" for x in paths:",
" info=tar.gettarinfo(str(x),arcname=x.relative_to(root).as_posix())",
" info.uid=info.gid=0; info.uname=info.gname='root'; info.mtime=0; info.mode=0o755 if info.isdir() else 0o644",
" with (open(x,'rb') if info.isfile() else io.BytesIO()) as src: tar.addfile(info,src if info.isfile() else None)",
].join("\n");
}
function commandPlan({ runnerSha256, activationSha256, rollbackSha256 }) {
return [
`transition=${transitionId}`,
`runner_sha256=${runnerSha256}`,
`activation_sha256=${activationSha256}`,
`rollback_sha256=${rollbackSha256}`,
"",
"# 1. Read-only gate: no deploy process and no state/deploy.lock.",
"# 2. Promote the exact staged runner in a standalone sudo step:",
`sudo sha256sum /volume1/docker/nodedc-deploy/runner-install/candidates/nodedc-deploy.${activationId}`,
"sudo install -o root -g root -m 0755 \\",
` /volume1/docker/nodedc-deploy/runner-install/candidates/nodedc-deploy.${activationId} \\`,
" /usr/local/sbin/nodedc-deploy",
"sudo /usr/local/sbin/nodedc-deploy verify-install",
"",
"# 3. Activation is always plan, then explicit apply:",
`sudo /usr/local/sbin/nodedc-deploy plan /volume1/docker/nodedc-deploy/inbox/${activationTarget.split("/").at(-1)}`,
`sudo /usr/local/sbin/nodedc-deploy apply /volume1/docker/nodedc-deploy/inbox/${activationTarget.split("/").at(-1)}`,
"",
"# 4. Rollback is operator-invoked only after its own plan:",
`sudo /usr/local/sbin/nodedc-deploy plan /volume1/docker/nodedc-deploy/inbox/${rollbackTarget.split("/").at(-1)}`,
`sudo /usr/local/sbin/nodedc-deploy apply /volume1/docker/nodedc-deploy/inbox/${rollbackTarget.split("/").at(-1)}`,
"",
].join("\n");
}
function hashBytes(value) {
return createHash("sha256").update(value).digest("hex");
}
function hashFile(path) {
return new Promise((resolveHash, rejectHash) => {
const digest = createHash("sha256");
const input = createReadStream(path);
input.on("data", (chunk) => digest.update(chunk));
input.on("error", rejectHash);
input.on("end", () => resolveHash(digest.digest("hex")));
});
}
function run(command, args, cwd) {
const result = spawnSync(command, args, {
cwd,
encoding: "utf8",
maxBuffer: 128 * 1024 * 1024,
stdio: ["ignore", "pipe", "pipe"],
});
if (result.status !== 0) throw new Error(`${command}_failed:${result.stderr || result.stdout}`);
return result;
}

View File

@ -0,0 +1,200 @@
#!/usr/bin/env node
import { createHash } from "node:crypto";
import { spawnSync } from "node:child_process";
import { copyFile, lstat, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
const here = dirname(fileURLToPath(import.meta.url));
const workspaceRoot = resolve(here, "../../..");
const engineRoot = resolve(
process.env.NODEDC_ENGINE_SOURCE_ROOT || join(workspaceRoot, "NODEDC_ENGINE_INFRA"),
);
const artifactRoot = resolve(
process.env.NODEDC_DEPLOY_ARTIFACT_DIR || resolve(here, "../deploy-artifacts"),
);
const baselineArtifact = resolve(
here,
"../deploy-artifacts/nodedc-engine-mcp-autonomy-provider-v5-20260720-004.tgz",
);
const baselineArtifactSha256 =
"3400954cdce078892a06b04b9bfd85a90f6ea775b1a0caf99eba1f880ef6601c";
const projectionRel = "nodedc-source/server/nodeIntelligence/upstreamProjection.js";
const descriptorRel = "nodedc-source/services/node-intelligence/activation.json";
const [patchId = "", ...extra] = process.argv.slice(2);
if (extra.length || !/^engine-provider-authority-diagnostics-\d{8}-\d{3}$/.test(patchId)) {
throw new Error(
"usage: build-engine-provider-authority-diagnostics-artifact.mjs " +
"<engine-provider-authority-diagnostics-YYYYMMDD-NNN>",
);
}
const targetSha256 = Object.freeze({
[projectionRel]:
"2dfa6b4f37d9bfa8b92a8109d4060d02dd2634ceb8f7924504b83ccf3fdd1523",
[descriptorRel]:
"84b0e15a10cedf334ad04d6f31c908da2dc155503c9974f0eeb75b01e20fb884",
});
const entries = Object.freeze(Object.keys(targetSha256));
const artifact = join(artifactRoot, `nodedc-${patchId}.tgz`);
await assertFresh(artifact);
await assertExactSources();
const descriptorText = await buildTargetDescriptor();
const stage = await mkdtemp(join(tmpdir(), "nodedc-engine-provider-authority-diagnostics-"));
try {
const payload = join(stage, "payload");
for (const relativePath of entries) {
const destination = join(payload, relativePath);
await mkdir(dirname(destination), { recursive: true });
if (relativePath === descriptorRel) {
await writeFile(destination, descriptorText, {
encoding: "utf8",
flag: "wx",
mode: 0o644,
});
} else {
await copyFile(join(engineRoot, relativePath), destination);
}
}
await writeFile(
join(stage, "manifest.env"),
`id=${patchId}\ncomponent=engine\ntype=app-overlay\n`,
{ encoding: "utf8", flag: "wx", mode: 0o644 },
);
await writeFile(join(stage, "files.txt"), `${entries.join("\n")}\n`, {
encoding: "utf8",
flag: "wx",
mode: 0o644,
});
await mkdir(artifactRoot, { recursive: true });
run("python3", ["-c", canonicalTarScript(), artifact, stage]);
console.log(JSON.stringify({
ok: true,
patchId,
artifact,
sha256: digest(await readFile(artifact)),
entries,
targetSha256,
services: ["nodedc-backend"],
transition: "private-node-name-reconciliation",
validationBoundary: "engine-pinned-private-node-catalog",
privateNodeIdentity: "node-id-or-exact-display-name",
credentialValues: "preserved",
untouched: ["L2 graph", "n8n", "L1", "Engine UI", "databases"],
}, null, 2));
} finally {
await rm(stage, { recursive: true, force: true });
}
async function assertExactSources() {
for (const [relativePath, expected] of Object.entries(targetSha256)) {
if (relativePath === descriptorRel) continue;
const sourcePath = join(engineRoot, relativePath);
const info = await lstat(sourcePath);
if (!info.isFile() || info.isSymbolicLink()) {
throw new Error(`engine_provider_authority_diagnostics_source_unsafe:${relativePath}`);
}
const actual = digest(await readFile(sourcePath));
if (actual !== expected) {
throw new Error(
`engine_provider_authority_diagnostics_target_mismatch:${relativePath}:` +
`expected=${expected}:actual=${actual}`,
);
}
const source = await readFile(sourcePath, "utf8");
for (const marker of [
"isUnknownPrivateNodeIssue",
"issue?.nodeName || issue?.node",
"privateResults",
"NDC_PRIVATE_NODE_VALIDATED_BY_ENGINE_CATALOG",
]) {
if (!source.includes(marker)) {
throw new Error(`engine_provider_authority_diagnostics_marker_missing:${marker}`);
}
}
}
}
async function buildTargetDescriptor() {
const baselineBytes = await readFile(baselineArtifact);
if (digest(baselineBytes) !== baselineArtifactSha256) {
throw new Error("engine_node_intelligence_baseline_artifact_sha256_mismatch");
}
const result = run("python3", [
"-c",
[
"import pathlib,sys,tarfile",
"p=pathlib.Path(sys.argv[1]); name=sys.argv[2]",
"with tarfile.open(p,'r:gz') as t:",
" m=t.getmember(name)",
" if not m.isfile(): raise SystemExit('member-not-file')",
" f=t.extractfile(m)",
" if f is None: raise SystemExit('member-unreadable')",
" sys.stdout.buffer.write(f.read())",
].join("\n"),
baselineArtifact,
`payload/${descriptorRel}`,
]);
const descriptor = JSON.parse(result.stdout);
if (
descriptor?.schemaVersion !== "nodedc.engine-node-intelligence-transition/v1"
|| descriptor?.action !== "activate"
|| descriptor?.releaseId !== "2.33.2-974a9fb3492f"
|| descriptor?.source?.upstreamProjectionSha256
!== "761a874b102a938bc6018159ddacdaac71ad6ae08e9f0f8d7f3b58a0165a5131"
|| descriptor?.source?.gatewaySha256
!== "5331f6dc8dc306f641370a2968eb025ee3e278e27ae9a217e4cfc2901fec369c"
) {
throw new Error("engine_node_intelligence_baseline_descriptor_mismatch");
}
descriptor.source.upstreamProjectionSha256 = targetSha256[projectionRel];
const text = `${JSON.stringify(descriptor, null, 2)}\n`;
if (digest(Buffer.from(text, "utf8")) !== targetSha256[descriptorRel]) {
throw new Error("engine_node_intelligence_target_descriptor_sha256_mismatch");
}
return text;
}
async function assertFresh(path) {
try {
await lstat(path);
} catch (error) {
if (error?.code === "ENOENT") return;
throw error;
}
throw new Error("artifact_already_exists");
}
function canonicalTarScript() {
return [
"import gzip,io,pathlib,sys,tarfile",
"root=pathlib.Path(sys.argv[2])",
"with open(sys.argv[1],'xb') as out:",
" with gzip.GzipFile(filename='',mode='wb',fileobj=out,compresslevel=9,mtime=0) as gz:",
" with tarfile.open(fileobj=gz,mode='w',format=tarfile.PAX_FORMAT) as tar:",
" for top in ('manifest.env','files.txt','payload'):",
" p=root/top; paths=[p]+(sorted(p.rglob('*')) if p.is_dir() else [])",
" for x in paths:",
" info=tar.gettarinfo(str(x),arcname=x.relative_to(root).as_posix())",
" info.uid=info.gid=0; info.uname=info.gname='root'; info.mtime=0; info.mode=0o755 if info.isdir() else 0o644",
" with (open(x,'rb') if info.isfile() else io.BytesIO()) as src: tar.addfile(info,src if info.isfile() else None)",
].join("\n");
}
function digest(value) {
return createHash("sha256").update(value).digest("hex");
}
function run(command, args) {
const result = spawnSync(command, args, {
encoding: "utf8",
maxBuffer: 128 * 1024 * 1024,
stdio: ["ignore", "pipe", "pipe"],
});
if (result.status !== 0) {
throw new Error(`${command}_failed:${result.stderr || result.stdout}`);
}
return result;
}

View File

@ -0,0 +1,140 @@
#!/usr/bin/env node
import { createHash } from "node:crypto";
import { spawnSync } from "node:child_process";
import { copyFile, lstat, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
const here = dirname(fileURLToPath(import.meta.url));
const workspaceRoot = resolve(here, "../../..");
const engineRoot = resolve(
process.env.NODEDC_ENGINE_SOURCE_ROOT || join(workspaceRoot, "NODEDC_ENGINE_INFRA"),
);
const artifactRoot = resolve(
process.env.NODEDC_DEPLOY_ARTIFACT_DIR || resolve(here, "../deploy-artifacts"),
);
const [patchId = "", ...extra] = process.argv.slice(2);
if (extra.length || !/^engine-provider-rotating-slot-\d{8}-\d{3}$/.test(patchId)) {
throw new Error(
"usage: build-engine-provider-rotating-slot-artifact.mjs " +
"<engine-provider-rotating-slot-YYYYMMDD-NNN>",
);
}
const targetSha256 = Object.freeze({
"nodedc-source/server/assets/provider-packages/v1/catalog.json":
"17f3e368f3264cbbd708965c9e1fd735aa974f15a1383bf88cd6d14a43dbf32d",
"nodedc-source/server/dataProductPublishGrant/providerCatalog.js":
"f6cf5f4de9e57f87fb904e2136a9f34d494e1ffc289aec3ed9ed788b5583a062",
});
const entries = Object.freeze(Object.keys(targetSha256));
const artifact = join(artifactRoot, `nodedc-${patchId}.tgz`);
await assertFresh(artifact);
await assertExactSources();
const stage = await mkdtemp(join(tmpdir(), "nodedc-engine-provider-rotating-slot-"));
try {
const payload = join(stage, "payload");
for (const relativePath of entries) {
const destination = join(payload, relativePath);
await mkdir(dirname(destination), { recursive: true });
await copyFile(join(engineRoot, relativePath), destination);
}
await writeFile(
join(stage, "manifest.env"),
`id=${patchId}\ncomponent=engine\ntype=app-overlay\n`,
{ encoding: "utf8", flag: "wx", mode: 0o644 },
);
await writeFile(join(stage, "files.txt"), `${entries.join("\n")}\n`, {
encoding: "utf8",
flag: "wx",
mode: 0o644,
});
await mkdir(artifactRoot, { recursive: true });
run("python3", ["-c", canonicalTarScript(), artifact, stage]);
console.log(JSON.stringify({
ok: true,
patchId,
artifact,
sha256: digest(await readFile(artifact)),
entries,
targetSha256,
services: ["nodedc-backend"],
transition: "exact-active-credential-slot-alignment",
providerPackage: "gelios.provider.v4",
authModeId: "gelios.rest-rotating-bearer.v3",
credentialSlot: "ndcProviderRotatingAccessApi",
credentialValues: "preserved",
untouched: ["L2 graph", "n8n", "L1", "Engine UI", "databases"],
}, null, 2));
} finally {
await rm(stage, { recursive: true, force: true });
}
async function assertExactSources() {
for (const [relativePath, expected] of Object.entries(targetSha256)) {
const path = join(engineRoot, relativePath);
const info = await lstat(path);
if (!info.isFile() || info.isSymbolicLink()) {
throw new Error(`engine_provider_rotating_slot_source_unsafe:${relativePath}`);
}
const actual = digest(await readFile(path));
if (actual !== expected) {
throw new Error(
`engine_provider_rotating_slot_target_mismatch:${relativePath}:` +
`expected=${expected}:actual=${actual}`,
);
}
}
const catalog = JSON.parse(await readFile(join(engineRoot, entries[0]), "utf8"));
const provider = catalog?.packages?.[0];
if (
provider?.id !== "gelios.provider.v4" ||
provider?.providerCredential?.authModeId !== "gelios.rest-rotating-bearer.v3" ||
provider?.providerCredential?.credentialType !== "ndcProviderRotatingAccessApi"
) {
throw new Error("engine_provider_rotating_slot_catalog_projection_mismatch");
}
}
async function assertFresh(path) {
try {
await lstat(path);
} catch (error) {
if (error?.code === "ENOENT") return;
throw error;
}
throw new Error("artifact_already_exists");
}
function canonicalTarScript() {
return [
"import gzip,io,pathlib,sys,tarfile",
"root=pathlib.Path(sys.argv[2])",
"with open(sys.argv[1],'xb') as out:",
" with gzip.GzipFile(filename='',mode='wb',fileobj=out,compresslevel=9,mtime=0) as gz:",
" with tarfile.open(fileobj=gz,mode='w',format=tarfile.PAX_FORMAT) as tar:",
" for top in ('manifest.env','files.txt','payload'):",
" p=root/top; paths=[p]+(sorted(p.rglob('*')) if p.is_dir() else [])",
" for x in paths:",
" info=tar.gettarinfo(str(x),arcname=x.relative_to(root).as_posix())",
" info.uid=info.gid=0; info.uname=info.gname='root'; info.mtime=0; info.mode=0o755 if info.isdir() else 0o644",
" with (open(x,'rb') if info.isfile() else io.BytesIO()) as src: tar.addfile(info,src if info.isfile() else None)",
].join("\n");
}
function digest(value) {
return createHash("sha256").update(value).digest("hex");
}
function run(command, args) {
const result = spawnSync(command, args, {
encoding: "utf8",
maxBuffer: 128 * 1024 * 1024,
stdio: ["ignore", "pipe", "pipe"],
});
if (result.status !== 0) {
throw new Error(`${command}_failed:${result.stderr || result.stdout}`);
}
}

View File

@ -0,0 +1,96 @@
#!/usr/bin/env node
import { createHash } from "node:crypto";
import { spawnSync } from "node:child_process";
import { cp, lstat, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
const here = dirname(fileURLToPath(import.meta.url));
const platformRoot = resolve(here, "../..");
const engineRoot = resolve(
process.env.NODEDC_ENGINE_SOURCE_ROOT || resolve(platformRoot, "../NODEDC_ENGINE_INFRA"),
);
const artifactRoot = resolve(
process.env.NODEDC_DEPLOY_ARTIFACT_DIR || resolve(here, "../deploy-artifacts"),
);
const [transitionId = "20260723-025", ...extra] = process.argv.slice(2);
if (extra.length || !/^\d{8}-[0-9]{3}$/.test(transitionId)) {
throw new Error("usage: build-engine-provider-security-catalog-artifact.mjs [YYYYMMDD-NNN]");
}
const id = `engine-provider-security-catalog-${transitionId}`;
const target = join(artifactRoot, `nodedc-${id}.tgz`);
const file = "nodedc-source/server/assets/provider-packages/v1/catalog.json";
const expectedSha256 = "773335bb616a5c03eb2108c4f53ef092c02e75ee75f014511201bc12e2956b27";
const source = join(engineRoot, file);
const catalogBytes = await readFile(source);
await assertFresh(target);
if (sha(catalogBytes) !== expectedSha256) throw new Error(`pinned_catalog_sha256_mismatch:${file}`);
const stage = await mkdtemp(join(tmpdir(), "nodedc-engine-provider-security-catalog-"));
const payload = join(stage, "payload");
try {
await mkdir(dirname(join(payload, file)), { recursive: true });
await cp(source, join(payload, file), { force: false });
await writeFile(join(stage, "manifest.env"), `id=${id}\ncomponent=engine\ntype=app-overlay\n`, "utf8");
await writeFile(join(stage, "files.txt"), `${file}\n`, "utf8");
await mkdir(artifactRoot, { recursive: true });
run("python3", ["-c", canonicalTarScript(), target, stage]);
console.log(JSON.stringify({
ok: true,
id,
artifact: target,
artifactSha256: sha(await readFile(target)),
services: ["nodedc-backend"],
providerPackage: "gelios.provider.v8",
providerCredential: "ndcProviderRotatingAccessApi",
endpoint: "https://api.geliospro.com/api/v1/units?incltrip=true&inclcntrs=true&inclsnsrs=true&incllsv=true",
dataProductId: "fleet.units.profile.current.v1",
preserved: ["n8n", "L1 graph", "Engine UI", "databases", "provider credential values"],
files: [file],
}, null, 2));
} finally {
await rm(stage, { recursive: true, force: true });
}
async function assertFresh(path) {
try {
await lstat(path);
} catch (error) {
if (error?.code === "ENOENT") return;
throw error;
}
throw new Error("artifact_already_exists");
}
function canonicalTarScript() {
return [
"import gzip,io,pathlib,sys,tarfile",
"root=pathlib.Path(sys.argv[2])",
"with open(sys.argv[1],'wb') as out:",
" with gzip.GzipFile(filename='',mode='wb',fileobj=out,compresslevel=9,mtime=0) as gz:",
" with tarfile.open(fileobj=gz,mode='w',format=tarfile.PAX_FORMAT) as tar:",
" for top in ('manifest.env','files.txt','payload'):",
" p=root/top; paths=[p]+(sorted(p.rglob('*')) if p.is_dir() else [])",
" for x in paths:",
" info=tar.gettarinfo(str(x),arcname=x.relative_to(root).as_posix())",
" info.uid=info.gid=0; info.uname=info.gname='root'; info.mtime=0; info.mode=0o755 if info.isdir() else 0o644",
" with (open(x,'rb') if info.isfile() else io.BytesIO()) as src: tar.addfile(info,src if info.isfile() else None)",
].join("\n");
}
function sha(value) {
return createHash("sha256").update(value).digest("hex");
}
function run(command, args) {
const result = spawnSync(command, args, {
encoding: "utf8",
maxBuffer: 128 * 1024 * 1024,
stdio: ["ignore", "pipe", "pipe"],
});
if (result.status !== 0) throw new Error(`${command}_failed:${result.stderr || result.stdout}`);
return result;
}

View File

@ -0,0 +1,138 @@
#!/usr/bin/env node
import { createHash } from "node:crypto";
import { spawnSync } from "node:child_process";
import { copyFile, lstat, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
const here = dirname(fileURLToPath(import.meta.url));
const workspaceRoot = resolve(here, "../../..");
const engineRoot = resolve(
process.env.NODEDC_ENGINE_SOURCE_ROOT || join(workspaceRoot, "NODEDC_ENGINE_INFRA"),
);
const artifactRoot = resolve(
process.env.NODEDC_DEPLOY_ARTIFACT_DIR || resolve(here, "../deploy-artifacts"),
);
const [patchId = "", ...extra] = process.argv.slice(2);
if (extra.length || !/^engine-provider-target-host-policy-\d{8}-\d{3}$/.test(patchId)) {
throw new Error(
"usage: build-engine-provider-target-host-policy-artifact.mjs " +
"<engine-provider-target-host-policy-YYYYMMDD-NNN>",
);
}
const targetSha256 = Object.freeze({
"nodedc-source/server/routes/n8n.js":
"9bc3638e271102abec91bb80413329befb89d60c0e4dc0548f0dd11e93220d0a",
});
const entries = Object.freeze(Object.keys(targetSha256));
const artifact = join(artifactRoot, `nodedc-${patchId}.tgz`);
await assertFresh(artifact);
await assertExactSources();
const stage = await mkdtemp(join(tmpdir(), "nodedc-engine-provider-target-host-policy-"));
try {
const payload = join(stage, "payload");
for (const relativePath of entries) {
const destination = join(payload, relativePath);
await mkdir(dirname(destination), { recursive: true });
await copyFile(join(engineRoot, relativePath), destination);
}
await writeFile(
join(stage, "manifest.env"),
`id=${patchId}\ncomponent=engine\ntype=app-overlay\n`,
{ encoding: "utf8", flag: "wx", mode: 0o644 },
);
await writeFile(join(stage, "files.txt"), `${entries.join("\n")}\n`, {
encoding: "utf8",
flag: "wx",
mode: 0o644,
});
await mkdir(artifactRoot, { recursive: true });
run("python3", ["-c", canonicalTarScript(), artifact, stage]);
console.log(JSON.stringify({
ok: true,
patchId,
artifact,
sha256: digest(await readFile(artifact)),
entries,
targetSha256,
services: ["nodedc-backend"],
transition: "exact-provider-literal-target-host",
providerPackage: "gelios.provider.v4",
dataProductId: "fleet.positions.current.v3",
credentialValues: "preserved",
untouched: ["L2 graph", "n8n", "L1", "Engine UI", "databases"],
}, null, 2));
} finally {
await rm(stage, { recursive: true, force: true });
}
async function assertExactSources() {
for (const [relativePath, expected] of Object.entries(targetSha256)) {
const sourcePath = join(engineRoot, relativePath);
const info = await lstat(sourcePath);
if (!info.isFile() || info.isSymbolicLink()) {
throw new Error(`engine_provider_target_host_policy_source_unsafe:${relativePath}`);
}
const actual = digest(await readFile(sourcePath));
if (actual !== expected) {
throw new Error(
`engine_provider_target_host_policy_target_mismatch:${relativePath}:` +
`expected=${expected}:actual=${actual}`,
);
}
const source = await readFile(sourcePath, "utf8");
for (const marker of [
"const eligibleHosts = explicitHosts.length",
"allowedHosts: [targetHost]",
"credential_host_not_allowed",
]) {
if (!source.includes(marker)) {
throw new Error(`engine_provider_target_host_policy_marker_missing:${marker}`);
}
}
}
}
async function assertFresh(path) {
try {
await lstat(path);
} catch (error) {
if (error?.code === "ENOENT") return;
throw error;
}
throw new Error("artifact_already_exists");
}
function canonicalTarScript() {
return [
"import gzip,io,pathlib,sys,tarfile",
"root=pathlib.Path(sys.argv[2])",
"with open(sys.argv[1],'xb') as out:",
" with gzip.GzipFile(filename='',mode='wb',fileobj=out,compresslevel=9,mtime=0) as gz:",
" with tarfile.open(fileobj=gz,mode='w',format=tarfile.PAX_FORMAT) as tar:",
" for top in ('manifest.env','files.txt','payload'):",
" p=root/top; paths=[p]+(sorted(p.rglob('*')) if p.is_dir() else [])",
" for x in paths:",
" info=tar.gettarinfo(str(x),arcname=x.relative_to(root).as_posix())",
" info.uid=info.gid=0; info.uname=info.gname='root'; info.mtime=0; info.mode=0o755 if info.isdir() else 0o644",
" with (open(x,'rb') if info.isfile() else io.BytesIO()) as src: tar.addfile(info,src if info.isfile() else None)",
].join("\n");
}
function digest(value) {
return createHash("sha256").update(value).digest("hex");
}
function run(command, args) {
const result = spawnSync(command, args, {
encoding: "utf8",
maxBuffer: 128 * 1024 * 1024,
stdio: ["ignore", "pipe", "pipe"],
});
if (result.status !== 0) {
throw new Error(`${command}_failed:${result.stderr || result.stdout}`);
}
}

View File

@ -0,0 +1,148 @@
#!/usr/bin/env node
import { createHash } from 'node:crypto'
import { spawnSync } from 'node:child_process'
import { copyFile, lstat, mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { dirname, join, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
const here = dirname(fileURLToPath(import.meta.url))
const platformRoot = resolve(here, '../..')
const workspaceRoot = resolve(platformRoot, '..')
const engineRoot = resolve(workspaceRoot, 'NODEDC_ENGINE_INFRA')
const artifactRoot = resolve(process.env.NODEDC_DEPLOY_ARTIFACT_DIR || join(workspaceRoot, 'deploy-artifacts'))
const [patchId = '', ...extra] = process.argv.slice(2)
if (extra.length || !/^engine-restart-safe-auth-\d{8}-\d{3}$/.test(patchId)) {
throw new Error('usage: build-engine-restart-safe-auth-artifact.mjs engine-restart-safe-auth-YYYYMMDD-NNN')
}
const entries = Object.freeze([
'nodedc-source/server/auth/engineHandoffSession.js',
'nodedc-source/server/index.js',
'nodedc-source/src/App.tsx',
'nodedc-source/src/platform/auth/access.ts',
'nodedc-source/src/platform/auth/sessionRecovery.ts',
'nodedc-source/dist/index.html',
'nodedc-source/dist/assets/index-Bim2pv1P.css',
'nodedc-source/dist/assets/index-4TenBzkg.js',
])
const candidateSha256 = Object.freeze({
'nodedc-source/server/auth/engineHandoffSession.js': '0cc5bf02b11cd5fc8cc8cad018091ca93efb13bc72616348ada61105af15bd8e',
'nodedc-source/server/index.js': '0ac408e0e9a7bc5c8e13a00afc957b982b065e2bc414919839e0b0a11aa05ba4',
'nodedc-source/src/App.tsx': 'd9bc0f23ceaaf4f5534ac9a9b1f97d84fd8eee4679c59b5999b5a75b8c77bb32',
'nodedc-source/src/platform/auth/access.ts': '9610dc1622a1c9b383eb3a1569347098002aa058dbbcbb23f096f2622d452222',
'nodedc-source/src/platform/auth/sessionRecovery.ts': 'f931cbb45d64d5d6a2868194e8c73879a408dffca1fdd7e41acfa262441c50d7',
'nodedc-source/dist/index.html': 'b031f6720683f6fb5ccfa59a01414ff2a3efcdf548b1c6aab33e1748ee3f003d',
'nodedc-source/dist/assets/index-Bim2pv1P.css': '18322addd45c126a7b8396f36b005f3085fddba3e9b346dd2c910f6fa6987ebf',
'nodedc-source/dist/assets/index-4TenBzkg.js': 'a48fcacf3348c20a432cd6da2627b89c0c3e54bc989c727b620125dd3412538a',
})
const predecessorSha256 = Object.freeze({
'nodedc-source/server/index.js': 'b2b790b02839570d967a2ca68b00e2724485a99389ac9b3a589a1b22302a36b8',
'nodedc-source/src/App.tsx': '2ea264a59c3e82f763be74f0312cb1aa6bce856644ba0bc7024d77e238e7eb41',
'nodedc-source/src/platform/auth/access.ts': '4fb9c4d524765dff8a5360efa1c0acf1d203da1cc2c7fd3b98e1456261d43d41',
'nodedc-source/src/platform/auth/sessionRecovery.ts': '997d3a18e439308d4650e3c7324710a8b17dea29976001c4275c84d96b18ccfe',
})
for (const [rel, expected] of Object.entries(predecessorSha256)) {
const result = spawnSync('git', ['-C', engineRoot, 'show', `HEAD:${rel}`], {
stdio: ['ignore', 'pipe', 'pipe'],
maxBuffer: 128 * 1024 * 1024,
})
if (result.status !== 0 || digest(result.stdout) !== expected) {
throw new Error(`engine_auth_predecessor_mismatch:${rel}`)
}
}
const newModulePath = 'nodedc-source/server/auth/engineHandoffSession.js'
const newModuleProbe = spawnSync('git', ['-C', engineRoot, 'cat-file', '-e', `HEAD:${newModulePath}`], {
stdio: 'ignore',
})
if (newModuleProbe.status === 0) throw new Error('engine_auth_new_module_predecessor_unexpected')
for (const rel of entries) {
const source = resolve(engineRoot, rel)
const info = await lstat(source)
if (info.isSymbolicLink() || !info.isFile()) throw new Error(`engine_auth_candidate_unsafe:${rel}`)
if (digest(await readFile(source)) !== candidateSha256[rel]) {
throw new Error(`engine_auth_candidate_sha256_mismatch:${rel}`)
}
}
const indexHtml = await readFile(resolve(engineRoot, 'nodedc-source/dist/index.html'), 'utf8')
if (!indexHtml.includes('/assets/index-4TenBzkg.js') || !indexHtml.includes('/assets/index-Bim2pv1P.css')) {
throw new Error('engine_auth_dist_entrypoint_mismatch')
}
await mkdir(artifactRoot, { recursive: true })
const artifact = join(artifactRoot, `nodedc-${patchId}.tgz`)
await assertArtifactTargetFresh(artifact)
const stage = await mkdtemp(join(tmpdir(), 'nodedc-engine-restart-safe-auth-'))
try {
await writeFile(
join(stage, 'manifest.env'),
`id=${patchId}\ncomponent=engine\ntype=app-overlay\n`,
'utf8',
)
await writeFile(join(stage, 'files.txt'), `${entries.join('\n')}\n`, 'utf8')
for (const rel of entries) {
const target = join(stage, 'payload', rel)
await mkdir(dirname(target), { recursive: true })
await copyFile(resolve(engineRoot, rel), target)
}
run('python3', ['-c', canonicalTarScript(), artifact, stage])
console.log(JSON.stringify({
ok: true,
patchId,
artifact,
sha256: digest(await readFile(artifact)),
entries,
predecessorSha256,
candidateSha256,
runtimeServices: ['nodedc-backend', 'app'],
}, null, 2))
} finally {
await rm(stage, { recursive: true, force: true })
}
async function assertArtifactTargetFresh(target) {
try {
await lstat(target)
} catch (error) {
if (error?.code === 'ENOENT') return
throw error
}
throw new Error('engine_auth_artifact_already_exists')
}
function canonicalTarScript() {
return [
'import gzip,io,pathlib,sys,tarfile',
'root=pathlib.Path(sys.argv[2])',
"with open(sys.argv[1],'wb') as out:",
" with gzip.GzipFile(filename='',mode='wb',fileobj=out,compresslevel=9,mtime=0) as gz:",
" with tarfile.open(fileobj=gz,mode='w',format=tarfile.PAX_FORMAT) as tar:",
" for top in ('manifest.env','files.txt','payload'):",
" p=root/top; paths=[p]+(sorted(p.rglob('*')) if p.is_dir() else [])",
' for x in paths:',
" info=tar.gettarinfo(str(x),arcname=x.relative_to(root).as_posix())",
" info.uid=info.gid=0; info.uname=info.gname='root'; info.mtime=0; info.mode=0o755 if info.isdir() else 0o644",
" with (open(x,'rb') if info.isfile() else io.BytesIO()) as src: tar.addfile(info,src if info.isfile() else None)",
].join('\n')
}
function run(command, args) {
const result = spawnSync(command, args, {
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'pipe'],
maxBuffer: 128 * 1024 * 1024,
})
if (result.status !== 0) throw new Error(`${command}_failed:${result.stderr || result.stdout}`)
}
function digest(bytes) {
return createHash('sha256').update(bytes).digest('hex')
}

View File

@ -0,0 +1,168 @@
#!/usr/bin/env node
import { createHash } from "node:crypto";
import { spawnSync } from "node:child_process";
import { cp, lstat, mkdir, mkdtemp, readFile, readdir, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { dirname, join, relative, resolve } from "node:path";
import { fileURLToPath } from "node:url";
const scriptDir = dirname(fileURLToPath(import.meta.url));
const platformRoot = resolve(scriptDir, "../..");
const artifactDir = resolve(process.env.NODEDC_DEPLOY_ARTIFACT_DIR || resolve(scriptDir, "../deploy-artifacts"));
const [patchId = "external-data-plane-20260714-001", ...extra] = process.argv.slice(2);
if (extra.length || !/^[A-Za-z0-9._-]{1,96}$/.test(patchId)) {
throw new Error("usage: build-external-data-plane-artifact.mjs [patch-id]");
}
const files = [
["infra/synology/docker-compose.external-data-plane.yml", "platform/docker-compose.external-data-plane.yml"],
["services/external-data-plane", "platform/services/external-data-plane"],
["packages/external-provider-contract/package.json", "platform/packages/external-provider-contract/package.json"],
["packages/external-provider-contract/src/contract-version.mjs", "platform/packages/external-provider-contract/src/contract-version.mjs"],
["packages/external-provider-contract/src/data-plane.mjs", "platform/packages/external-provider-contract/src/data-plane.mjs"],
["packages/external-provider-contract/src/data-product.mjs", "platform/packages/external-provider-contract/src/data-product.mjs"],
["packages/external-provider-contract/src/geometry.mjs", "platform/packages/external-provider-contract/src/geometry.mjs"],
["packages/external-provider-contract/src/intake-batch.mjs", "platform/packages/external-provider-contract/src/intake-batch.mjs"],
["packages/external-provider-contract/src/index.mjs", "platform/packages/external-provider-contract/src/index.mjs"],
["packages/external-provider-contract/src/provider-capability-catalog.mjs", "platform/packages/external-provider-contract/src/provider-capability-catalog.mjs"],
["packages/external-provider-contract/src/provider-package.mjs", "platform/packages/external-provider-contract/src/provider-package.mjs"],
["packages/external-provider-contract/src/sensitive-field-policy.mjs", "platform/packages/external-provider-contract/src/sensitive-field-policy.mjs"],
["packages/external-provider-contract/src/telemetry-readings.mjs", "platform/packages/external-provider-contract/src/telemetry-readings.mjs"],
["packages/external-provider-contract/src/zone-source.mjs", "platform/packages/external-provider-contract/src/zone-source.mjs"],
["packages/external-provider-contract/providers/gelios", "platform/packages/external-provider-contract/providers/gelios"],
];
const ignoredBasenames = new Set([".DS_Store", ".git", "node_modules"]);
const ignoredDirectoryNames = new Set(["test"]);
const stage = await mkdtemp(join(tmpdir(), "nodedc-external-data-plane-artifact-"));
const payload = join(stage, "payload");
const target = join(artifactDir, `nodedc-platform-${patchId}.tgz`);
await assertSourceBoundary();
try {
await mkdir(payload, { recursive: true });
for (const [sourceRelative, destinationRelative] of files) {
await copySafe(resolve(platformRoot, sourceRelative), join(payload, destinationRelative));
}
await writeFile(join(stage, "manifest.env"), `id=${patchId}\ncomponent=platform\ntype=app-overlay\n`, "utf8");
await writeFile(join(stage, "files.txt"), `${files.map(([, destination]) => destination).join("\n")}\n`, "utf8");
await mkdir(artifactDir, { recursive: true });
const tar = spawnSync("python3", ["-c", canonicalTarScript(), target, stage], {
encoding: "utf8",
maxBuffer: 128 * 1024 * 1024,
});
if (tar.status !== 0) throw new Error(`tar_failed:${tar.stderr || tar.stdout}`);
const digest = createHash("sha256").update(await readFile(target)).digest("hex");
console.log(JSON.stringify({
ok: true,
patchId,
artifact: target,
sha256: digest,
entries: files.map(([, destination]) => destination),
excluded: [
".env*",
"node_modules",
"services/external-data-plane/test",
"private-key.pem",
"secrets",
],
}, null, 2));
} finally {
await rm(stage, { recursive: true, force: true });
}
async function assertSourceBoundary() {
const compose = await readFile(
resolve(platformRoot, "infra/synology/docker-compose.external-data-plane.yml"),
"utf8",
);
for (const fragment of [
"source: /volume1/docker/nodedc-platform/trust/engine-managed-provisioner",
"target: /run/nodedc-trust/engine-managed-provisioner",
"EXTERNAL_DATA_PLANE_MANAGED_PROVISIONER_PUBLIC_KEY_FILE: /run/nodedc-trust/engine-managed-provisioner/public-key.pem",
"source: /volume1/docker/nodedc-platform/trust/foundry-managed-provisioner",
"target: /run/nodedc-trust/foundry-managed-provisioner",
"EXTERNAL_DATA_PLANE_FOUNDRY_PROVISIONER_PUBLIC_KEY_FILE: /run/nodedc-trust/foundry-managed-provisioner/public-key.pem",
"EXTERNAL_DATA_PLANE_FOUNDRY_PROVISIONING_ENABLED: ${EXTERNAL_DATA_PLANE_FOUNDRY_PROVISIONING_ENABLED:-true}",
"create_host_path: false",
]) {
if (!compose.includes(fragment)) throw new Error(`platform_compose_boundary_missing:${fragment}`);
}
if (
compose.includes("private-key.pem")
|| compose.includes("ENGINE_EDP_MANAGED_PROVISIONER_PRIVATE_KEY_FILE")
) throw new Error("platform_artifact_private_key_boundary_violation");
const server = await readFile(
resolve(platformRoot, "services/external-data-plane/src/server.mjs"),
"utf8",
);
for (const marker of [
'managedWriterBindingLifetime: config.managedProvisionerApiEnabled ? "explicit-revoke" : "disabled"',
'managedWriterBindings: "digest+idempotent-generation+explicit-revoke"',
'app.put("/internal/data-plane/v1/reader-bindings/by-key/:bindingKey"',
'managedReaderBindingProvisioning: config.managedProvisionerApiEnabled',
'managedReaderBindingLifetime: config.managedProvisionerApiEnabled ? "explicit-revoke" : "disabled"',
'managedReaderBindings: "digest+idempotent-generation+explicit-revoke"',
'readerSourceScope: "writer-resolved+fail-closed-ambiguity"',
'app.post("/internal/data-plane/v1/consumer-reader-bindings/plan"',
'app.put("/internal/data-plane/v1/consumer-reader-bindings/by-key/:bindingKey"',
'foundryReaderBindingProvisioning: config.foundryProvisionerApiEnabled ? "digest+server-resolved-source" : "disabled"',
'source_connection_id as "sourceConnectionId"',
'where id = $1 and binding_key is null and active = true',
]) {
if (!server.includes(marker)) throw new Error(`managed_reader_boundary_missing:${marker}`);
}
const readerSourceScope = await readFile(
resolve(platformRoot, "services/external-data-plane/src/reader-source-scope.mjs"),
"utf8",
);
for (const marker of [
"managed_reader_source_scope_not_found",
"managed_reader_source_scope_ambiguous",
"managed_consumer_reader_source_scope_not_found",
"managed_consumer_reader_source_scope_ambiguous",
"external_data_plane_writer_bindings",
]) {
if (!readerSourceScope.includes(marker)) throw new Error(`reader_source_scope_boundary_missing:${marker}`);
}
}
function canonicalTarScript() {
return [
"import gzip, io, pathlib, sys, tarfile",
"root=pathlib.Path(sys.argv[2])",
"with open(sys.argv[1], 'wb') as out:",
" with gzip.GzipFile(filename='', mode='wb', fileobj=out, compresslevel=9, mtime=0) as gz:",
" with tarfile.open(fileobj=gz, mode='w', format=tarfile.PAX_FORMAT) as tar:",
" for top in ('manifest.env','files.txt','payload'):",
" p=root/top; paths=[p] + (sorted(p.rglob('*')) if p.is_dir() else [])",
" for x in paths:",
" info=tar.gettarinfo(str(x), arcname=x.relative_to(root).as_posix())",
" info.uid=info.gid=0; info.uname=info.gname='root'; info.mtime=0; info.mode=0o755 if info.isdir() else 0o644",
" with (open(x,'rb') if info.isfile() else io.BytesIO()) as src: tar.addfile(info, src if info.isfile() else None)",
].join("\n");
}
async function copySafe(source, destination) {
const sourceStat = await lstat(source);
if (sourceStat.isSymbolicLink()) throw new Error(`source_symlink_rejected:${source}`);
if (sourceStat.isFile()) {
await mkdir(dirname(destination), { recursive: true });
await cp(source, destination, { force: true, verbatimSymlinks: true });
return;
}
if (!sourceStat.isDirectory()) throw new Error(`source_type_rejected:${source}`);
await mkdir(destination, { recursive: true });
for (const entry of await readdir(source, { withFileTypes: true })) {
if (ignoredBasenames.has(entry.name) || entry.name.startsWith(".env")) continue;
if (entry.isDirectory() && ignoredDirectoryNames.has(entry.name)) continue;
const childSource = join(source, entry.name);
const childDestination = join(destination, entry.name);
if (entry.isSymbolicLink()) throw new Error(`source_symlink_rejected:${relative(platformRoot, childSource)}`);
await copySafe(childSource, childDestination);
}
}

View File

@ -0,0 +1,74 @@
#!/usr/bin/env node
import { createHash } from "node:crypto";
import { spawnSync } from "node:child_process";
import { cp, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
const scriptDir = dirname(fileURLToPath(import.meta.url));
const platformRoot = resolve(scriptDir, "../..");
const artifactDir = resolve(process.env.NODEDC_DEPLOY_ARTIFACT_DIR || join(scriptDir, "../deploy-artifacts"));
const [patchId = "external-data-plane-replacement-replay-v1-20260721-014", ...extra] = process.argv.slice(2);
const sourceRelative = "services/external-data-plane/src/data-product-delivery.mjs";
const destinationRelative = `platform/${sourceRelative}`;
if (extra.length || !/^[A-Za-z0-9._-]{1,96}$/.test(patchId)) {
throw new Error("usage: build-external-data-plane-replacement-replay-artifact.mjs [patch-id]");
}
const source = resolve(platformRoot, sourceRelative);
const sourceText = await readFile(source, "utf8");
for (const marker of [
'replacement.disposition === "replay"',
"classifyReplacementGeneration",
'throw deliveryError("data_product_generation_not_newer", 409)',
'join current_scope using (source_id, semantic_type, fingerprint)',
]) {
if (!sourceText.includes(marker)) throw new Error(`replacement_replay_boundary_missing:${marker}`);
}
const stage = await mkdtemp(join(tmpdir(), "nodedc-edp-replacement-replay-"));
const payloadPath = join(stage, "payload", destinationRelative);
const target = join(artifactDir, `nodedc-platform-${patchId}.tgz`);
try {
await mkdir(dirname(payloadPath), { recursive: true });
await cp(source, payloadPath, { force: false });
await writeFile(join(stage, "manifest.env"), `id=${patchId}\ncomponent=platform\ntype=app-overlay\n`, "utf8");
await writeFile(join(stage, "files.txt"), `${destinationRelative}\n`, "utf8");
await mkdir(artifactDir, { recursive: true });
const tar = spawnSync("python3", ["-c", canonicalTarScript(), target, stage], {
encoding: "utf8",
maxBuffer: 32 * 1024 * 1024,
});
if (tar.status !== 0) throw new Error(`tar_failed:${tar.stderr || tar.stdout}`);
const sha256 = createHash("sha256").update(await readFile(target)).digest("hex");
console.log(JSON.stringify({
ok: true,
patchId,
artifact: target,
sha256,
entries: [destinationRelative],
service: "external-data-plane",
database: "preserved",
}, null, 2));
} finally {
await rm(stage, { recursive: true, force: true });
}
function canonicalTarScript() {
return [
"import gzip, io, pathlib, sys, tarfile",
"root=pathlib.Path(sys.argv[2])",
"with open(sys.argv[1], 'wb') as out:",
" with gzip.GzipFile(filename='', mode='wb', fileobj=out, compresslevel=9, mtime=0) as gz:",
" with tarfile.open(fileobj=gz, mode='w', format=tarfile.PAX_FORMAT) as tar:",
" for top in ('manifest.env','files.txt','payload'):",
" p=root/top; paths=[p] + (sorted(p.rglob('*')) if p.is_dir() else [])",
" for x in paths:",
" info=tar.gettarinfo(str(x), arcname=x.relative_to(root).as_posix())",
" info.uid=info.gid=0; info.uname=info.gname='root'; info.mtime=0; info.mode=0o755 if info.isdir() else 0o644",
" with (open(x,'rb') if info.isfile() else io.BytesIO()) as src: tar.addfile(info, src if info.isfile() else None)",
].join("\n");
}

View File

@ -0,0 +1,129 @@
#!/usr/bin/env node
import { createHash } from "node:crypto";
import { spawnSync } from "node:child_process";
import { copyFile, lstat, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
const scriptDir = dirname(fileURLToPath(import.meta.url));
const platformRoot = resolve(scriptDir, "../..");
const artifactDir = resolve(
process.env.NODEDC_DEPLOY_ARTIFACT_DIR || resolve(scriptDir, "../deploy-artifacts"),
);
const [patchId = "external-data-plane-unit-contacts-20260724-014", ...extra] =
process.argv.slice(2);
if (extra.length || !/^external-data-plane-unit-contacts-\d{8}-\d{3}$/.test(patchId)) {
throw new Error(
"usage: build-external-data-plane-unit-contacts-artifact.mjs "
+ "[external-data-plane-unit-contacts-YYYYMMDD-NNN]",
);
}
const sourceRelative =
"services/external-data-plane/definitions/fleet.units.contacts.current.v1.json";
const destinationRelative = `platform/${sourceRelative}`;
const artifact = join(artifactDir, `nodedc-${patchId}.tgz`);
const checksum = `${artifact}.sha256`;
const stage = await mkdtemp(join(tmpdir(), "nodedc-edp-unit-contacts-artifact-"));
await assertFresh(artifact);
await assertDefinition();
try {
const payloadFile = join(stage, "payload", destinationRelative);
await mkdir(dirname(payloadFile), { recursive: true });
await copyFile(join(platformRoot, sourceRelative), payloadFile);
await writeFile(
join(stage, "manifest.env"),
`id=${patchId}\ncomponent=platform\ntype=app-overlay\n`,
"utf8",
);
await writeFile(join(stage, "files.txt"), `${destinationRelative}\n`, "utf8");
await mkdir(artifactDir, { recursive: true });
run("python3", ["-c", canonicalTarScript(), artifact, stage]);
const sha256 = digest(await readFile(artifact));
await writeFile(checksum, `${sha256} ${artifact.split("/").at(-1)}\n`, "utf8");
console.log(JSON.stringify({
ok: true,
patchId,
artifact,
checksum,
sha256,
services: ["external-data-plane"],
files: [destinationRelative],
}, null, 2));
} finally {
await rm(stage, { recursive: true, force: true });
}
async function assertDefinition() {
const definition = JSON.parse(
await readFile(join(platformRoot, sourceRelative), "utf8"),
);
if (
definition?.id !== "fleet.units.contacts.current.v1"
|| definition?.version !== "1.0.0"
|| definition?.ontologyRevision !== "ontology.map.moving_object.v3"
|| JSON.stringify(definition?.semanticTypes)
!== JSON.stringify(["map.moving_object"])
|| JSON.stringify(definition?.fields) !== JSON.stringify([
"device_imei",
"device_phone_primary",
"device_phone_secondary",
"display_name",
"provider_creator_login",
])
) {
throw new Error("unit_contacts_definition_contract_invalid");
}
if (
/(token|secret|password|authorization|decrypt|raw_provider_payload)/i.test(
JSON.stringify(definition),
)
) {
throw new Error("unit_contacts_definition_secret_boundary_violation");
}
}
async function assertFresh(path) {
try {
await lstat(path);
} catch (error) {
if (error?.code === "ENOENT") return;
throw error;
}
throw new Error("artifact_already_exists");
}
function canonicalTarScript() {
return [
"import gzip,io,pathlib,sys,tarfile",
"root=pathlib.Path(sys.argv[2])",
"with open(sys.argv[1],'wb') as out:",
" with gzip.GzipFile(filename='',mode='wb',fileobj=out,compresslevel=9,mtime=0) as gz:",
" with tarfile.open(fileobj=gz,mode='w',format=tarfile.PAX_FORMAT) as tar:",
" for top in ('manifest.env','files.txt','payload'):",
" p=root/top; paths=[p]+(sorted(p.rglob('*')) if p.is_dir() else [])",
" for x in paths:",
" info=tar.gettarinfo(str(x),arcname=x.relative_to(root).as_posix())",
" info.uid=info.gid=0; info.uname=info.gname='root'; info.mtime=0; info.mode=0o755 if info.isdir() else 0o644",
" with (open(x,'rb') if info.isfile() else io.BytesIO()) as src: tar.addfile(info,src if info.isfile() else None)",
].join("\n");
}
function digest(value) {
return createHash("sha256").update(value).digest("hex");
}
function run(command, args) {
const result = spawnSync(command, args, {
encoding: "utf8",
maxBuffer: 64 * 1024 * 1024,
});
if (result.status !== 0) {
throw new Error(`${command}_failed:${result.stderr || result.stdout}`);
}
}

View File

@ -0,0 +1,131 @@
#!/usr/bin/env node
import { createHash } from "node:crypto";
import { spawnSync } from "node:child_process";
import { copyFile, lstat, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
const scriptDir = dirname(fileURLToPath(import.meta.url));
const platformRoot = resolve(scriptDir, "../..");
const artifactDir = resolve(
process.env.NODEDC_DEPLOY_ARTIFACT_DIR || resolve(scriptDir, "../deploy-artifacts"),
);
const [patchId = "external-data-plane-unit-identity-20260724-015", ...extra] =
process.argv.slice(2);
if (extra.length || !/^external-data-plane-unit-identity-\d{8}-\d{3}$/.test(patchId)) {
throw new Error(
"usage: build-external-data-plane-unit-identity-artifact.mjs "
+ "[external-data-plane-unit-identity-YYYYMMDD-NNN]",
);
}
const sourceRelative =
"services/external-data-plane/definitions/fleet.units.identity.current.v1.json";
const destinationRelative = `platform/${sourceRelative}`;
const expectedSha256 =
"1bc017cf71f4705875e735550ec4589cea77e1d2e8c8450162148a051a7ceffe";
const artifact = join(artifactDir, `nodedc-${patchId}.tgz`);
const checksum = `${artifact}.sha256`;
const stage = await mkdtemp(join(tmpdir(), "nodedc-edp-unit-identity-artifact-"));
await assertFresh(artifact);
await assertDefinition();
try {
const payloadFile = join(stage, "payload", destinationRelative);
await mkdir(dirname(payloadFile), { recursive: true });
await copyFile(join(platformRoot, sourceRelative), payloadFile);
await writeFile(
join(stage, "manifest.env"),
`id=${patchId}\ncomponent=platform\ntype=app-overlay\n`,
"utf8",
);
await writeFile(join(stage, "files.txt"), `${destinationRelative}\n`, "utf8");
await mkdir(artifactDir, { recursive: true });
run("python3", ["-c", canonicalTarScript(), artifact, stage]);
const sha256 = digest(await readFile(artifact));
await writeFile(checksum, `${sha256} ${artifact.split("/").at(-1)}\n`, "utf8");
console.log(JSON.stringify({
ok: true,
patchId,
artifact,
checksum,
sha256,
services: ["external-data-plane"],
dataProductId: "fleet.units.identity.current.v1",
dataClass: "restricted",
files: [destinationRelative],
}, null, 2));
} finally {
await rm(stage, { recursive: true, force: true });
}
async function assertDefinition() {
const source = await readFile(join(platformRoot, sourceRelative));
if (digest(source) !== expectedSha256) {
throw new Error("unit_identity_definition_target_mismatch");
}
const definition = JSON.parse(source);
const fields = Array.isArray(definition?.fields) ? definition.fields : [];
if (
definition?.id !== "fleet.units.identity.current.v1"
|| definition?.version !== "1.0.0"
|| definition?.ontologyRevision !== "ontology.map.moving_object.v3"
|| JSON.stringify(definition?.semanticTypes) !== JSON.stringify(["map.moving_object"])
|| !fields.includes("provider_unit_id")
|| !fields.includes("device_imei")
|| !fields.includes("assigned_driver_phone")
|| definition?.fieldContracts?.available_user_logins?.type !== "string_array"
|| definition?.fieldContracts?.custom_fields?.type !== "string_array"
) {
throw new Error("unit_identity_definition_contract_invalid");
}
if (/(token|secret|password|authorization|decrypt|raw_provider_payload)/i.test(
JSON.stringify(definition),
)) {
throw new Error("unit_identity_definition_secret_boundary_violation");
}
}
async function assertFresh(path) {
try {
await lstat(path);
} catch (error) {
if (error?.code === "ENOENT") return;
throw error;
}
throw new Error("artifact_already_exists");
}
function canonicalTarScript() {
return [
"import gzip,io,pathlib,sys,tarfile",
"root=pathlib.Path(sys.argv[2])",
"with open(sys.argv[1],'xb') as out:",
" with gzip.GzipFile(filename='',mode='wb',fileobj=out,compresslevel=9,mtime=0) as gz:",
" with tarfile.open(fileobj=gz,mode='w',format=tarfile.PAX_FORMAT) as tar:",
" for top in ('manifest.env','files.txt','payload'):",
" p=root/top; paths=[p]+(sorted(p.rglob('*')) if p.is_dir() else [])",
" for x in paths:",
" info=tar.gettarinfo(str(x),arcname=x.relative_to(root).as_posix())",
" info.uid=info.gid=0; info.uname=info.gname='root'; info.mtime=0; info.mode=0o755 if info.isdir() else 0o644",
" with (open(x,'rb') if info.isfile() else io.BytesIO()) as src: tar.addfile(info,src if info.isfile() else None)",
].join("\n");
}
function digest(value) {
return createHash("sha256").update(value).digest("hex");
}
function run(command, args) {
const result = spawnSync(command, args, {
encoding: "utf8",
maxBuffer: 64 * 1024 * 1024,
});
if (result.status !== 0) {
throw new Error(`${command}_failed:${result.stderr || result.stdout}`);
}
}

View File

@ -0,0 +1,109 @@
#!/usr/bin/env node
import { createHash } from "node:crypto";
import { spawnSync } from "node:child_process";
import { cp, lstat, mkdir, mkdtemp, readdir, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { dirname, join, relative, resolve } from "node:path";
import { fileURLToPath } from "node:url";
const scriptDir = dirname(fileURLToPath(import.meta.url));
const platformRoot = resolve(scriptDir, "../..");
const artifactDir = resolve(scriptDir, "../deploy-artifacts");
const args = process.argv.slice(2);
const gatewayOnly = args.includes("--gateway-only");
const positionalArgs = args.filter((argument) => argument !== "--gateway-only");
if (positionalArgs.length > 1) {
throw new Error("usage: build-gelios-data-plane-artifact.mjs [patch-id] [--gateway-only]");
}
const patchId = positionalArgs[0] || "gelios-data-plane-20260713-001";
if (!/^[A-Za-z0-9._-]{1,96}$/.test(patchId)) {
throw new Error("patch_id_must_contain_only_letters_digits_dot_underscore_hyphen");
}
const fullDataPlaneFiles = [
["infra/synology/docker-compose.platform-http.yml", "platform/docker-compose.platform-http.yml"],
["services/ontology-core", "platform/ontology-core"],
["services/ai-workspace-hub", "platform/ai-workspace-hub"],
["services/ai-workspace-assistant", "platform/ai-workspace-assistant"],
["services/gelios-gateway", "platform/gelios-gateway"],
];
// A policy/code update to an already deployed Gelios data plane must not
// carry the common Platform compose file. The deploy runner treats that file
// as a whole-Platform change. The existing Gelios service already uses the
// live Platform env_file, so this overlay can safely recreate only Gelios.
const files = gatewayOnly
? [["services/gelios-gateway", "platform/gelios-gateway"]]
: fullDataPlaneFiles;
const ignoredBasenames = new Set([".DS_Store", ".git", "node_modules"]);
const stage = await mkdtemp(join(tmpdir(), "nodedc-gelios-artifact-"));
const payload = join(stage, "payload");
const target = join(artifactDir, `nodedc-platform-${patchId}.tgz`);
try {
await mkdir(payload, { recursive: true });
for (const [sourceRelative, destinationRelative] of files) {
const source = resolve(platformRoot, sourceRelative);
const destination = join(payload, destinationRelative);
await copySafe(source, destination);
}
if (!gatewayOnly) {
// The Assistant imports the deterministic catalog as a local sibling at
// runtime. Its production Dockerfile therefore expects this directory
// inside the Assistant build context. Keep the deploy artifact equivalent
// to the canonical Synology staging layout without making a second source
// copy in the repository.
await copySafe(
resolve(platformRoot, "services/ontology-core"),
join(payload, "platform/ai-workspace-assistant/ontology-core"),
);
// This nested copy is source-only build input for the Assistant. The nested
// service Dockerfile is neither used nor allowed by the production runner.
await rm(join(payload, "platform/ai-workspace-assistant/ontology-core/Dockerfile"), { force: true });
}
await writeFile(join(stage, "manifest.env"), `id=${patchId}\ncomponent=platform\ntype=app-overlay\n`, "utf8");
await writeFile(join(stage, "files.txt"), `${files.map(([, destination]) => destination).join("\n")}\n`, "utf8");
await mkdir(artifactDir, { recursive: true });
// macOS bsdtar includes AppleDouble sidecar files for extended attributes.
// The root runner rejects those as unexpected members, so use stdlib tarfile
// to generate a portable, data-only archive instead.
const tar = spawnSync("python3", ["-c", [
"import sys, tarfile",
"with tarfile.open(sys.argv[1], 'w:gz', format=tarfile.PAX_FORMAT) as archive:",
" [archive.add(name, arcname=name, recursive=True) for name in ('manifest.env', 'files.txt', 'payload')]",
].join("\n"), target], {
cwd: stage,
encoding: "utf8",
});
if (tar.status !== 0) throw new Error(`tar_failed:${tar.stderr || tar.stdout}`);
const digest = createHash("sha256").update(await (await import("node:fs/promises")).readFile(target)).digest("hex");
console.log(JSON.stringify({ ok: true, patchId, gatewayOnly, artifact: target, sha256: digest }, null, 2));
} finally {
await rm(stage, { recursive: true, force: true });
}
async function copySafe(source, destination) {
const sourceStat = await lstat(source);
if (sourceStat.isSymbolicLink()) throw new Error(`source_symlink_rejected:${source}`);
if (sourceStat.isFile()) {
await mkdir(dirname(destination), { recursive: true });
await cp(source, destination, { force: true, verbatimSymlinks: true });
return;
}
if (!sourceStat.isDirectory()) throw new Error(`source_type_rejected:${source}`);
await mkdir(destination, { recursive: true });
for (const entry of await readdir(source, { withFileTypes: true })) {
if (ignoredBasenames.has(entry.name) || entry.name.startsWith(".env")) continue;
const childSource = join(source, entry.name);
const childDestination = join(destination, entry.name);
if (entry.isSymbolicLink()) throw new Error(`source_symlink_rejected:${relative(platformRoot, childSource)}`);
await copySafe(childSource, childDestination);
}
}

View File

@ -0,0 +1,85 @@
#!/usr/bin/env node
import { createHash } from "node:crypto";
import { spawnSync } from "node:child_process";
import { cp, lstat, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
const scriptDir = dirname(fileURLToPath(import.meta.url));
const platformRoot = resolve(scriptDir, "../..");
const launcherRoot = resolve(platformRoot, "../../data/nodedc_launcher");
const artifactDir = resolve(scriptDir, "../deploy-artifacts");
const patchId = process.argv[2] || "module-foundry-hub-registration-20260714-001";
const profile = process.argv[3] || "registration";
if (!/^[A-Za-z0-9._-]{1,96}$/.test(patchId)) {
throw new Error("patch_id_must_contain_only_letters_digits_dot_underscore_hyphen");
}
// DCPLATFORM-21: the production runner overlays this payload onto the existing
// Launcher source and recreates only the `launcher` service. Keep corrective
// patches to their exact reviewed file set instead of re-sending unrelated
// registration sources.
const registrationFiles = [
"server/authentik-sync.mjs",
"server/control-plane-store.mjs",
"server/dev-server.mjs",
"src/entities/service/types.ts",
];
const filesByProfile = {
registration: registrationFiles,
"handoff-fix": ["server/dev-server.mjs"],
};
const files = filesByProfile[profile];
if (!files) {
throw new Error(`unknown_profile:${profile}`);
}
const stage = await mkdtemp(join(tmpdir(), "nodedc-launcher-foundry-artifact-"));
const payloadRoot = join(stage, "payload");
const artifact = join(artifactDir, `launcher-${patchId}.tgz`);
const checksum = `${artifact}.sha256`;
try {
await mkdir(payloadRoot, { recursive: true });
for (const relativePath of files) {
const source = resolve(launcherRoot, relativePath);
const destination = join(payloadRoot, relativePath);
const stat = await lstat(source);
if (!stat.isFile() || stat.isSymbolicLink()) {
throw new Error(`source_file_rejected:${relativePath}`);
}
await mkdir(dirname(destination), { recursive: true });
await cp(source, destination, { force: true, verbatimSymlinks: true });
}
await writeFile(
join(stage, "manifest.env"),
`id=${patchId}\ncomponent=launcher\ntype=app-overlay\n`,
"utf8",
);
await writeFile(join(stage, "files.txt"), `${files.join("\n")}\n`, "utf8");
await mkdir(artifactDir, { recursive: true });
// macOS bsdtar may emit AppleDouble `._*` sidecars. DCPLATFORM-21 rejects
// them, therefore create a data-only POSIX archive through stdlib tarfile.
const tar = spawnSync("python3", ["-c", [
"import sys, tarfile",
"with tarfile.open(sys.argv[1], 'w:gz', format=tarfile.PAX_FORMAT) as archive:",
" [archive.add(name, arcname=name, recursive=True) for name in ('manifest.env', 'files.txt', 'payload')]",
].join("\n"), artifact], {
cwd: stage,
encoding: "utf8",
});
if (tar.status !== 0) throw new Error(`tar_failed:${tar.stderr || tar.stdout}`);
const digest = createHash("sha256").update(await readFile(artifact)).digest("hex");
await writeFile(checksum, `${digest} ${artifact.split("/").at(-1)}\n`, "utf8");
console.log(JSON.stringify({ ok: true, patchId, profile, artifact, checksum, sha256: digest, files }, null, 2));
} finally {
await rm(stage, { recursive: true, force: true });
}

View File

@ -0,0 +1,49 @@
#!/usr/bin/env node
import { createHash } from "node:crypto";
import { spawnSync } from "node:child_process";
import { cp, lstat, mkdir, mkdtemp, readdir, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { dirname, join, relative, resolve } from "node:path";
import { fileURLToPath } from "node:url";
const scriptDir = dirname(fileURLToPath(import.meta.url));
const platformRoot = resolve(scriptDir, "../..");
const artifactDir = resolve(scriptDir, "../deploy-artifacts");
const [patchId = "platform-map-gateway-20260714-001", ...extra] = process.argv.slice(2);
if (extra.length || !/^[A-Za-z0-9._-]{1,96}$/.test(patchId)) throw new Error("usage: build-map-gateway-artifact.mjs [patch-id]");
const files = [
["infra/synology/docker-compose.platform-http.yml", "platform/docker-compose.platform-http.yml"],
["services/map-gateway", "platform/services/map-gateway"],
];
const ignored = new Set([".DS_Store", ".git", "node_modules"]);
const stage = await mkdtemp(join(tmpdir(), "nodedc-map-gateway-artifact-"));
const payload = join(stage, "payload");
const target = join(artifactDir, `nodedc-platform-${patchId}.tgz`);
try {
await mkdir(payload, { recursive: true });
for (const [source, destination] of files) await copySafe(resolve(platformRoot, source), join(payload, destination));
await writeFile(join(stage, "manifest.env"), `id=${patchId}\ncomponent=platform\ntype=app-overlay\n`, "utf8");
await writeFile(join(stage, "files.txt"), `${files.map(([, destination]) => destination).join("\n")}\n`, "utf8");
await mkdir(artifactDir, { recursive: true });
const tar = spawnSync("python3", ["-c", "import sys,tarfile\nwith tarfile.open(sys.argv[1],'w:gz',format=tarfile.PAX_FORMAT) as a:\n [a.add(n,arcname=n,recursive=True) for n in ('manifest.env','files.txt','payload')]", target], { cwd: stage, encoding: "utf8" });
if (tar.status !== 0) throw new Error(`tar_failed:${tar.stderr || tar.stdout}`);
console.log(JSON.stringify({ ok: true, patchId, artifact: target, sha256: createHash("sha256").update(await readFile(target)).digest("hex") }, null, 2));
} finally {
await rm(stage, { recursive: true, force: true });
}
async function copySafe(source, destination) {
const info = await lstat(source);
if (info.isSymbolicLink()) throw new Error(`source_symlink_rejected:${source}`);
if (info.isFile()) { await mkdir(dirname(destination), { recursive: true }); await cp(source, destination, { force: true }); return; }
if (!info.isDirectory()) throw new Error(`source_type_rejected:${source}`);
await mkdir(destination, { recursive: true });
for (const entry of await readdir(source, { withFileTypes: true })) {
if (ignored.has(entry.name) || entry.name.startsWith(".env")) continue;
const child = join(source, entry.name);
if (entry.isSymbolicLink()) throw new Error(`source_symlink_rejected:${relative(platformRoot, child)}`);
await copySafe(child, join(destination, entry.name));
}
}

View File

@ -0,0 +1,125 @@
#!/usr/bin/env node
import { createHash } from "node:crypto";
import { spawnSync } from "node:child_process";
import { cp, lstat, mkdir, mkdtemp, readdir, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { dirname, join, relative, resolve } from "node:path";
import { fileURLToPath } from "node:url";
const scriptDir = dirname(fileURLToPath(import.meta.url));
const platformRoot = resolve(scriptDir, "../..");
const workspaceRoot = resolve(platformRoot, "..");
const foundryRoot = resolve(workspaceRoot, "NODEDC_DESIGN_GUIDELINE");
const artifactDir = resolve(process.env.NODEDC_DEPLOY_ARTIFACT_DIR || resolve(scriptDir, "../deploy-artifacts"));
const [patchId = "module-foundry-bootstrap-20260714-001", ...extra] = process.argv.slice(2);
if (extra.length || !/^[A-Za-z0-9._-]{1,96}$/.test(patchId)) {
throw new Error("patch_id_must_contain_only_letters_digits_dot_underscore_hyphen");
}
const files = [
".dockerignore",
".env.example",
".gitignore",
"Dockerfile",
"package.json",
"package-lock.json",
"tsconfig.base.json",
"infra/docker-compose.module-foundry.yml",
"apps",
"packages",
"registry",
"runtime-seed",
"scripts",
"server",
];
const ignoredBasenames = new Set([".DS_Store", ".git", "node_modules", "runtime-data", "dist"]);
const stage = await mkdtemp(join(tmpdir(), "nodedc-module-foundry-artifact-"));
const payload = join(stage, "payload");
const target = join(artifactDir, `nodedc-module-foundry-${patchId}.tgz`);
await assertSourceBoundary();
try {
await mkdir(payload, { recursive: true });
for (const sourceRelative of files) {
await copySafe(resolve(foundryRoot, sourceRelative), join(payload, sourceRelative));
}
await writeFile(join(stage, "manifest.env"), `id=${patchId}\ncomponent=module-foundry\ntype=app-overlay\n`, "utf8");
await writeFile(join(stage, "files.txt"), `${files.join("\n")}\n`, "utf8");
await mkdir(artifactDir, { recursive: true });
const tar = spawnSync("python3", ["-c", canonicalTarScript(), target, stage], {
encoding: "utf8",
maxBuffer: 128 * 1024 * 1024,
});
if (tar.status !== 0) throw new Error(`tar_failed:${tar.stderr || tar.stdout}`);
const digest = createHash("sha256").update(await readFile(target)).digest("hex");
console.log(JSON.stringify({ ok: true, patchId, artifact: target, sha256: digest }, null, 2));
} finally {
await rm(stage, { recursive: true, force: true });
}
async function assertSourceBoundary() {
const compose = await readFile(resolve(foundryRoot, "infra/docker-compose.module-foundry.yml"), "utf8");
for (const fragment of [
"source: /volume1/docker/nodedc-platform/secrets/foundry-edp-managed-provisioner/private-key.pem",
"target: /run/nodedc-secrets/foundry-edp-managed-provisioner/private-key.pem",
"NODEDC_EXTERNAL_DATA_PLANE_FOUNDRY_PROVISIONER_PRIVATE_KEY_FILE: /run/nodedc-secrets/foundry-edp-managed-provisioner/private-key.pem",
"create_host_path: false",
]) {
if (!compose.includes(fragment)) throw new Error(`foundry_reader_grant_boundary_missing:${fragment}`);
}
const provisioner = await readFile(resolve(foundryRoot, "server/foundry-reader-grant-provisioner.mjs"), "utf8");
for (const marker of [
'"/internal/data-plane/v1/consumer-reader-bindings/plan"',
"consumer-reader-bindings/by-key/${encodeURIComponent(bindingKey)}",
"capabilityDigest: createHash(\"sha256\").update(token",
"sourceScope: \"resolved-server-side\"",
"O_NOFOLLOW",
]) {
if (!provisioner.includes(marker)) throw new Error(`foundry_reader_grant_boundary_missing:${marker}`);
}
if (/providerId|tenantId|connectionId/.test(provisioner)) {
throw new Error("foundry_reader_grant_source_scope_boundary_violation");
}
}
function canonicalTarScript() {
return [
"import gzip,io,pathlib,sys,tarfile",
"root=pathlib.Path(sys.argv[2])",
"with open(sys.argv[1],'wb') as out:",
" with gzip.GzipFile(filename='',mode='wb',fileobj=out,compresslevel=9,mtime=0) as gz:",
" with tarfile.open(fileobj=gz,mode='w',format=tarfile.PAX_FORMAT) as tar:",
" for top in ('manifest.env','files.txt','payload'):",
" p=root/top; paths=[p]+(sorted(p.rglob('*')) if p.is_dir() else [])",
" for x in paths:",
" info=tar.gettarinfo(str(x),arcname=x.relative_to(root).as_posix())",
" info.uid=info.gid=0; info.uname=info.gname='root'; info.mtime=0; info.mode=0o755 if info.isdir() else 0o644",
" with (open(x,'rb') if info.isfile() else io.BytesIO()) as src: tar.addfile(info,src if info.isfile() else None)",
].join("\n");
}
async function copySafe(source, destination) {
const sourceStat = await lstat(source);
if (sourceStat.isSymbolicLink()) throw new Error(`source_symlink_rejected:${relative(foundryRoot, source)}`);
if (sourceStat.isFile()) {
await mkdir(dirname(destination), { recursive: true });
await cp(source, destination, { force: true, verbatimSymlinks: true });
return;
}
if (!sourceStat.isDirectory()) throw new Error(`source_type_rejected:${source}`);
await mkdir(destination, { recursive: true });
for (const entry of await readdir(source, { withFileTypes: true })) {
if (entry.name === "private-key.pem") throw new Error(`private_key_source_rejected:${relative(foundryRoot, join(source, entry.name))}`);
if (ignoredBasenames.has(entry.name) || entry.name.startsWith(".env") || entry.name.endsWith(".tsbuildinfo")) continue;
const childSource = join(source, entry.name);
const childDestination = join(destination, entry.name);
if (entry.isSymbolicLink()) throw new Error(`source_symlink_rejected:${relative(foundryRoot, childSource)}`);
await copySafe(childSource, childDestination);
}
}

View File

@ -0,0 +1,170 @@
#!/usr/bin/env node
import { createHash } from "node:crypto";
import { spawnSync } from "node:child_process";
import { copyFile, lstat, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
const scriptDir = dirname(fileURLToPath(import.meta.url));
const workspaceRoot = resolve(scriptDir, "../../..");
const foundryRoot = resolve(workspaceRoot, "NODEDC_DESIGN_GUIDELINE");
const artifactDir = resolve(
process.env.NODEDC_DEPLOY_ARTIFACT_DIR || resolve(scriptDir, "../deploy-artifacts"),
);
const [patchId = "module-foundry-classified-aspects-20260724-010", ...extra] =
process.argv.slice(2);
if (extra.length || !/^module-foundry-classified-aspects-\d{8}-\d{3}$/.test(patchId)) {
throw new Error(
"usage: build-module-foundry-classified-aspects-artifact.mjs "
+ "[module-foundry-classified-aspects-YYYYMMDD-NNN]",
);
}
const files = Object.freeze([
"apps/catalog/src/MapFixturePreview.tsx",
"apps/catalog/src/mapSubjectCard.mjs",
"apps/catalog/src/useMapDataProductRuntime.ts",
"registry/data-product-consumer-policies.json",
"registry/schemas/application-manifest-v0.1.schema.json",
"server/catalog-server.mjs",
"server/foundry-data-product-consumer.mjs",
"server/foundry-mcp.mjs",
"server/map-subject-detail-profile.mjs",
]);
const artifact = join(artifactDir, `nodedc-${patchId}.tgz`);
const checksum = `${artifact}.sha256`;
const stage = await mkdtemp(join(tmpdir(), "nodedc-foundry-classified-aspects-"));
await assertFresh(artifact);
await assertClassifiedAspectBoundary();
try {
for (const relativePath of files) {
const source = join(foundryRoot, relativePath);
const info = await lstat(source);
if (!info.isFile() || info.isSymbolicLink()) {
throw new Error(`source_file_rejected:${relativePath}`);
}
const destination = join(stage, "payload", relativePath);
await mkdir(dirname(destination), { recursive: true });
await copyFile(source, destination);
}
await writeFile(
join(stage, "manifest.env"),
`id=${patchId}\ncomponent=module-foundry\ntype=app-overlay\n`,
"utf8",
);
await writeFile(join(stage, "files.txt"), `${files.join("\n")}\n`, "utf8");
await mkdir(artifactDir, { recursive: true });
run("python3", ["-c", canonicalTarScript(), artifact, stage]);
const sha256 = digest(await readFile(artifact));
await writeFile(checksum, `${sha256} ${artifact.split("/").at(-1)}\n`, "utf8");
console.log(JSON.stringify({
ok: true,
patchId,
artifact,
checksum,
sha256,
services: ["nodedc-module-foundry"],
policy: "provider-neutral-data-class",
files,
}, null, 2));
} finally {
await rm(stage, { recursive: true, force: true });
}
async function assertClassifiedAspectBoundary() {
const profile = await readFile(
join(foundryRoot, "server/map-subject-detail-profile.mjs"),
"utf8",
);
const consumer = await readFile(
join(foundryRoot, "server/foundry-data-product-consumer.mjs"),
"utf8",
);
const card = await readFile(
join(foundryRoot, "apps/catalog/src/mapSubjectCard.mjs"),
"utf8",
);
const gateway = await readFile(join(foundryRoot, "server/foundry-mcp.mjs"), "utf8");
for (const [name, source] of [
["profile", profile],
["consumer", consumer],
["card", card],
["gateway", gateway],
]) {
if (!source.includes("dataClass")) {
throw new Error(`classified_aspect_marker_missing:${name}:dataClass`);
}
}
if (
/gelios\.provider|fleet\.units\.contacts/i.test(profile)
|| /gelios\.provider|fleet\.units\.contacts/i.test(card)
|| /gelios\.provider|fleet\.units\.contacts/i.test(gateway)
) {
throw new Error("classified_aspect_ui_or_gateway_provider_hardcode");
}
const policies = JSON.parse(
await readFile(
join(foundryRoot, "registry/data-product-consumer-policies.json"),
"utf8",
),
);
const matches = policies?.policies?.filter(
(policy) =>
policy?.dataProductId === "fleet.units.contacts.current.v1"
&& policy?.productVersion === "1.0.0",
) || [];
if (
matches.length !== 1
|| matches[0]?.dataClass !== "restricted"
|| matches[0]?.consumerContract?.ontologyRevision
!== "ontology.map.moving_object.v3"
) {
throw new Error("classified_aspect_consumer_policy_invalid");
}
}
async function assertFresh(path) {
try {
await lstat(path);
} catch (error) {
if (error?.code === "ENOENT") return;
throw error;
}
throw new Error("artifact_already_exists");
}
function canonicalTarScript() {
return [
"import gzip,io,pathlib,sys,tarfile",
"root=pathlib.Path(sys.argv[2])",
"with open(sys.argv[1],'wb') as out:",
" with gzip.GzipFile(filename='',mode='wb',fileobj=out,compresslevel=9,mtime=0) as gz:",
" with tarfile.open(fileobj=gz,mode='w',format=tarfile.PAX_FORMAT) as tar:",
" for top in ('manifest.env','files.txt','payload'):",
" p=root/top; paths=[p]+(sorted(p.rglob('*')) if p.is_dir() else [])",
" for x in paths:",
" info=tar.gettarinfo(str(x),arcname=x.relative_to(root).as_posix())",
" info.uid=info.gid=0; info.uname=info.gname='root'; info.mtime=0; info.mode=0o755 if info.isdir() else 0o644",
" with (open(x,'rb') if info.isfile() else io.BytesIO()) as src: tar.addfile(info,src if info.isfile() else None)",
].join("\n");
}
function digest(value) {
return createHash("sha256").update(value).digest("hex");
}
function run(command, args) {
const result = spawnSync(command, args, {
encoding: "utf8",
maxBuffer: 64 * 1024 * 1024,
});
if (result.status !== 0) {
throw new Error(`${command}_failed:${result.stderr || result.stdout}`);
}
}

View File

@ -0,0 +1,235 @@
#!/usr/bin/env node
import { createHash } from "node:crypto";
import { spawnSync } from "node:child_process";
import { cp, lstat, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
const scriptDir = dirname(fileURLToPath(import.meta.url));
const platformRoot = resolve(scriptDir, "../..");
const workspaceRoot = resolve(platformRoot, "..");
const foundryRoot = resolve(workspaceRoot, "NODEDC_DESIGN_GUIDELINE");
const artifactDir = resolve(process.env.NODEDC_DEPLOY_ARTIFACT_DIR || resolve(scriptDir, "../deploy-artifacts"));
const [patchId = "module-foundry-zone-v2-consumer-policy-20260721-001", ...extra] = process.argv.slice(2);
if (extra.length || !/^[A-Za-z0-9._-]{1,96}$/.test(patchId)) {
throw new Error("patch_id_must_contain_only_letters_digits_dot_underscore_hyphen");
}
const files = [
"registry/data-product-consumer-policies.json",
"scripts/validate-registry.mjs",
"server/foundry-data-product-consumer.mjs",
"server/foundry-data-product-consumer.test.mjs",
];
const stage = await mkdtemp(join(tmpdir(), "nodedc-module-foundry-consumer-policy-artifact-"));
const payload = join(stage, "payload");
const artifact = join(artifactDir, `nodedc-module-foundry-${patchId}.tgz`);
const checksum = `${artifact}.sha256`;
await assertConsumerPolicyBoundary();
try {
await mkdir(payload, { recursive: true });
for (const relativePath of files) {
const source = resolve(foundryRoot, relativePath);
const sourceStat = await lstat(source);
if (!sourceStat.isFile() || sourceStat.isSymbolicLink()) {
throw new Error(`source_file_rejected:${relativePath}`);
}
const destination = join(payload, relativePath);
await mkdir(dirname(destination), { recursive: true });
await cp(source, destination, { force: true, verbatimSymlinks: true });
}
await writeFile(join(stage, "manifest.env"), `id=${patchId}\ncomponent=module-foundry\ntype=app-overlay\n`, "utf8");
await writeFile(join(stage, "files.txt"), `${files.join("\n")}\n`, "utf8");
await mkdir(artifactDir, { recursive: true });
const tar = spawnSync("python3", ["-c", canonicalTarScript(), artifact, stage], {
encoding: "utf8",
maxBuffer: 32 * 1024 * 1024,
});
if (tar.status !== 0) throw new Error(`tar_failed:${tar.stderr || tar.stdout}`);
const sha256 = createHash("sha256").update(await readFile(artifact)).digest("hex");
await writeFile(checksum, `${sha256} ${artifact.split("/").at(-1)}\n`, "utf8");
console.log(JSON.stringify({ ok: true, patchId, artifact, checksum, sha256, files }, null, 2));
} finally {
await rm(stage, { recursive: true, force: true });
}
async function assertConsumerPolicyBoundary() {
const registry = JSON.parse(await readFile(resolve(foundryRoot, files[0]), "utf8"));
if (registry?.schemaVersion !== "nodedc.foundry.data-product-consumer-policies/v1") {
throw new Error("foundry_consumer_policy_registry_invalid");
}
const movingObjectV4Matches = registry.policies?.filter((policy) => (
policy?.dataProductId === "fleet.positions.current.v4" && policy?.productVersion === "4.0.0"
)) || [];
if (movingObjectV4Matches.length !== 1) throw new Error("foundry_consumer_policy_v4_missing_or_ambiguous");
const movingObjectV4Policy = movingObjectV4Matches[0];
if (
movingObjectV4Policy.id !== "map-moving-object-current-v4"
|| movingObjectV4Policy.version !== "4.0.0"
|| movingObjectV4Policy.staleAfterMs !== null
|| movingObjectV4Policy.statusContract?.attribute !== "signal_state"
|| JSON.stringify(movingObjectV4Policy.statusContract?.allowedValues) !== JSON.stringify(["active", "inactive"])
|| movingObjectV4Policy.statusContract?.missing !== "reject"
|| movingObjectV4Policy.statusContract?.freshness !== "none"
|| JSON.stringify(movingObjectV4Policy.terminalStatuses) !== JSON.stringify(["inactive"])
|| movingObjectV4Policy.removeMode !== "canonical-tombstone-or-snapshot-rebase"
) throw new Error("foundry_consumer_policy_v4_contract_invalid");
const zoneV1Matches = registry.policies?.filter((policy) => (
policy?.dataProductId === "map.zones.current.v1" && policy?.productVersion === "1.0.0"
)) || [];
if (zoneV1Matches.length !== 1 || JSON.stringify(zoneV1Matches[0]) !== JSON.stringify({
id: "map-zone-current-v1",
version: "1.0.0",
dataProductId: "map.zones.current.v1",
productVersion: "1.0.0",
freshness: "none",
staleAfterMs: null,
terminalStatuses: [],
removeMode: "canonical-tombstone-or-snapshot-rebase",
})) throw new Error("foundry_consumer_policy_zone_v1_contract_changed");
const zoneV2Matches = registry.policies?.filter((policy) => (
policy?.dataProductId === "map.zones.current.v2" && policy?.productVersion === "2.0.0"
)) || [];
if (zoneV2Matches.length !== 1) throw new Error("foundry_consumer_policy_zone_v2_missing_or_ambiguous");
const zoneV2Policy = zoneV2Matches[0];
const expectedProjection = [
"display_name",
"geometry_kind",
"max_speed_kph",
"schedule_timezone",
"applies_to_couriers",
"applies_to_kicksharing",
];
if (
zoneV2Policy.id !== "map-zone-current-v2"
|| zoneV2Policy.version !== "2.0.0"
|| zoneV2Policy.freshness !== "none"
|| zoneV2Policy.staleAfterMs !== null
|| JSON.stringify(zoneV2Policy.terminalStatuses) !== JSON.stringify([])
|| zoneV2Policy.consumerContract?.ontologyRevision !== "ontology.map.zone.v1"
|| zoneV2Policy.consumerContract?.deliveryMode !== "snapshot+patch"
|| JSON.stringify(zoneV2Policy.consumerContract?.semanticTypes) !== JSON.stringify(["map.zone"])
|| JSON.stringify(zoneV2Policy.consumerContract?.fieldProjection) !== JSON.stringify(expectedProjection)
|| JSON.stringify(zoneV2Policy.consumerContract?.geometryTypes) !== JSON.stringify(["Polygon", "MultiPolygon"])
|| zoneV2Policy.consumerContract?.subjectIdentity !== "semantic-type+source-id"
|| zoneV2Policy.consumerContract?.snapshotMode !== "atomic-replace"
|| zoneV2Policy.consumerContract?.patchMode !== "atomic"
|| zoneV2Policy.removeMode !== "canonical-tombstone-or-snapshot-rebase"
) throw new Error("foundry_consumer_policy_zone_v2_contract_invalid");
const unitProfileV1Matches = registry.policies?.filter((policy) => (
policy?.dataProductId === "fleet.units.profile.current.v1" && policy?.productVersion === "1.0.0"
)) || [];
if (unitProfileV1Matches.length !== 1) throw new Error("foundry_consumer_policy_unit_profile_v1_missing_or_ambiguous");
const unitProfileV1Policy = unitProfileV1Matches[0];
const expectedUnitProfileProjection = [
"corrected_engine_hours_factor",
"corrected_mileage_factor",
"display_name",
"filter_by_satellite_count_enabled",
"filter_by_satellite_count_value",
"filter_emissions",
"hardware_manufacturer_name",
"hardware_port",
"hardware_type_class",
"hardware_type_name",
"limit_acceleration",
"lost_connection_enabled",
"lost_connection_time_value",
"maximum_permissible_speed",
"maximum_valid_height",
"maximum_valid_speed",
"mileage_by_ignition",
"minimum_movement_speed",
"minimum_movement_time",
"minimum_parking_time",
"minimum_stop_time",
"minimum_trip_distance",
"minimum_valid_height",
"speed_parameter",
"trip_detection_type",
"unit_type_class",
"unit_type_name",
"use_odometer",
];
if (
unitProfileV1Policy.id !== "map-moving-object-unit-profile-current-v1"
|| unitProfileV1Policy.version !== "1.0.0"
|| unitProfileV1Policy.freshness !== "none"
|| unitProfileV1Policy.staleAfterMs !== null
|| JSON.stringify(unitProfileV1Policy.terminalStatuses) !== JSON.stringify([])
|| unitProfileV1Policy.consumerContract?.ontologyRevision !== "ontology.map.moving_object.v3"
|| unitProfileV1Policy.consumerContract?.deliveryMode !== "snapshot+patch"
|| JSON.stringify(unitProfileV1Policy.consumerContract?.semanticTypes) !== JSON.stringify(["map.moving_object"])
|| JSON.stringify(unitProfileV1Policy.consumerContract?.fieldProjection) !== JSON.stringify(expectedUnitProfileProjection)
|| JSON.stringify(unitProfileV1Policy.consumerContract?.geometryTypes) !== JSON.stringify([])
|| unitProfileV1Policy.consumerContract?.subjectIdentity !== "semantic-type+source-id"
|| unitProfileV1Policy.consumerContract?.snapshotMode !== "atomic-replace"
|| unitProfileV1Policy.consumerContract?.patchMode !== "atomic"
|| unitProfileV1Policy.removeMode !== "canonical-tombstone-or-snapshot-rebase"
) throw new Error("foundry_consumer_policy_unit_profile_v1_contract_invalid");
for (const [id, policy] of [
["v4", movingObjectV4Policy],
["zone-v2", zoneV2Policy],
["unit-profile-v1", unitProfileV1Policy],
]) {
const allowedPolicyKeys = new Set([
"id",
"version",
"dataProductId",
"productVersion",
"freshness",
"staleAfterMs",
"terminalStatuses",
"statusContract",
"consumerContract",
"removeMode",
]);
if (
Object.keys(policy).some((key) => !allowedPolicyKeys.has(key))
|| /(provider|tenant|endpoint|credential|token|secret|authorization)/i.test(JSON.stringify(policy))
) {
throw new Error(`foundry_consumer_policy_${id}_transport_boundary_violation`);
}
}
const consumer = await readFile(resolve(foundryRoot, files[2]), "utf8");
for (const marker of [
"data_product_consumer_fact_status_invalid",
"data_product_consumer_status_field_not_projected",
'policy.freshness === "none"',
"assertConsumerContract(policy.consumerContract, product, target.binding)",
"data_product_snapshot_product_mismatch",
"data_product_patch_product_mismatch",
"data_product_consumer_fact_contract_invalid",
]) {
if (!consumer.includes(marker)) throw new Error(`foundry_consumer_policy_runtime_guard_missing:${marker}`);
}
}
function canonicalTarScript() {
return [
"import gzip,io,pathlib,sys,tarfile",
"root=pathlib.Path(sys.argv[2])",
"with open(sys.argv[1],'wb') as out:",
" with gzip.GzipFile(filename='',mode='wb',fileobj=out,compresslevel=9,mtime=0) as gz:",
" with tarfile.open(fileobj=gz,mode='w',format=tarfile.PAX_FORMAT) as tar:",
" for top in ('manifest.env','files.txt','payload'):",
" p=root/top; paths=[p]+(sorted(p.rglob('*')) if p.is_dir() else [])",
" for x in paths:",
" info=tar.gettarinfo(str(x),arcname=x.relative_to(root).as_posix())",
" info.uid=info.gid=0; info.uname=info.gname='root'; info.mtime=0; info.mode=0o755 if info.isdir() else 0o644",
" with (open(x,'rb') if info.isfile() else io.BytesIO()) as src: tar.addfile(info,src if info.isfile() else None)",
].join("\n");
}

View File

@ -0,0 +1,102 @@
#!/usr/bin/env node
import { createHash } from "node:crypto";
import { spawnSync } from "node:child_process";
import { cp, lstat, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
const scriptDir = dirname(fileURLToPath(import.meta.url));
const platformRoot = resolve(scriptDir, "../..");
const workspaceRoot = resolve(platformRoot, "..");
const foundryRoot = resolve(workspaceRoot, "NODEDC_DESIGN_GUIDELINE");
const artifactDir = resolve(process.env.NODEDC_DEPLOY_ARTIFACT_DIR || resolve(scriptDir, "../deploy-artifacts"));
const [patchId = "module-foundry-filter-toggle-20260720-001", ...extra] = process.argv.slice(2);
if (extra.length || !/^[A-Za-z0-9._-]{1,96}$/.test(patchId)) {
throw new Error("patch_id_must_contain_only_letters_digits_dot_underscore_hyphen");
}
const files = [
"apps/catalog/src/MapFixturePreview.tsx",
"apps/catalog/src/mapPresentationProfile.ts",
"scripts/map-presentation-filters.test.mjs",
];
const stage = await mkdtemp(join(tmpdir(), "nodedc-module-foundry-filter-toggle-artifact-"));
const payload = join(stage, "payload");
const artifact = join(artifactDir, `nodedc-module-foundry-${patchId}.tgz`);
const checksum = `${artifact}.sha256`;
await assertFilterToggleBoundary();
try {
await mkdir(payload, { recursive: true });
for (const relativePath of files) {
const source = resolve(foundryRoot, relativePath);
const sourceStat = await lstat(source);
if (!sourceStat.isFile() || sourceStat.isSymbolicLink()) {
throw new Error(`source_file_rejected:${relativePath}`);
}
const destination = join(payload, relativePath);
await mkdir(dirname(destination), { recursive: true });
await cp(source, destination, { force: true, verbatimSymlinks: true });
}
await writeFile(join(stage, "manifest.env"), `id=${patchId}\ncomponent=module-foundry\ntype=app-overlay\n`, "utf8");
await writeFile(join(stage, "files.txt"), `${files.join("\n")}\n`, "utf8");
await mkdir(artifactDir, { recursive: true });
const tar = spawnSync("python3", ["-c", canonicalTarScript(), artifact, stage], {
encoding: "utf8",
maxBuffer: 64 * 1024 * 1024,
});
if (tar.status !== 0) throw new Error(`tar_failed:${tar.stderr || tar.stdout}`);
const sha256 = createHash("sha256").update(await readFile(artifact)).digest("hex");
await writeFile(checksum, `${sha256} ${artifact.split("/").at(-1)}\n`, "utf8");
console.log(JSON.stringify({ ok: true, patchId, artifact, checksum, sha256, files }, null, 2));
} finally {
await rm(stage, { recursive: true, force: true });
}
async function assertFilterToggleBoundary() {
const helper = await readFile(resolve(foundryRoot, files[1]), "utf8");
for (const marker of [
"toggleMapPresentationFacetSelection",
"const { [field]: _removed, ...unconstrained } = facets",
"return unconstrained",
]) {
if (!helper.includes(marker)) throw new Error(`foundry_filter_toggle_helper_missing:${marker}`);
}
const preview = await readFile(resolve(foundryRoot, files[0]), "utf8");
if (!preview.includes("filters: toggleMapPresentationFacetSelection(filters, field, value)")) {
throw new Error("foundry_filter_toggle_component_contract_missing");
}
const test = await readFile(resolve(foundryRoot, files[2]), "utf8");
for (const marker of [
"interactive deselect of the last chip removes the facet constraint",
"interactive deselect preserves other values and facet constraints",
"deselecting the last chip remains empty and never normalizes to all",
]) {
if (!test.includes(marker)) throw new Error(`foundry_filter_toggle_regression_missing:${marker}`);
}
}
function canonicalTarScript() {
return [
"import gzip,io,pathlib,sys,tarfile",
"root=pathlib.Path(sys.argv[2])",
"with open(sys.argv[1],'wb') as out:",
" with gzip.GzipFile(filename='',mode='wb',fileobj=out,compresslevel=9,mtime=0) as gz:",
" with tarfile.open(fileobj=gz,mode='w',format=tarfile.PAX_FORMAT) as tar:",
" for top in ('manifest.env','files.txt','payload'):",
" p=root/top; paths=[p]+(sorted(p.rglob('*')) if p.is_dir() else [])",
" for x in paths:",
" info=tar.gettarinfo(str(x),arcname=x.relative_to(root).as_posix())",
" info.uid=info.gid=0; info.uname=info.gname='root'; info.mtime=0; info.mode=0o755 if info.isdir() else 0o644",
" with (open(x,'rb') if info.isfile() else io.BytesIO()) as src: tar.addfile(info,src if info.isfile() else None)",
].join("\n");
}

View File

@ -0,0 +1,170 @@
#!/usr/bin/env node
import { createHash } from "node:crypto";
import { spawnSync } from "node:child_process";
import { copyFile, lstat, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
const scriptDir = dirname(fileURLToPath(import.meta.url));
const workspaceRoot = resolve(scriptDir, "../../..");
const foundryRoot = resolve(workspaceRoot, "NODEDC_DESIGN_GUIDELINE");
const artifactDir = resolve(
process.env.NODEDC_DEPLOY_ARTIFACT_DIR || resolve(scriptDir, "../deploy-artifacts"),
);
const [patchId = "module-foundry-unit-identity-20260724-011", ...extra] =
process.argv.slice(2);
if (extra.length || !/^module-foundry-unit-identity-\d{8}-\d{3}$/.test(patchId)) {
throw new Error(
"usage: build-module-foundry-unit-identity-artifact.mjs "
+ "[module-foundry-unit-identity-YYYYMMDD-NNN]",
);
}
const expectedSha256 = Object.freeze({
"apps/catalog/src/MapFixturePreview.tsx":
"0ab08a872a8ec4cfb5144c808e361fa42e261bc14a23350843c487e3639d3cf7",
"apps/catalog/src/mapSubjectCard.d.mts":
"3a1afd1b2da42fe843bd50a9199137b5a490cc87759375033e17e18368ad019f",
"apps/catalog/src/mapSubjectCard.mjs":
"e235ca0b33ebed687ab62d2a3a5ff0cc865af4c51d42f1b920822077ddd67de1",
"registry/data-product-consumer-policies.json":
"7817e9ffbb8eb0e20445c97e3c2684d4dfa4e217ead569241052b9f29789ab44",
"server/foundry-data-product-consumer.mjs":
"3a21d887368bd28bf538e8bcda45cbd3e1fa775b8e21092ade50dd913ad29305",
"server/foundry-mcp.mjs":
"1a80ba6398e1a038fd07cb118de48f23402433863559b310942a0f6cb01d974b",
"server/map-subject-detail-profile.mjs":
"2d3d466c1798bd9a0c0c8c766235bde1792285498309fbbe58d4c1892ed62862",
});
const files = Object.freeze(Object.keys(expectedSha256));
const artifact = join(artifactDir, `nodedc-${patchId}.tgz`);
const checksum = `${artifact}.sha256`;
const stage = await mkdtemp(join(tmpdir(), "nodedc-foundry-unit-identity-"));
await assertFresh(artifact);
await assertExactSources();
try {
for (const relativePath of files) {
const destination = join(stage, "payload", relativePath);
await mkdir(dirname(destination), { recursive: true });
await copyFile(join(foundryRoot, relativePath), destination);
}
await writeFile(
join(stage, "manifest.env"),
`id=${patchId}\ncomponent=module-foundry\ntype=app-overlay\n`,
"utf8",
);
await writeFile(join(stage, "files.txt"), `${files.join("\n")}\n`, "utf8");
await mkdir(artifactDir, { recursive: true });
run("python3", ["-c", canonicalTarScript(), artifact, stage]);
const sha256 = digest(await readFile(artifact));
await writeFile(checksum, `${sha256} ${artifact.split("/").at(-1)}\n`, "utf8");
console.log(JSON.stringify({
ok: true,
patchId,
artifact,
checksum,
sha256,
services: ["nodedc-module-foundry"],
presentation: "provider-neutral-restricted-string-lists",
files,
}, null, 2));
} finally {
await rm(stage, { recursive: true, force: true });
}
async function assertExactSources() {
for (const [relativePath, expected] of Object.entries(expectedSha256)) {
const sourcePath = join(foundryRoot, relativePath);
const info = await lstat(sourcePath);
if (!info.isFile() || info.isSymbolicLink()) {
throw new Error(`foundry_unit_identity_source_unsafe:${relativePath}`);
}
const actual = digest(await readFile(sourcePath));
if (actual !== expected) {
throw new Error(
`foundry_unit_identity_target_mismatch:${relativePath}:`
+ `expected=${expected}:actual=${actual}`,
);
}
}
const profile = await readFile(
join(foundryRoot, "server/map-subject-detail-profile.mjs"),
"utf8",
);
const card = await readFile(
join(foundryRoot, "apps/catalog/src/mapSubjectCard.mjs"),
"utf8",
);
const consumer = await readFile(
join(foundryRoot, "server/foundry-data-product-consumer.mjs"),
"utf8",
);
if (
!profile.includes("string_list")
|| !card.includes("string_list")
|| !consumer.includes("string_array")
|| /gelios\.provider/i.test(profile)
|| /gelios\.provider/i.test(card)
) {
throw new Error("foundry_unit_identity_provider_neutral_boundary_invalid");
}
const policies = JSON.parse(await readFile(
join(foundryRoot, "registry/data-product-consumer-policies.json"),
"utf8",
));
const policy = policies?.policies?.find(
(entry) => entry?.dataProductId === "fleet.units.identity.current.v1",
);
if (
policy?.dataClass !== "restricted"
|| policy?.consumerContract?.fieldTypes?.device_imei !== "string"
|| policy?.consumerContract?.fieldTypes?.custom_fields !== "string_array"
) {
throw new Error("foundry_unit_identity_policy_invalid");
}
}
async function assertFresh(path) {
try {
await lstat(path);
} catch (error) {
if (error?.code === "ENOENT") return;
throw error;
}
throw new Error("artifact_already_exists");
}
function canonicalTarScript() {
return [
"import gzip,io,pathlib,sys,tarfile",
"root=pathlib.Path(sys.argv[2])",
"with open(sys.argv[1],'xb') as out:",
" with gzip.GzipFile(filename='',mode='wb',fileobj=out,compresslevel=9,mtime=0) as gz:",
" with tarfile.open(fileobj=gz,mode='w',format=tarfile.PAX_FORMAT) as tar:",
" for top in ('manifest.env','files.txt','payload'):",
" p=root/top; paths=[p]+(sorted(p.rglob('*')) if p.is_dir() else [])",
" for x in paths:",
" info=tar.gettarinfo(str(x),arcname=x.relative_to(root).as_posix())",
" info.uid=info.gid=0; info.uname=info.gname='root'; info.mtime=0; info.mode=0o755 if info.isdir() else 0o644",
" with (open(x,'rb') if info.isfile() else io.BytesIO()) as src: tar.addfile(info,src if info.isfile() else None)",
].join("\n");
}
function digest(value) {
return createHash("sha256").update(value).digest("hex");
}
function run(command, args) {
const result = spawnSync(command, args, {
encoding: "utf8",
maxBuffer: 64 * 1024 * 1024,
});
if (result.status !== 0) {
throw new Error(`${command}_failed:${result.stderr || result.stdout}`);
}
}

View File

@ -0,0 +1,288 @@
#!/usr/bin/env node
import { createHash } from "node:crypto";
import { createRequire } from "node:module";
import { spawnSync } from "node:child_process";
import { cp, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
const scriptDir = dirname(fileURLToPath(import.meta.url));
const platformRoot = resolve(scriptDir, "../..");
const packageRoot = resolve(platformRoot, "packages/n8n-nodes-ndc");
const artifactDir = resolve(process.env.NODEDC_DEPLOY_ARTIFACT_DIR || resolve(scriptDir, "../deploy-artifacts"));
const requireModule = createRequire(import.meta.url);
const expectedPackageVersion = "0.1.6";
const [patchId = "n8n-nodes-ndc-replace-replay-v1-20260721-009", ...extra] = process.argv.slice(2);
const expectedRuntimeNodes = [
{
file: "dist/nodes/NdcDataProductPublish/NdcDataProductPublish.node.js",
exportName: "NdcDataProductPublish",
name: "ndcDataProductPublish",
},
{
file: "dist/nodes/NdcDataProductRead/NdcDataProductRead.node.js",
exportName: "NdcDataProductRead",
name: "ndcDataProductRead",
},
{
file: "dist/nodes/NdcFoundryBinding/NdcFoundryBinding.node.js",
exportName: "NdcFoundryBinding",
name: "ndcFoundryBinding",
},
];
const expectedN8nNodes = expectedRuntimeNodes.map((node) => node.file);
const expectedRuntimeNodeTypes = expectedRuntimeNodes.map((node) => `n8n-nodes-ndc.${node.name}`);
const expectedN8nCredentials = [
"dist/credentials/NdcDataProductWriterApi.credentials.js",
"dist/credentials/NdcDataProductReaderApi.credentials.js",
"dist/credentials/NdcFoundryBindingApi.credentials.js",
"dist/credentials/NdcProviderRotatingAccessApi.credentials.js",
];
const expectedCredentialTypes = [
"ndcDataProductWriterApi",
"ndcDataProductReaderApi",
"ndcFoundryBindingApi",
"ndcProviderRotatingAccessApi",
];
if (extra.length || !/^[A-Za-z0-9._-]{1,96}$/.test(patchId)) {
throw new Error("usage: build-n8n-private-extension-artifact.mjs [patch-id]");
}
const packageJson = JSON.parse(await readFile(join(packageRoot, "package.json"), "utf8"));
assertPackageSourcePolicy(packageJson);
run("npm", ["test"], packageRoot);
run("npm", ["run", "lint"], packageRoot);
assertRuntimeNodePolicy(packageJson);
const stage = await mkdtemp(join(tmpdir(), "nodedc-n8n-private-extension-"));
const packDir = join(stage, "pack");
const payload = join(stage, "payload");
try {
await mkdir(packDir, { recursive: true });
const pack = run("npm", ["pack", "--ignore-scripts", "--json", "--pack-destination", packDir], packageRoot);
const packResult = JSON.parse(pack.stdout);
if (!Array.isArray(packResult) || packResult.length !== 1) throw new Error("npm_pack_result_invalid");
const metadata = packResult[0];
assertPackedFilePolicy(metadata, packageJson);
const packedPath = join(packDir, metadata.filename);
const canonicalPackedPath = join(packDir, "n8n-nodes-ndc.canonical.tgz");
const canonicalPackageScript = [
"import gzip, io, sys, tarfile",
"members = []",
"with tarfile.open(sys.argv[1], 'r:gz') as source:",
" for original in source:",
" if not (original.isfile() or original.isdir()):",
" raise SystemExit('unsupported npm package member')",
" content = b''",
" if original.isfile():",
" extracted = source.extractfile(original)",
" if extracted is None:",
" raise SystemExit('unreadable npm package member')",
" content = extracted.read()",
" members.append((original.name, original.isdir(), content))",
"with open(sys.argv[2], 'wb') as output:",
" with gzip.GzipFile(filename='', mode='wb', fileobj=output, compresslevel=9, mtime=0) as compressed:",
" with tarfile.open(fileobj=compressed, mode='w', format=tarfile.PAX_FORMAT) as target:",
" for name, is_dir, content in sorted(members, key=lambda item: item[0]):",
" member = tarfile.TarInfo(name)",
" member.uid = member.gid = 0",
" member.uname = member.gname = 'root'",
" member.mtime = 0",
" member.mode = 0o755 if is_dir else 0o644",
" member.type = tarfile.DIRTYPE if is_dir else tarfile.REGTYPE",
" member.size = 0 if is_dir else len(content)",
" target.addfile(member, None if is_dir else io.BytesIO(content))",
].join("\n");
run("python3", ["-c", canonicalPackageScript, packedPath, canonicalPackedPath], stage);
const packageBytes = await readFile(canonicalPackedPath);
const packageSha256 = createHash("sha256").update(packageBytes).digest("hex");
const releaseId = `${packageJson.version}-${packageSha256.slice(0, 16)}`;
const relativeReleasePath = `releases/n8n-nodes-ndc/${releaseId}`;
const releaseDir = join(payload, relativeReleasePath);
await mkdir(releaseDir, { recursive: true });
await cp(canonicalPackedPath, join(releaseDir, "package.tgz"), { force: false });
const rollbackBaselinePolicy = {
allowed: [
"previous_verified_immutable_release",
"verified_inactive",
],
firstActivation: "verified_inactive",
requiresPreActivationVerification: true,
};
const release = {
schemaVersion: "nodedc.n8n-private-extension-release/v2",
releaseId,
package: {
name: "n8n-nodes-ndc",
version: packageJson.version,
sha256: packageSha256,
bytes: packageBytes.byteLength,
runtimeTypePrefix: "n8n-nodes-ndc.",
},
storage: {
relativePath: relativeReleasePath,
immutable: true,
},
activation: {
owner: "engine",
status: "blocked_pending_engine_owned_mount",
requiredCommunityPackagePath: "/home/node/.n8n/nodes/node_modules/n8n-nodes-ndc",
requiresAtomicReleaseSwitch: true,
requiresAllN8nProcessesRestart: true,
requiresMcpSchemaAcceptance: true,
rollbackBaselinePolicy,
},
};
const rollback = {
schemaVersion: "nodedc.n8n-private-extension-rollback/v2",
releaseId,
packageSha256,
mode: "engine-owned-atomic-release-switch",
baselinePolicy: rollbackBaselinePolicy,
steps: [
"select_verified_previous_release_or_preverified_inactive_baseline",
"switch_engine_owned_mount_atomically",
"restart_all_n8n_processes",
"verify_mcp_schema_state_matches_selected_baseline",
],
forbidden: [
"delete_active_release",
"mutate_engine_core",
"live_npm_install",
],
};
await writeFile(join(releaseDir, "release.json"), `${JSON.stringify(release, null, 2)}\n`, "utf8");
await writeFile(join(releaseDir, "rollback.json"), `${JSON.stringify(rollback, null, 2)}\n`, "utf8");
await writeFile(join(stage, "manifest.env"), `id=${patchId}\ncomponent=n8n-private-extension\ntype=app-overlay\n`, "utf8");
await writeFile(join(stage, "files.txt"), `${relativeReleasePath}\n`, "utf8");
await mkdir(artifactDir, { recursive: true });
const target = join(artifactDir, `nodedc-n8n-private-extension-${patchId}.tgz`);
const tarScript = [
"import gzip, os, pathlib, sys, tarfile",
"root = pathlib.Path(sys.argv[2])",
"def clean(info):",
" info.uid = info.gid = 0",
" info.uname = info.gname = 'root'",
" info.mtime = 0",
" info.mode = 0o755 if info.isdir() else 0o644",
" return info",
"with open(sys.argv[1], 'wb') as output:",
" with gzip.GzipFile(filename='', mode='wb', fileobj=output, compresslevel=9, mtime=0) as compressed:",
" with tarfile.open(fileobj=compressed, mode='w', format=tarfile.PAX_FORMAT) as archive:",
" for top in ('manifest.env', 'files.txt', 'payload'):",
" path = root / top",
" archive.add(path, arcname=top, recursive=False, filter=clean)",
" if path.is_dir():",
" for child in sorted(path.rglob('*'), key=lambda item: item.as_posix()):",
" archive.add(child, arcname=child.relative_to(root).as_posix(), recursive=False, filter=clean)",
].join("\n");
run("python3", ["-c", tarScript, target, stage], stage);
const artifactSha256 = createHash("sha256").update(await readFile(target)).digest("hex");
console.log(JSON.stringify({
ok: true,
patchId,
artifact: target,
artifactSha256,
releaseId,
packageSha256,
nodeTypes: expectedRuntimeNodeTypes,
credentialTypes: expectedCredentialTypes,
activation: "blocked_pending_engine_owned_mount",
}, null, 2));
} finally {
await rm(stage, { recursive: true, force: true });
}
function assertPackageSourcePolicy(value) {
if (value.name !== "n8n-nodes-ndc") throw new Error("package_name_invalid");
if (value.version !== expectedPackageVersion) throw new Error("package_version_invalid");
if (value.private !== true) throw new Error("package_must_remain_private");
if (value.dependencies !== undefined) throw new Error("runtime_dependencies_forbidden");
for (const lifecycle of ["preinstall", "install", "postinstall", "prepack", "prepare", "postpack"]) {
if (value.scripts?.[lifecycle] !== undefined) throw new Error(`lifecycle_script_forbidden:${lifecycle}`);
}
assertExactRegistration(value.n8n?.nodes, expectedN8nNodes, "n8n_nodes");
assertExactRegistration(value.n8n?.credentials, expectedN8nCredentials, "n8n_credentials");
}
function assertRuntimeNodePolicy(packageJson) {
const observedTypes = [];
for (const expected of expectedRuntimeNodes) {
const loaded = requireModule(join(packageRoot, expected.file));
const NodeClass = loaded?.[expected.exportName];
if (typeof NodeClass !== "function") throw new Error(`runtime_node_export_missing:${expected.exportName}`);
const description = new NodeClass()?.description;
if (!description || description.name !== expected.name) {
throw new Error(`runtime_node_name_mismatch:${expected.exportName}`);
}
if ("usableAsTool" in description) {
throw new Error(`runtime_tool_variant_forbidden:${expected.name}`);
}
observedTypes.push(`${packageJson.name}.${description.name}`);
}
if (JSON.stringify(observedTypes) !== JSON.stringify(expectedRuntimeNodeTypes)) {
throw new Error("runtime_node_types_mismatch");
}
}
function assertExactRegistration(actual, expected, label) {
if (!Array.isArray(actual)) throw new Error(`${label}_missing`);
if (actual.length !== new Set(actual).size) throw new Error(`${label}_duplicate`);
if (actual.length !== expected.length || expected.some((value) => !actual.includes(value))) {
throw new Error(`${label}_mismatch`);
}
}
function assertPackedFilePolicy(metadata, sourcePackage) {
if (metadata.name !== sourcePackage.name || metadata.version !== sourcePackage.version) {
throw new Error("npm_pack_identity_mismatch");
}
if (!Number.isSafeInteger(metadata.size) || metadata.size < 1024 || metadata.size > 32 * 1024 * 1024) {
throw new Error("npm_pack_size_invalid");
}
if (!Array.isArray(metadata.files) || metadata.files.length > 512) throw new Error("npm_pack_file_list_invalid");
const paths = new Set();
for (const file of metadata.files) {
if (!file || typeof file.path !== "string" || paths.has(file.path)) throw new Error("npm_pack_file_invalid");
paths.add(file.path);
if (!(file.path === "README.md" || file.path === "package.json" || file.path.startsWith("dist/"))) {
throw new Error(`npm_pack_path_forbidden:${file.path}`);
}
const parts = file.path.split("/");
if (
file.path.includes("\\")
|| parts.some((part) => !part || part === "." || part === ".." || part.startsWith("."))
|| file.mode !== 0o644
) {
throw new Error(`npm_pack_path_unsafe:${file.path}`);
}
}
assertExactRegistration(
[...paths].filter((value) => /^dist\/nodes\/.+\.node\.js$/.test(value)),
expectedN8nNodes,
"npm_pack_nodes",
);
assertExactRegistration(
[...paths].filter((value) => /^dist\/credentials\/.+\.credentials\.js$/.test(value)),
expectedN8nCredentials,
"npm_pack_credentials",
);
for (const registered of [...sourcePackage.n8n.nodes, ...sourcePackage.n8n.credentials]) {
if (!paths.has(registered)) throw new Error(`npm_pack_registration_missing:${registered}`);
}
}
function run(command, args, cwd) {
const result = spawnSync(command, args, { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] });
if (result.status !== 0) {
throw new Error(`${command}_failed:${result.stderr || result.stdout}`);
}
return result;
}

View File

@ -0,0 +1,84 @@
#!/usr/bin/env node
import { createHash } from "node:crypto";
import { spawnSync } from "node:child_process";
import { cp, lstat, mkdir, mkdtemp, readFile, readdir, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { dirname, join, relative, resolve } from "node:path";
import { fileURLToPath } from "node:url";
const scriptDir = dirname(fileURLToPath(import.meta.url));
const platformRoot = resolve(scriptDir, "../..");
const artifactDir = resolve(process.env.NODEDC_DEPLOY_ARTIFACT_DIR || resolve(scriptDir, "../deploy-artifacts"));
const [patchId = "ontology-core-20260719-001", ...extra] = process.argv.slice(2);
if (extra.length || !/^[A-Za-z0-9._-]{1,96}$/.test(patchId)) {
throw new Error("usage: build-ontology-core-artifact.mjs [patch-id]");
}
const sourceRoot = resolve(platformRoot, "services/ontology-core");
const destinationRoot = "platform/ontology-core";
const ignoredBasenames = new Set([".DS_Store", ".git", "node_modules", "test"]);
const stage = await mkdtemp(join(tmpdir(), "nodedc-ontology-core-artifact-"));
const payload = join(stage, "payload");
const target = join(artifactDir, `nodedc-platform-${patchId}.tgz`);
try {
await copySafe(sourceRoot, join(payload, destinationRoot));
await writeFile(join(stage, "manifest.env"), `id=${patchId}\ncomponent=platform\ntype=app-overlay\n`, "utf8");
await writeFile(join(stage, "files.txt"), `${destinationRoot}\n`, "utf8");
await mkdir(artifactDir, { recursive: true });
const tar = spawnSync("python3", ["-c", canonicalTarScript(), target, stage], {
encoding: "utf8",
maxBuffer: 128 * 1024 * 1024,
});
if (tar.status !== 0) throw new Error(`tar_failed:${tar.stderr || tar.stdout}`);
console.log(JSON.stringify({
ok: true,
patchId,
artifact: target,
sha256: createHash("sha256").update(await readFile(target)).digest("hex"),
entries: [destinationRoot],
servicesExpected: ["ontology-core", "ai-workspace-hub"],
excluded: [".env*", "node_modules", "test", "secrets"],
}, null, 2));
} finally {
await rm(stage, { recursive: true, force: true });
}
function canonicalTarScript() {
return [
"import gzip,io,pathlib,sys,tarfile",
"root=pathlib.Path(sys.argv[2])",
"with open(sys.argv[1],'wb') as out:",
" with gzip.GzipFile(filename='',mode='wb',fileobj=out,compresslevel=9,mtime=0) as gz:",
" with tarfile.open(fileobj=gz,mode='w',format=tarfile.PAX_FORMAT) as tar:",
" for top in ('manifest.env','files.txt','payload'):",
" p=root/top; paths=[p]+(sorted(p.rglob('*')) if p.is_dir() else [])",
" for x in paths:",
" info=tar.gettarinfo(str(x),arcname=x.relative_to(root).as_posix())",
" info.uid=info.gid=0; info.uname=info.gname='root'; info.mtime=0; info.mode=0o755 if info.isdir() else 0o644",
" with (open(x,'rb') if info.isfile() else io.BytesIO()) as src: tar.addfile(info,src if info.isfile() else None)",
].join("\n");
}
async function copySafe(source, destination) {
const sourceStat = await lstat(source);
if (sourceStat.isSymbolicLink()) throw new Error(`source_symlink_rejected:${relative(platformRoot, source)}`);
if (sourceStat.isFile()) {
await mkdir(dirname(destination), { recursive: true });
await cp(source, destination, { force: true, verbatimSymlinks: true });
return;
}
if (!sourceStat.isDirectory()) throw new Error(`source_type_rejected:${relative(platformRoot, source)}`);
await mkdir(destination, { recursive: true });
for (const entry of await readdir(source, { withFileTypes: true })) {
if (ignoredBasenames.has(entry.name) || entry.name.startsWith(".env")) continue;
const childSource = join(source, entry.name);
const childDestination = join(destination, entry.name);
if (entry.isSymbolicLink()) throw new Error(`source_symlink_rejected:${relative(platformRoot, childSource)}`);
await copySafe(childSource, childDestination);
}
}

View File

@ -0,0 +1,180 @@
#!/usr/bin/env node
import { createHash } from "node:crypto";
import { spawnSync } from "node:child_process";
import { cp, lstat, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
const here = dirname(fileURLToPath(import.meta.url));
const sourceRoot = resolve(here, "ops-mcp-workspace-tools");
const overlayRoot = join(sourceRoot, "overlays");
const release = JSON.parse(await readFile(join(sourceRoot, "release.json"), "utf8"));
const artifactRoot = resolve(
process.env.NODEDC_DEPLOY_ARTIFACT_DIR || resolve(here, "../deploy-artifacts"),
);
const [requestedRelease = release.release, ...extra] = process.argv.slice(2);
if (extra.length || requestedRelease !== release.release) {
throw new Error("usage: build-ops-mcp-workspace-tools-artifacts.mjs [" + release.release + "]");
}
const productionRoots = {
tasker: "/volume1/docker/nodedc-platform/tasker",
"ops-agents": "/volume1/docker/nodedc-platform/ops-agents",
};
const results = [];
await mkdir(artifactRoot, { recursive: true });
for (const [component, descriptor] of Object.entries(release.components)) {
validateDescriptor(component, descriptor);
const target = join(artifactRoot, "nodedc-" + descriptor.artifactId + ".tgz");
const predecessorPath = join(artifactRoot, "nodedc-" + descriptor.artifactId + ".predecessor.sha256");
const newPathsPath = join(artifactRoot, "nodedc-" + descriptor.artifactId + ".new-paths");
await assertFresh(target);
await assertFresh(predecessorPath);
await assertFresh(newPathsPath);
const stage = await mkdtemp(join(tmpdir(), "nodedc-" + component + "-workspace-tools-"));
const payload = join(stage, "payload");
try {
await mkdir(payload, { recursive: true });
for (const relativePath of descriptor.files) {
const source = join(overlayRoot, component, relativePath);
const info = await lstat(source);
if (!info.isFile() || info.isSymbolicLink()) {
throw new Error("invalid_overlay_source:" + component + ":" + relativePath);
}
await mkdir(dirname(join(payload, relativePath)), { recursive: true });
await cp(source, join(payload, relativePath), { force: false });
}
await writeFile(
join(stage, "manifest.env"),
"id=" + descriptor.artifactId + "\ncomponent=" + component + "\ntype=app-overlay\n",
"utf8",
);
await writeFile(join(stage, "files.txt"), descriptor.files.join("\n") + "\n", "utf8");
run("python3", ["-c", canonicalTarScript(), target, stage]);
const productionRoot = productionRoots[component];
const predecessorLines = Object.entries(descriptor.predecessors)
.map(([relativePath, digest]) => digest + " " + productionRoot + "/" + relativePath);
await writeFile(predecessorPath, predecessorLines.join("\n") + "\n", "utf8");
await writeFile(
newPathsPath,
descriptor.newPaths.map((relativePath) => productionRoot + "/" + relativePath).join("\n") + "\n",
"utf8",
);
results.push({
component,
artifactId: descriptor.artifactId,
artifact: target,
artifactSha256: sha(await readFile(target)),
predecessorChecks: predecessorPath,
predecessorChecksSha256: sha(await readFile(predecessorPath)),
newPaths: newPathsPath,
newPathsSha256: sha(await readFile(newPathsPath)),
files: descriptor.files,
});
} finally {
await rm(stage, { recursive: true, force: true });
}
}
console.log(JSON.stringify({
ok: true,
schemaVersion: release.schemaVersion,
release: release.release,
deployOrder: ["tasker", "ops-agents"],
runnerChanged: false,
results,
}, null, 2));
function validateDescriptor(component, descriptor) {
if (!(component in productionRoots)) throw new Error("unsupported_component:" + component);
if (!/^[A-Za-z0-9._-]{1,96}$/.test(descriptor.artifactId)) {
throw new Error("invalid_artifact_id:" + component);
}
if (!Array.isArray(descriptor.files) || descriptor.files.length === 0) {
throw new Error("empty_file_list:" + component);
}
if (new Set(descriptor.files).size !== descriptor.files.length) {
throw new Error("duplicate_file:" + component);
}
for (const relativePath of descriptor.files) validateRelativePath(relativePath);
for (const relativePath of Object.keys(descriptor.predecessors)) {
validateRelativePath(relativePath);
if (!descriptor.files.includes(relativePath)) {
throw new Error("predecessor_not_in_files:" + component + ":" + relativePath);
}
if (!/^[a-f0-9]{64}$/.test(descriptor.predecessors[relativePath])) {
throw new Error("invalid_predecessor_sha256:" + component + ":" + relativePath);
}
}
for (const relativePath of descriptor.newPaths) {
validateRelativePath(relativePath);
if (!descriptor.files.includes(relativePath)) {
throw new Error("new_path_not_in_files:" + component + ":" + relativePath);
}
if (relativePath in descriptor.predecessors) {
throw new Error("new_path_has_predecessor:" + component + ":" + relativePath);
}
}
const covered = new Set([...Object.keys(descriptor.predecessors), ...descriptor.newPaths]);
if (covered.size !== descriptor.files.length) {
throw new Error("predecessor_partition_incomplete:" + component);
}
}
function validateRelativePath(value) {
if (
typeof value !== "string"
|| !value
|| value.startsWith("/")
|| value.split("/").some((part) => !part || part === "." || part === "..")
) {
throw new Error("unsafe_relative_path:" + value);
}
}
async function assertFresh(path) {
try {
await lstat(path);
} catch (error) {
if (error?.code === "ENOENT") return;
throw error;
}
throw new Error("output_already_exists:" + path);
}
function canonicalTarScript() {
return [
"import gzip,io,pathlib,sys,tarfile",
"root=pathlib.Path(sys.argv[2])",
"with open(sys.argv[1],'wb') as out:",
" with gzip.GzipFile(filename='',mode='wb',fileobj=out,compresslevel=9,mtime=0) as gz:",
" with tarfile.open(fileobj=gz,mode='w',format=tarfile.PAX_FORMAT) as tar:",
" for top in ('manifest.env','files.txt','payload'):",
" p=root/top; paths=[p]+(sorted(p.rglob('*')) if p.is_dir() else [])",
" for x in paths:",
" info=tar.gettarinfo(str(x),arcname=x.relative_to(root).as_posix())",
" info.uid=info.gid=0; info.uname=info.gname='root'; info.mtime=0; info.mode=0o755 if info.isdir() else 0o644",
" with (open(x,'rb') if info.isfile() else io.BytesIO()) as src: tar.addfile(info,src if info.isfile() else None)",
].join("\n");
}
function run(command, args) {
const result = spawnSync(command, args, {
encoding: "utf8",
maxBuffer: 128 * 1024 * 1024,
stdio: ["ignore", "pipe", "pipe"],
});
if (result.status !== 0) throw new Error(command + "_failed:" + (result.stderr || result.stdout));
return result;
}
function sha(bytes) {
return createHash("sha256").update(bytes).digest("hex");
}

View File

@ -0,0 +1,101 @@
#!/usr/bin/env node
import { createHash } from 'node:crypto'
import { spawnSync } from 'node:child_process'
import { cp, lstat, mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { dirname, join, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
const here = dirname(fileURLToPath(import.meta.url))
const platformRoot = resolve(here, '../..')
const artifactRoot = resolve(process.env.NODEDC_DEPLOY_ARTIFACT_DIR || resolve(here, '../deploy-artifacts'))
const [transitionId = '20260718-005', ...extra] = process.argv.slice(2)
if (extra.length || !/^\d{8}-[0-9]{3}$/.test(transitionId)) {
throw new Error('usage: build-platform-gelios-provider-v2-artifact.mjs [YYYYMMDD-NNN]')
}
const id = `platform-gelios-provider-v2-${transitionId}`
const target = join(artifactRoot, `nodedc-${id}.tgz`)
const files = [
'platform/packages/external-provider-contract/providers/gelios/v2/README.md',
'platform/packages/external-provider-contract/providers/gelios/v2/index.mjs',
'platform/packages/external-provider-contract/providers/gelios/v2/package.mjs',
]
const expectedSha256 = new Map([
[files[0], '82b56e484370b5dd99d281a4b91a6f8f4ac7e3e98ce75c15be1f2f23b556d21d'],
[files[1], '405d2d9894b6b9f53c6522f675740b32355628b63dc2215dac7729033e8ef242'],
[files[2], 'eca88d359a422162f261c449a4c1547d80bdb7029b6111621a82c99e073218c5'],
])
await assertFresh(target)
const stage = await mkdtemp(join(tmpdir(), 'nodedc-platform-gelios-provider-v2-'))
const payload = join(stage, 'payload')
try {
await mkdir(payload, { recursive: true })
for (const rel of files) {
const source = join(platformRoot, rel.replace(/^platform\//, ''))
const stat = await lstat(source)
if (!stat.isFile() || stat.isSymbolicLink()) throw new Error(`source_boundary_invalid:${rel}`)
const bytes = await readFile(source)
if (sha(bytes) !== expectedSha256.get(rel)) throw new Error(`source_sha256_mismatch:${rel}`)
await mkdir(dirname(join(payload, rel)), { recursive: true })
await cp(source, join(payload, rel), { force: false })
}
await writeFile(join(stage, 'manifest.env'), `id=${id}\ncomponent=platform\ntype=app-overlay\n`, 'utf8')
await writeFile(join(stage, 'files.txt'), `${files.join('\n')}\n`, 'utf8')
await mkdir(artifactRoot, { recursive: true })
run('python3', ['-c', canonicalTarScript(), target, stage])
console.log(JSON.stringify({
ok: true,
id,
artifact: target,
artifactSha256: sha(await readFile(target)),
services: [],
providerPackage: 'gelios.provider.v2',
authentication: 'SDK query token',
runtimeEffect: 'catalog-only; no service restart',
files,
}, null, 2))
} finally {
await rm(stage, { recursive: true, force: true })
}
async function assertFresh(path) {
try {
await lstat(path)
} catch (error) {
if (error?.code === 'ENOENT') return
throw error
}
throw new Error('artifact_already_exists')
}
function canonicalTarScript() {
return [
'import gzip,io,pathlib,sys,tarfile',
'root=pathlib.Path(sys.argv[2])',
"with open(sys.argv[1],'wb') as out:",
" with gzip.GzipFile(filename='',mode='wb',fileobj=out,compresslevel=9,mtime=0) as gz:",
" with tarfile.open(fileobj=gz,mode='w',format=tarfile.PAX_FORMAT) as tar:",
" for top in ('manifest.env','files.txt','payload'):",
" p=root/top; paths=[p]+(sorted(p.rglob('*')) if p.is_dir() else [])",
' for x in paths:',
' info=tar.gettarinfo(str(x),arcname=x.relative_to(root).as_posix())',
" info.uid=info.gid=0; info.uname=info.gname='root'; info.mtime=0; info.mode=0o755 if info.isdir() else 0o644",
" with (open(x,'rb') if info.isfile() else io.BytesIO()) as src: tar.addfile(info,src if info.isfile() else None)",
].join('\n')
}
function sha(bytes) {
return createHash('sha256').update(bytes).digest('hex')
}
function run(command, args) {
const result = spawnSync(command, args, {
encoding: 'utf8',
maxBuffer: 128 * 1024 * 1024,
stdio: ['ignore', 'pipe', 'pipe'],
})
if (result.status !== 0) throw new Error(`${command}_failed:${result.stderr || result.stdout}`)
return result
}

View File

@ -0,0 +1,101 @@
#!/usr/bin/env node
import { createHash } from "node:crypto";
import { spawnSync } from "node:child_process";
import { cp, lstat, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
const here = dirname(fileURLToPath(import.meta.url));
const platformRoot = resolve(here, "../..");
const artifactRoot = resolve(process.env.NODEDC_DEPLOY_ARTIFACT_DIR || resolve(here, "../deploy-artifacts"));
const [transitionId = "20260719-005", ...extra] = process.argv.slice(2);
if (extra.length || !/^\d{8}-[0-9]{3}$/.test(transitionId)) {
throw new Error("usage: build-platform-gelios-provider-v3-artifact.mjs [YYYYMMDD-NNN]");
}
const id = `platform-gelios-provider-v3-${transitionId}`;
const target = join(artifactRoot, `nodedc-${id}.tgz`);
const files = [
"platform/packages/external-provider-contract/providers/gelios/v3/README.md",
"platform/packages/external-provider-contract/providers/gelios/v3/index.mjs",
"platform/packages/external-provider-contract/providers/gelios/v3/package.mjs",
];
const expectedSha256 = new Map([
[files[0], "7ac2318e455786895bebf2e7ee75ae9f86e8958fc5d4699fd0c8c603b8a92ca1"],
[files[1], "dbb82752248fd45c4d3670dadb435123d0575881bfa9458fc0757a421471de24"],
[files[2], "67170084c2d55c3adbed05f1d63c71403689fb59ebc9811e495a55a3c4fa41bf"],
]);
await assertFresh(target);
const stage = await mkdtemp(join(tmpdir(), "nodedc-platform-gelios-provider-v3-"));
const payload = join(stage, "payload");
try {
await mkdir(payload, { recursive: true });
for (const rel of files) {
const source = join(platformRoot, rel.replace(/^platform\//, ""));
const stat = await lstat(source);
if (!stat.isFile() || stat.isSymbolicLink()) throw new Error(`source_boundary_invalid:${rel}`);
const bytes = await readFile(source);
if (sha(bytes) !== expectedSha256.get(rel)) throw new Error(`source_sha256_mismatch:${rel}`);
await mkdir(dirname(join(payload, rel)), { recursive: true });
await cp(source, join(payload, rel), { force: false });
}
await writeFile(join(stage, "manifest.env"), `id=${id}\ncomponent=platform\ntype=app-overlay\n`, "utf8");
await writeFile(join(stage, "files.txt"), `${files.join("\n")}\n`, "utf8");
await mkdir(artifactRoot, { recursive: true });
run("python3", ["-c", canonicalTarScript(), target, stage]);
console.log(JSON.stringify({
ok: true,
id,
artifact: target,
artifactSha256: sha(await readFile(target)),
services: [],
providerPackage: "gelios.provider.v3",
dataProductId: "fleet.positions.current.v2",
runtimeEffect: "catalog-only; no service restart",
files,
}, null, 2));
} finally {
await rm(stage, { recursive: true, force: true });
}
async function assertFresh(path) {
try {
await lstat(path);
} catch (error) {
if (error?.code === "ENOENT") return;
throw error;
}
throw new Error("artifact_already_exists");
}
function canonicalTarScript() {
return [
"import gzip,io,pathlib,sys,tarfile",
"root=pathlib.Path(sys.argv[2])",
"with open(sys.argv[1],'wb') as out:",
" with gzip.GzipFile(filename='',mode='wb',fileobj=out,compresslevel=9,mtime=0) as gz:",
" with tarfile.open(fileobj=gz,mode='w',format=tarfile.PAX_FORMAT) as tar:",
" for top in ('manifest.env','files.txt','payload'):",
" p=root/top; paths=[p]+(sorted(p.rglob('*')) if p.is_dir() else [])",
" for x in paths:",
" info=tar.gettarinfo(str(x),arcname=x.relative_to(root).as_posix())",
" info.uid=info.gid=0; info.uname=info.gname='root'; info.mtime=0; info.mode=0o755 if info.isdir() else 0o644",
" with (open(x,'rb') if info.isfile() else io.BytesIO()) as src: tar.addfile(info,src if info.isfile() else None)",
].join("\n");
}
function sha(bytes) {
return createHash("sha256").update(bytes).digest("hex");
}
function run(command, args) {
const result = spawnSync(command, args, {
encoding: "utf8",
maxBuffer: 128 * 1024 * 1024,
stdio: ["ignore", "pipe", "pipe"],
});
if (result.status !== 0) throw new Error(`${command}_failed:${result.stderr || result.stdout}`);
return result;
}

View File

@ -0,0 +1,104 @@
#!/usr/bin/env node
import { createHash } from "node:crypto";
import { spawnSync } from "node:child_process";
import { cp, lstat, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
const here = dirname(fileURLToPath(import.meta.url));
const platformRoot = resolve(here, "../..");
const artifactRoot = resolve(process.env.NODEDC_DEPLOY_ARTIFACT_DIR || resolve(here, "../deploy-artifacts"));
const [transitionId = "20260720-001", ...extra] = process.argv.slice(2);
if (extra.length || !/^\d{8}-[0-9]{3}$/.test(transitionId)) {
throw new Error("usage: build-platform-gelios-provider-v4-artifact.mjs [YYYYMMDD-NNN]");
}
const id = `platform-gelios-provider-v4-${transitionId}`;
const target = join(artifactRoot, `nodedc-${id}.tgz`);
const files = [
"platform/packages/external-provider-contract/src/provider-package.mjs",
"platform/packages/external-provider-contract/providers/gelios/v4/README.md",
"platform/packages/external-provider-contract/providers/gelios/v4/index.mjs",
"platform/packages/external-provider-contract/providers/gelios/v4/package.mjs",
"platform/services/external-data-plane/definitions/fleet.positions.current.v3.json",
];
const expectedSha256 = new Map([
[files[0], "8ae73cae0be8cbf8484125e1ccf836fdc245b31872a5c7888ced289ed1895ba2"],
[files[1], "9f38f5cb267269b54053fa590a3970abb4b6b9803fc931e997be4293aa6e592a"],
[files[2], "b92fb1fed6ff2fe29c3dc77978e7c35dcdf4c7f2af4ae63ada23e9c3119e7fd9"],
[files[3], "55a46f825e12bcef2354fefd956e674b72fea68b6997df3fcbe89b0a074ec9b8"],
[files[4], "04d458f050313468990849f60d37a8a6c9aadd355137d4084b66556bb4a4b673"],
]);
await assertFresh(target);
const stage = await mkdtemp(join(tmpdir(), "nodedc-platform-gelios-provider-v4-"));
const payload = join(stage, "payload");
try {
await mkdir(payload, { recursive: true });
for (const rel of files) {
const source = join(platformRoot, rel.replace(/^platform\//, ""));
const stat = await lstat(source);
if (!stat.isFile() || stat.isSymbolicLink()) throw new Error(`source_boundary_invalid:${rel}`);
const bytes = await readFile(source);
if (sha(bytes) !== expectedSha256.get(rel)) throw new Error(`source_sha256_mismatch:${rel}`);
await mkdir(dirname(join(payload, rel)), { recursive: true });
await cp(source, join(payload, rel), { force: false });
}
await writeFile(join(stage, "manifest.env"), `id=${id}\ncomponent=platform\ntype=app-overlay\n`, "utf8");
await writeFile(join(stage, "files.txt"), `${files.join("\n")}\n`, "utf8");
await mkdir(artifactRoot, { recursive: true });
run("python3", ["-c", canonicalTarScript(), target, stage]);
console.log(JSON.stringify({
ok: true,
id,
artifact: target,
artifactSha256: sha(await readFile(target)),
services: ["external-data-plane"],
ontologyPackage: "gelios@1.1.0",
providerPackage: "gelios.provider.v4",
dataProductId: "fleet.positions.current.v3",
files,
}, null, 2));
} finally {
await rm(stage, { recursive: true, force: true });
}
async function assertFresh(path) {
try {
await lstat(path);
} catch (error) {
if (error?.code === "ENOENT") return;
throw error;
}
throw new Error("artifact_already_exists");
}
function canonicalTarScript() {
return [
"import gzip,io,pathlib,sys,tarfile",
"root=pathlib.Path(sys.argv[2])",
"with open(sys.argv[1],'wb') as out:",
" with gzip.GzipFile(filename='',mode='wb',fileobj=out,compresslevel=9,mtime=0) as gz:",
" with tarfile.open(fileobj=gz,mode='w',format=tarfile.PAX_FORMAT) as tar:",
" for top in ('manifest.env','files.txt','payload'):",
" p=root/top; paths=[p]+(sorted(p.rglob('*')) if p.is_dir() else [])",
" for x in paths:",
" info=tar.gettarinfo(str(x),arcname=x.relative_to(root).as_posix())",
" info.uid=info.gid=0; info.uname=info.gname='root'; info.mtime=0; info.mode=0o755 if info.isdir() else 0o644",
" with (open(x,'rb') if info.isfile() else io.BytesIO()) as src: tar.addfile(info,src if info.isfile() else None)",
].join("\n");
}
function sha(bytes) {
return createHash("sha256").update(bytes).digest("hex");
}
function run(command, args) {
const result = spawnSync(command, args, {
encoding: "utf8",
maxBuffer: 128 * 1024 * 1024,
stdio: ["ignore", "pipe", "pipe"],
});
if (result.status !== 0) throw new Error(`${command}_failed:${result.stderr || result.stdout}`);
}

View File

@ -0,0 +1,44 @@
#!/usr/bin/env node
import { createHash } from "node:crypto";
import { spawnSync } from "node:child_process";
import { cp, lstat, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { dirname, join, resolve } from "node:path";
import { tmpdir } from "node:os";
import { fileURLToPath } from "node:url";
const scriptDir = dirname(fileURLToPath(import.meta.url));
const platformRoot = resolve(scriptDir, "../..");
const artifactDir = resolve(scriptDir, "../deploy-artifacts");
const [patchId = "cesium-egress-20260715-012", ...extra] = process.argv.slice(2);
if (extra.length || !/^[A-Za-z0-9._-]{1,96}$/.test(patchId)) throw new Error("usage: build-proxy-contur-artifact.mjs [patch-id]");
const files = [
["services/proxy-contur/Dockerfile", "Dockerfile"],
["services/proxy-contur/README.md", "README.md"],
["services/proxy-contur/docker-compose.yml", "docker-compose.yml"],
["services/proxy-contur/package.json", "package.json"],
["services/proxy-contur/server.js", "server.js"],
];
const stage = await mkdtemp(join(tmpdir(), "nodedc-proxy-contur-artifact-"));
const payload = join(stage, "payload");
const target = join(artifactDir, `nodedc-proxy-contur-${patchId}.tgz`);
try {
await mkdir(payload, { recursive: true });
for (const [source, destination] of files) await copySafe(resolve(platformRoot, source), join(payload, destination));
await writeFile(join(stage, "manifest.env"), `id=${patchId}\ncomponent=proxy-contur\ntype=app-overlay\n`, "utf8");
await writeFile(join(stage, "files.txt"), `${files.map(([, destination]) => destination).join("\n")}\n`, "utf8");
await mkdir(artifactDir, { recursive: true });
const tar = spawnSync("python3", ["-c", "import sys,tarfile\nwith tarfile.open(sys.argv[1],'w:gz',format=tarfile.PAX_FORMAT) as a:\n [a.add(n,arcname=n,recursive=True) for n in ('manifest.env','files.txt','payload')]", target], { cwd: stage, encoding: "utf8" });
if (tar.status !== 0) throw new Error(`tar_failed:${tar.stderr || tar.stdout}`);
console.log(JSON.stringify({ ok: true, patchId, artifact: target, sha256: createHash("sha256").update(await readFile(target)).digest("hex") }, null, 2));
} finally {
await rm(stage, { recursive: true, force: true });
}
async function copySafe(source, destination) {
const info = await lstat(source);
if (info.isSymbolicLink() || !info.isFile()) throw new Error(`source_file_rejected:${source}`);
await mkdir(dirname(destination), { recursive: true });
await cp(source, destination, { force: true });
}

View File

@ -0,0 +1,167 @@
#!/usr/bin/env node
import { createHash } from "node:crypto";
import { readFile, writeFile } from "node:fs/promises";
import { resolve } from "node:path";
import {
compileL2ExecutionPlan,
instantiateL2Connection,
} from "../../packages/external-provider-contract/src/index.mjs";
import {
geliosProviderPackageV10,
geliosTelemetryFieldRegistryV3,
} from "../../packages/external-provider-contract/providers/gelios/v10/index.mjs";
const engineRoot = resolve(
process.env.NODEDC_ENGINE_SOURCE_ROOT || "../NODEDC_ENGINE_INFRA",
);
const executionCatalogPath = resolve(
engineRoot,
"nodedc-source/server/assets/execution-plans/v1/catalog.json",
);
const securityCatalogPath = resolve(
engineRoot,
"nodedc-source/server/assets/provider-packages/v1/catalog.json",
);
const executionCatalog = JSON.parse(
await readFile(executionCatalogPath, "utf8"),
);
executionCatalog.runtime.compilerVersions = [
...new Set([...executionCatalog.runtime.compilerVersions, "1.3.0"]),
].sort();
executionCatalog.packages = executionCatalog.packages.filter(
(providerPackage) => providerPackage.id !== geliosProviderPackageV10.id,
);
executionCatalog.packages.push(executionCatalogEntry());
await writeFile(executionCatalogPath, `${JSON.stringify(executionCatalog, null, 2)}\n`);
const securityCatalog = JSON.parse(
await readFile(securityCatalogPath, "utf8"),
);
securityCatalog.packages = securityCatalog.packages.filter(
(providerPackage) => providerPackage.id !== geliosProviderPackageV10.id,
);
securityCatalog.packages.push(securityCatalogEntry());
await writeFile(securityCatalogPath, `${JSON.stringify(securityCatalog, null, 2)}\n`);
console.log(JSON.stringify({
ok: true,
providerPackage: geliosProviderPackageV10.id,
executionCatalogPath,
securityCatalogPath,
}, null, 2));
function executionCatalogEntry() {
const profiles = geliosProviderPackageV10.collectionProfiles.map((profile) => {
const connection = instantiateL2Connection(geliosProviderPackageV10, {
tenantId: "tenant-catalog-build",
connectionId: `catalog-${profile.id.replaceAll(".", "-")}`,
collectionProfileId: profile.id,
providerCredentialRef: "ndc-credref:catalog-build-provider-v10",
});
const plan = compileL2ExecutionPlan(
geliosProviderPackageV10,
connection,
profile.dataProductId === "fleet.positions.current.v5"
? { telemetryFieldRegistry: geliosTelemetryFieldRegistryV3 }
: {},
);
const { packageDigest: _packageDigest, ...artifacts } = plan.artifacts;
return {
id: profile.id,
dataProductId: profile.dataProductId,
capabilityIds: [...profile.capabilityIds],
stepSignatures: plan.steps.map((step) => ({
id: step.id,
kind: step.kind,
...(step.config.capabilityId ? { capabilityId: step.config.capabilityId } : {}),
...(step.config.mappingContractId ? {
mappingContractId: step.config.mappingContractId,
} : {}),
...(step.config.dataProductId ? { dataProductId: step.config.dataProductId } : {}),
...(step.config.nodeType ? { nodeType: step.config.nodeType } : {}),
})),
artifacts,
};
});
return {
id: geliosProviderPackageV10.id,
providerId: geliosProviderPackageV10.providerId,
version: geliosProviderPackageV10.version,
contractDigest: canonicalDigest(geliosProviderPackageV10),
providerCredential: {
authModeId: "gelios.rest-rotating-bearer.v3",
credentialType: "ndcProviderRotatingAccessApi",
},
publisher: {
nodeType: "n8n-nodes-ndc.ndcDataProductPublish",
credentialType: "ndcDataProductWriterApi",
},
capabilities: geliosProviderPackageV10.capabilities.map((capability) => ({
id: capability.id,
contractDigest: canonicalDigest(capability),
requestDigest: canonicalDigest(capability.request),
method: capability.request.method,
url: requestUrl(capability.request),
})),
profiles,
};
}
function securityCatalogEntry() {
const productsByCapability = new Map(
geliosProviderPackageV10.capabilities.map((capability) => [capability.id, new Set()]),
);
for (const profile of geliosProviderPackageV10.collectionProfiles) {
if (profile.dataProductId !== "fleet.units.contacts.current.v1") continue;
for (const capabilityId of profile.capabilityIds) {
productsByCapability.get(capabilityId)?.add(profile.dataProductId);
}
}
return {
id: geliosProviderPackageV10.id,
version: geliosProviderPackageV10.version,
providerId: geliosProviderPackageV10.providerId,
providerCredential: {
authModeId: "gelios.rest-rotating-bearer.v3",
credentialType: "ndcProviderRotatingAccessApi",
},
capabilities: geliosProviderPackageV10.capabilities
.filter((capability) => productsByCapability.get(capability.id)?.size)
.map((capability) => ({
id: capability.id,
classification: capability.classification,
status: capability.status,
request: {
method: capability.request.method,
url: requestUrl(capability.request),
},
dataProductIds: [...productsByCapability.get(capability.id)].sort(),
})),
publisher: {
nodeType: "n8n-nodes-ndc.ndcDataProductPublish",
credentialType: "ndcDataProductWriterApi",
},
};
}
function requestUrl(request) {
const url = new URL(request.path, request.baseUrl);
for (const [name, value] of Object.entries(request.query || {})) {
url.searchParams.append(name, String(value));
}
return url.toString();
}
function canonicalDigest(value) {
return `sha256:${createHash("sha256").update(JSON.stringify(stableValue(value)), "utf8").digest("hex")}`;
}
function stableValue(value) {
if (Array.isArray(value)) return value.map(stableValue);
if (!value || typeof value !== "object") return value;
return Object.fromEntries(
Object.keys(value).sort().map((key) => [key, stableValue(value[key])]),
);
}

View File

@ -0,0 +1,181 @@
#!/usr/bin/env node
import { createHash } from "node:crypto";
import { readFile, writeFile } from "node:fs/promises";
import { resolve } from "node:path";
import {
compileL2ExecutionPlan,
instantiateL2Connection,
} from "../../packages/external-provider-contract/src/index.mjs";
import {
geliosProviderPackageV11,
geliosTelemetryFieldRegistryV4,
} from "../../packages/external-provider-contract/providers/gelios/v11/index.mjs";
const engineRoot = resolve(
process.env.NODEDC_ENGINE_SOURCE_ROOT || "../NODEDC_ENGINE_INFRA",
);
const executionCatalogPath = resolve(
engineRoot,
"nodedc-source/server/assets/execution-plans/v1/catalog.json",
);
const securityCatalogPath = resolve(
engineRoot,
"nodedc-source/server/assets/provider-packages/v1/catalog.json",
);
const executionCatalog = JSON.parse(
await readFile(executionCatalogPath, "utf8"),
);
executionCatalog.runtime.compilerVersions = [
...new Set([...executionCatalog.runtime.compilerVersions, "1.4.0"]),
].sort();
executionCatalog.runtime.derivationKinds = [
...new Set([
...executionCatalog.runtime.derivationKinds,
"bounded_named_values",
"bounded_string_list",
]),
].sort();
executionCatalog.runtime.ruleIds = [
...new Set([
...executionCatalog.runtime.ruleIds,
"array.named_values",
"array.string_values",
]),
].sort();
executionCatalog.packages = executionCatalog.packages.filter(
(providerPackage) => providerPackage.id !== geliosProviderPackageV11.id,
);
executionCatalog.packages.push(executionCatalogEntry());
await writeFile(executionCatalogPath, `${JSON.stringify(executionCatalog, null, 2)}\n`);
const securityCatalog = JSON.parse(
await readFile(securityCatalogPath, "utf8"),
);
securityCatalog.packages = securityCatalog.packages.filter(
(providerPackage) => providerPackage.id !== geliosProviderPackageV11.id,
);
securityCatalog.packages.push(securityCatalogEntry());
await writeFile(securityCatalogPath, `${JSON.stringify(securityCatalog, null, 2)}\n`);
console.log(JSON.stringify({
ok: true,
providerPackage: geliosProviderPackageV11.id,
executionCatalogPath,
securityCatalogPath,
}, null, 2));
function executionCatalogEntry() {
const profiles = geliosProviderPackageV11.collectionProfiles.map((profile) => {
const connection = instantiateL2Connection(geliosProviderPackageV11, {
tenantId: "tenant-catalog-build",
connectionId: `catalog-${profile.id.replaceAll(".", "-")}`,
collectionProfileId: profile.id,
providerCredentialRef: "ndc-credref:catalog-build-provider-v11",
});
const plan = compileL2ExecutionPlan(
geliosProviderPackageV11,
connection,
profile.dataProductId === "fleet.positions.current.v5"
? { telemetryFieldRegistry: geliosTelemetryFieldRegistryV4 }
: {},
);
const { packageDigest: _packageDigest, ...artifacts } = plan.artifacts;
return {
id: profile.id,
dataProductId: profile.dataProductId,
capabilityIds: [...profile.capabilityIds],
stepSignatures: plan.steps.map((step) => ({
id: step.id,
kind: step.kind,
...(step.config.capabilityId ? { capabilityId: step.config.capabilityId } : {}),
...(step.config.mappingContractId ? {
mappingContractId: step.config.mappingContractId,
} : {}),
...(step.config.dataProductId ? { dataProductId: step.config.dataProductId } : {}),
...(step.config.nodeType ? { nodeType: step.config.nodeType } : {}),
})),
artifacts,
};
});
return {
id: geliosProviderPackageV11.id,
providerId: geliosProviderPackageV11.providerId,
version: geliosProviderPackageV11.version,
contractDigest: canonicalDigest(geliosProviderPackageV11),
providerCredential: {
authModeId: "gelios.rest-rotating-bearer.v3",
credentialType: "ndcProviderRotatingAccessApi",
},
publisher: {
nodeType: "n8n-nodes-ndc.ndcDataProductPublish",
credentialType: "ndcDataProductWriterApi",
},
capabilities: geliosProviderPackageV11.capabilities.map((capability) => ({
id: capability.id,
contractDigest: canonicalDigest(capability),
requestDigest: canonicalDigest(capability.request),
method: capability.request.method,
url: requestUrl(capability.request),
})),
profiles,
};
}
function securityCatalogEntry() {
const productsByCapability = new Map(
geliosProviderPackageV11.capabilities.map((capability) => [capability.id, new Set()]),
);
for (const profile of geliosProviderPackageV11.collectionProfiles) {
if (profile.dataProductId !== "fleet.units.identity.current.v1") continue;
for (const capabilityId of profile.capabilityIds) {
productsByCapability.get(capabilityId)?.add(profile.dataProductId);
}
}
return {
id: geliosProviderPackageV11.id,
version: geliosProviderPackageV11.version,
providerId: geliosProviderPackageV11.providerId,
providerCredential: {
authModeId: "gelios.rest-rotating-bearer.v3",
credentialType: "ndcProviderRotatingAccessApi",
},
capabilities: geliosProviderPackageV11.capabilities
.filter((capability) => productsByCapability.get(capability.id)?.size)
.map((capability) => ({
id: capability.id,
classification: capability.classification,
status: capability.status,
request: {
method: capability.request.method,
url: requestUrl(capability.request),
},
dataProductIds: [...productsByCapability.get(capability.id)].sort(),
})),
publisher: {
nodeType: "n8n-nodes-ndc.ndcDataProductPublish",
credentialType: "ndcDataProductWriterApi",
},
};
}
function requestUrl(request) {
const url = new URL(request.path, request.baseUrl);
for (const [name, value] of Object.entries(request.query || {})) {
url.searchParams.append(name, String(value));
}
return url.toString();
}
function canonicalDigest(value) {
return `sha256:${createHash("sha256").update(JSON.stringify(stableValue(value)), "utf8").digest("hex")}`;
}
function stableValue(value) {
if (Array.isArray(value)) return value.map(stableValue);
if (!value || typeof value !== "object") return value;
return Object.fromEntries(
Object.keys(value).sort().map((key) => [key, stableValue(value[key])]),
);
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,35 @@
# Ops MCP workspace tools release
This release updates two already registered deploy-runner components. It does
not add a component and does not change the deploy runner.
Deploy order:
1. Tasker restores full card reads and adds the narrow project-create and
attachment adapter intents. It also exposes the two new scopes in the
existing Tasker agent settings UI.
2. Ops Agent publishes the three MCP tools, mints independent Ops and
Ontology credentials from one setup code, and installs two separate MCP
servers through npm package 0.1.3.
The builder emits, for each artifact:
- a deterministic tgz;
- a predecessor SHA-256 file for every replaced production path;
- a new-path file for paths that must not exist before apply.
Operators must verify both companion files before plan. The deploy runner
then provides the existing component backup, compose rebuild/recreate,
healthcheck, state registration, and rollback behavior.
Security boundaries:
- no raw Tasker API;
- no delete/archive tool;
- project creation requires project:create, an agent-scoped token, existing
workspace entitlement, and Tasker workspace-admin revalidation;
- attachments accept explicit base64 bytes only, max 5 MiB, and use Tasker's
existing FileAsset/S3 quota and dedup path;
- Ops and Ontology tokens have distinct persisted purposes and cannot be used
against the other endpoint;
- Ontology stays a separate read-only MCP.

View File

@ -0,0 +1,20 @@
NODE_ENV=production
HOST=0.0.0.0
PORT=4100
HOST_BIND=172.22.0.222
HOST_PORT=18190
LOG_LEVEL=info
NODEDC_AGENT_GATEWAY_PUBLIC_URL=https://ops-agents.nodedc.ru
NODEDC_AGENT_GATEWAY_INTERNAL_TOKEN=replace-with-strong-gateway-internal-token
NODEDC_AI_WORKSPACE_RUN_TOKEN_TTL_SECONDS=43200
NODEDC_LAUNCHER_INTERNAL_URL=http://172.22.0.222:18080
NODEDC_TASKER_INTERNAL_URL=http://172.22.0.222:18090
NODEDC_ONTOLOGY_CORE_URL=http://172.22.0.222:18104
# Optional. Defaults to NODEDC_INTERNAL_ACCESS_TOKEN.
# NODEDC_ONTOLOGY_CORE_ACCESS_TOKEN=replace-with-platform-internal-access-token
NODEDC_INTERNAL_ACCESS_TOKEN=replace-with-platform-internal-access-token
POSTGRES_DB=nodedc_agent_gateway
POSTGRES_USER=nodedc_agent_gateway
POSTGRES_PASSWORD=replace-with-strong-postgres-password

View File

@ -0,0 +1,215 @@
# NODE.DC Tasker Codex API
Отдельный модуль NODE.DC для безопасного подключения локальных Codex/AI-агентов к Tasker / Operational Core.
Модуль не является частью Plane fork и не должен становиться backend-расширением Tasker. Его роль — agent gateway: выдача ограниченных agent credentials, проверка прав, MCP/REST-контракт для внешних агентов, аудит и маршрутизация разрешённых операций в Tasker через узкий internal adapter.
## Documents
- [Architecture](docs/ARCHITECTURE.md)
- [UX flow](docs/UX_FLOW.md)
- [MCP tools contract](docs/MCP_TOOLS_CONTRACT.md)
- [Tasker API audit](docs/TASKER_API_AUDIT.md)
- [Threat model](docs/THREAT_MODEL.md)
- [Implementation plan](docs/IMPLEMENTATION_PLAN.md)
## Core rule
External Codex instances never receive Plane session cookies, raw Tasker API tokens, database access, or a generic HTTP proxy into Tasker.
All writes go through NODE.DC Agent Gateway, are scoped by agent grants, and are recorded as actions of a dedicated agent identity owned by a human platform user.
## Current implementation
- Fastify service with `/healthz`, `/readyz`, and capability metadata.
- Postgres migrations for agents, grants, token hashes, pairing codes, audit events, and idempotency keys.
- Internal REST endpoints for agent profile, grant, and token lifecycle.
- Lifecycle endpoints are protected by `NODEDC_AGENT_GATEWAY_INTERNAL_TOKEN`; public agent traffic uses only issued agent tokens.
- Opaque agent tokens are generated once and stored only as SHA-256 hashes.
- Authenticated agent-session endpoint returns effective grants/scopes for future MCP calls.
- Agent setup endpoint returns an MCP config template and AGENTS.md instruction pack without echoing the raw token.
- Internal AI Workspace entitlement endpoint issues short-lived run tokens and returns a dynamic Ops MCP server profile for the Platform run profile.
- Product tool endpoints validate agent token, scopes, and project grants before calling Tasker internal adapter.
- MCP JSON-RPC endpoint `/mcp` exposes the same tool runtime as REST product endpoints.
- A separate `/ontology-mcp` endpoint exposes the read-only Ontology MCP with a
different token purpose; the npm installer configures both servers independently.
- The Ops tool surface includes full issue reading (comments and attachment
metadata), workspace-gated project creation, and bounded base64 attachment upload.
- Write tools require idempotency keys and replay successful duplicate requests without creating duplicate Tasker writes.
- Agent Gateway writes audit events for executed, replayed, and failed write-tool calls.
- Tool execution calls the real Tasker internal adapter; no fake Tasker storage exists in Gateway.
- Local real e2e smoke verifies Gateway -> MCP -> Tasker runtime writes.
## Local development
Product-like Docker run:
```bash
cp .env.example .env
docker compose --env-file .env -f docker-compose.local.yml up -d --build
curl http://127.0.0.1:4100/readyz
```
The `agent-gateway` container waits for local Postgres, runs migrations on startup, and exposes the same `:4100` internal endpoint used by Tasker (`PLANE_NODEDC_AGENT_GATEWAY_URL=http://host.docker.internal:4100` in local development). `HOST_BIND` and `HOST_PORT` control the host-side port for reverse proxy deployments; Synology should use `docker-compose.synology.yml` with `172.22.0.222:18190:4100` because `18090` is reserved for Tasker. The user-facing setup packet uses `NODEDC_AGENT_GATEWAY_PUBLIC_URL`; product defaults point to `https://ops-agents.nodedc.ru`, not localhost.
Synology deployment notes live in `docs/SYNOLOGY_DEPLOY.md`.
Direct Node.js development:
```bash
cp .env.example .env
docker compose --env-file .env -f docker-compose.local.yml up -d postgres
npm install
npm run migrate
npm run dev
```
Useful checks:
```bash
npm run check
npm run build
npm run smoke:mcp
npm run smoke:gateway
curl http://127.0.0.1:4100/readyz
curl http://127.0.0.1:4100/api/v1/meta/capabilities
```
Create a local test agent:
```bash
curl -X POST http://127.0.0.1:4100/api/v1/agents \
-H "Authorization: Bearer $NODEDC_AGENT_GATEWAY_INTERNAL_TOKEN" \
-H 'Content-Type: application/json' \
-d '{"owner_user_id":"local-user","owner_email":"local@example.test","display_name":"Local Codex"}'
```
Create a token and inspect effective agent session:
```bash
TOKEN=$(curl -sS -X POST http://127.0.0.1:4100/api/v1/agents/<agent-id>/tokens \
-H "Authorization: Bearer $NODEDC_AGENT_GATEWAY_INTERNAL_TOKEN" \
-H 'Content-Type: application/json' \
-d '{"name":"Local Codex token"}' | jq -r .token)
curl http://127.0.0.1:4100/api/v1/agent-session \
-H "Authorization: Bearer $TOKEN"
```
Tasker UI should use the owner-scoped internal lifecycle API through its backend proxy:
```bash
curl http://127.0.0.1:4100/api/internal/v1/owners/<owner-user-id>/agents \
-H "Authorization: Bearer $NODEDC_AGENT_GATEWAY_INTERNAL_TOKEN"
```
The internal API verifies the owner path against the stored agent owner before returning agent detail, grants, tokens, setup packets, or revoke responses.
Issue an AI Workspace entitlement for a Codex run:
```bash
curl -X POST http://127.0.0.1:4100/api/internal/v1/ai-workspace/entitlements \
-H "Authorization: Bearer $NODEDC_AGENT_GATEWAY_INTERNAL_TOKEN" \
-H 'Content-Type: application/json' \
-d '{
"schemaVersion":"ai-workspace.entitlement-request.v1",
"appId":"ops",
"owner":{"userId":"local-user","email":"local@example.test"},
"runContext":{"ops":{"workspaceSlug":"nodedc","projectId":"<project-id>"}}
}'
```
The response uses the AI Workspace adapter contract and returns `appGrants.ops.mcpServers[]` with a bearer token narrowed to the matching active agent grant(s) for the requested Ops workspace/project. The token expires after `NODEDC_AI_WORKSPACE_RUN_TOKEN_TTL_SECONDS` seconds, is marked as `grant_scope=token`, and the response reports `grantMode=token-scoped-run-grants`. Existing long-lived agent tokens keep the legacy `grant_scope=agent` behavior for manually installed local Codex clients.
Run the same check without issuing a token:
```bash
curl -X POST http://127.0.0.1:4100/api/internal/v1/ai-workspace/preflight \
-H "Authorization: Bearer $NODEDC_AGENT_GATEWAY_INTERNAL_TOKEN" \
-H 'Content-Type: application/json' \
-d '{
"schemaVersion":"ai-workspace.entitlement-request.v1",
"appId":"ops",
"owner":{"userId":"local-user","email":"local@example.test"},
"runContext":{"ops":{"workspaceSlug":"nodedc","projectId":"<project-id>"}}
}'
```
Preflight returns redacted diagnostics: selected agent, matching grants, scopes, `grantMode`, token TTL, and MCP endpoint metadata. It does not create or return a bearer token.
Generate a local Codex setup packet:
```bash
curl http://127.0.0.1:4100/api/v1/agent-session/setup \
-H "Authorization: Bearer $TOKEN" | jq -r .setup.agents_md
```
The setup packet includes:
- MCP endpoint and header template with `<agent-token>` placeholder;
- available tools for the current grants;
- AGENTS.md rules for Tasker card writing, no-delete boundary, and required `idempotency_key`;
- no raw token echo.
Call MCP tools through the JSON-RPC endpoint:
```bash
curl -sS http://127.0.0.1:4100/mcp \
-H 'Content-Type: application/json' \
-H 'Accept: application/json' \
-H 'MCP-Protocol-Version: 2025-06-18' \
-H "Authorization: Bearer $TOKEN" \
-d '{"jsonrpc":"2.0","id":"tools","method":"tools/list","params":{}}' | jq
```
## Local testing strategy
No fake Tasker storage is embedded into Agent Gateway.
Local verification is split into product layers:
1. `npm run smoke:mcp` verifies MCP initialize, tool listing, bearer token auth, scope checks, grant checks, idempotency requirement, and the Tasker boundary.
2. `npm run smoke:gateway` verifies the REST compatibility boundary and idempotency requirement over the same tool execution path.
3. `npm run smoke:e2e` verifies REST tool endpoints and idempotent replay against the real local Tasker runtime.
4. `npm run smoke:mcp:e2e` verifies MCP tool calls and idempotent replay against the real local Tasker runtime.
5. External-machine testing uses the same token and endpoint shape against staging HTTPS; no extra protocol or fake environment should be introduced.
Example real localhost MCP e2e:
```bash
TOKEN=$(python3 - <<'PY'
from pathlib import Path
for line in Path('/Users/dcconstructions/Downloads/mnt/data/dc_taskmanager/NODEDC_TASKMANAGER/plane-app/plane.env').read_text().splitlines():
if line.startswith('NODEDC_INTERNAL_ACCESS_TOKEN=') or line.startswith('PLANE_NODEDC_ACCESS_TOKEN='):
value = line.split('=', 1)[1].strip().strip('"').strip("'")
if value:
print(value)
break
PY
)
DATABASE_URL='postgres://nodedc_agent_gateway:replace-with-local-postgres-password@localhost:54100/nodedc_agent_gateway' \
NODE_ENV=development \
LOG_LEVEL=silent \
NODEDC_TASKER_INTERNAL_URL='http://localhost:8090' \
NODEDC_AGENT_GATEWAY_INTERNAL_TOKEN='replace-with-gateway-internal-token' \
NODEDC_INTERNAL_ACCESS_TOKEN="$TOKEN" \
SMOKE_WORKSPACE_SLUG='nodedc' \
SMOKE_PROJECT_ID='<project-id>' \
npm run smoke:mcp:e2e
```
Current Tasker internal adapter contract expected by Gateway:
- `POST /api/internal/nodedc/agent/projects/resolve`
- `POST /api/internal/nodedc/agent/projects`
- `GET /api/internal/nodedc/agent/projects/:projectId/context`
- `GET /api/internal/nodedc/agent/issues?project_id=...`
- `POST /api/internal/nodedc/agent/issues`
- `GET /api/internal/nodedc/agent/issues/:issueId`
- `PATCH /api/internal/nodedc/agent/issues/:issueId`
- `POST /api/internal/nodedc/agent/issues/:issueId/attachments`
- `POST /api/internal/nodedc/agent/issues/:issueId/move`
- `POST /api/internal/nodedc/agent/issues/:issueId/comments`
- `PUT /api/internal/nodedc/agent/issues/:issueId/labels`
- `PUT /api/internal/nodedc/agent/issues/:issueId/assignees`

View File

@ -0,0 +1,53 @@
services:
postgres:
image: postgres:17-alpine
environment:
POSTGRES_DB: ${POSTGRES_DB:-nodedc_agent_gateway}
POSTGRES_USER: ${POSTGRES_USER:-nodedc_agent_gateway}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-replace-with-strong-postgres-password}
volumes:
- agent-gateway-postgres:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}"]
interval: 5s
timeout: 5s
retries: 10
agent-gateway:
build:
context: .
init: true
environment:
NODE_ENV: ${NODE_ENV:-production}
HOST: 0.0.0.0
PORT: ${PORT:-4100}
LOG_LEVEL: ${LOG_LEVEL:-info}
DATABASE_URL: postgres://${POSTGRES_USER:-nodedc_agent_gateway}:${POSTGRES_PASSWORD:-replace-with-strong-postgres-password}@postgres:5432/${POSTGRES_DB:-nodedc_agent_gateway}
NODEDC_AGENT_GATEWAY_PUBLIC_URL: ${NODEDC_AGENT_GATEWAY_PUBLIC_URL:-https://ops-agents.nodedc.ru}
NODEDC_AGENT_GATEWAY_INTERNAL_TOKEN: ${NODEDC_AGENT_GATEWAY_INTERNAL_TOKEN}
NODEDC_OPS_CODEX_INSTALL_CHANNEL: ${NODEDC_OPS_CODEX_INSTALL_CHANNEL:-registry}
NODEDC_OPS_CODEX_NPM_SPEC: ${NODEDC_OPS_CODEX_NPM_SPEC:-@nodedc/ops-codex}
NODEDC_AI_WORKSPACE_RUN_TOKEN_TTL_SECONDS: ${NODEDC_AI_WORKSPACE_RUN_TOKEN_TTL_SECONDS:-43200}
NODEDC_LAUNCHER_INTERNAL_URL: ${NODEDC_LAUNCHER_INTERNAL_URL:-http://172.22.0.222:18080}
NODEDC_TASKER_INTERNAL_URL: ${NODEDC_TASKER_INTERNAL_URL:-http://172.22.0.222:18090}
NODEDC_ENGINE_INTERNAL_URL: ${NODEDC_ENGINE_INTERNAL_URL:-http://172.22.0.222:3001}
NODEDC_ONTOLOGY_CORE_URL: ${NODEDC_ONTOLOGY_CORE_URL:-http://172.22.0.222:18104}
NODEDC_ONTOLOGY_CORE_ACCESS_TOKEN: ${NODEDC_ONTOLOGY_CORE_ACCESS_TOKEN:-}
NODEDC_INTERNAL_ACCESS_TOKEN: ${NODEDC_INTERNAL_ACCESS_TOKEN}
depends_on:
postgres:
condition: service_healthy
ports:
- "${HOST_BIND:-172.22.0.222}:${HOST_PORT:-18190}:${PORT:-4100}"
healthcheck:
test:
[
"CMD-SHELL",
"node -e \"fetch('http://127.0.0.1:' + (process.env.PORT || 4100) + '/readyz').then(async r => { const b = await r.json(); process.exit(r.ok && b.ok ? 0 : 1); }).catch(() => process.exit(1))\"",
]
interval: 10s
timeout: 5s
retries: 10
volumes:
agent-gateway-postgres:

View File

@ -0,0 +1,280 @@
# Architecture: NODE.DC Tasker Codex API
Last updated: 2026-07-18.
## Goal
Give selected NODE.DC users a controlled way to connect their local Codex or another AI coding agent to Tasker so the agent can maintain project work items according to NODE.DC task-card rules.
The agent can create projects inside an explicitly granted workspace, create and
update cards, move them through states, write structured task documentation,
update checkers, add comments and bounded file attachments, assign existing
workspace users to project cards when permitted, and apply labels. It cannot
delete cards or projects, manage workspace settings, bypass workspace/project
grants, or call arbitrary Tasker endpoints.
## Product boundary
This module is a standalone service:
```text
Launcher / Hub
owns platform entitlement: can this user/client use Codex agents?
Tasker / Operational Core
owns workspaces, projects, issues, labels, states, members, comments
Agent Gateway
owns agents, tokens, scopes, grants, MCP tools, audit, rate limits
```
The service is deployed as its own Docker container and has its own repository and database. It may call Launcher and Tasker through internal APIs, but it does not read or write either database directly.
## Runtime shape
```text
Local Codex
-> independent Ops and read-only Ontology MCP servers over HTTPS
-> Agent Gateway public endpoints (/mcp and /ontology-mcp)
-> scope and grant checks
-> Tasker internal adapter
-> Tasker domain models
```
```text
User UI
-> Launcher entitlement
-> Tasker workspace feature settings
-> Agent Gateway agent/token management
```
## Components
### Launcher integration
Launcher decides whether a user can use the module at all.
Required Launcher concepts:
- service: `operational-core`
- module entitlement: `codex_agents`
- scope owner: client/user/access matrix
- open contour support
- enterprise contour support
Launcher should not create low-level Tasker cards or tokens. It only projects entitlement and global lifecycle state.
### Tasker integration
Tasker shows the feature in `Workspace settings -> Features` only when:
- the current user has Tasker access;
- Launcher access check says the user has `codex_agents`;
- the workspace policy allows this workspace to host agents.
Tasker remains the owner of issues, states, labels, project members, comments, and structured blocks.
### Agent Gateway
Agent Gateway owns:
- agent profiles;
- pairing codes;
- opaque access tokens;
- token hashes;
- grants;
- scopes;
- audit events;
- idempotency keys;
- rate limits;
- MCP tools exposed to local Codex.
It should not execute user code and should not run Codex itself.
Agent lifecycle management is not public. Tasker/Launcher-facing management calls use `NODEDC_AGENT_GATEWAY_INTERNAL_TOKEN`, while external Codex calls use only the opaque agent token issued for one agent. The owner-scoped internal routes verify that the requested agent belongs to the requested `owner_user_id`.
### Tasker internal adapter
The adapter is a narrow Tasker API layer for Agent Gateway. It exists because the current Plane REST API is broad and includes operations the agent must not receive directly.
The adapter should expose intent-level operations:
- list allowed workspaces/projects;
- read project states, labels, members;
- create a project within a granted workspace after Tasker admin revalidation;
- search/list issues;
- read one issue with full comments and attachment metadata;
- create issue;
- patch allowed issue fields;
- append comment;
- attach a bounded base64 file through Tasker-owned object storage;
- update NODE.DC structured blocks;
- add existing workspace member to project when explicitly allowed;
- publish audit/realtime events.
It must reject delete, archive, workspace settings, project deletion, invite
creation, arbitrary path/URL attachments, and arbitrary path proxying.
## Data model
Initial Agent Gateway entities:
```text
agent
id
owner_user_id
owner_email
display_name
avatar_url
status: active | disabled | revoked
created_at
updated_at
agent_token
id
agent_id
token_hash
name
purpose: ops | ontology
status: active | revoked | expired
expires_at
last_used_at
created_at
agent_grant
id
agent_id
workspace_slug
project_id (empty string internally means workspace-level grant)
scopes[]
mode: voluntary | reporting
created_by_user_id
created_at
updated_at
pairing_code
id
agent_id
code_hash
status: active | used | expired | revoked
expires_at
created_at
used_at
agent_audit_event
id
agent_id
event_type
actor_user_id
metadata
created_at
idempotency_key
key
agent_id
request_hash
response_body
created_at
expires_at
```
## Actor model
Writes should be visible as agent actions, not anonymous platform automation.
Recommended Tasker-side identity:
```text
display_name: Codex Agent: <name>
email: agent+<agent_id>@agents.nodedc.local
is_bot: true
owner_user_id: <human user id>
```
For audit and UI, every write must preserve both:
- `agent_id`;
- `owner_user_id`.
If Tasker cannot yet represent owned bot users cleanly, the adapter can initially use a system actor and store the agent metadata in audit payloads, but this should be treated as a migration step, not the final model.
## Capability model
Allowed initial scopes:
```text
workspace:read
project:read
project:create
project:member:add_existing
issue:read
issue:create
issue:update
issue:move
issue:comment
issue:label
issue:assign
issue:structured_blocks:write
issue:attachment:write
```
Explicitly denied for MVP:
```text
issue:delete
issue:archive
comment:delete
label:delete
state:create
state:delete
project:delete
workspace:settings
workspace:member:invite
workspace:member:remove
raw_tasker_api
```
Deleting or archiving a card remains a human-only operation.
## Two operating modes
### Voluntary mode
The user creates an agent and gives its token to their local Codex. Codex updates Tasker when the user asks it to.
This mode is user-owned and best for personal development workflow.
### Reporting mode
The enterprise/project admin requires agent reporting for a project. The developer still runs local Codex, but the expected workflow is:
- start work session;
- update active issue/checker;
- append implementation notes;
- finish work session.
This mode is policy-visible but cannot be fully enforced if Codex runs entirely on the developer machine outside a managed wrapper. The system can enforce token scope and show stale/missing reports, but it cannot force an arbitrary local agent to report unless the organization distributes a managed Codex config or wrapper.
## Deployment
Initial local target:
```text
ops-agents.local.nodedc -> Agent Gateway
```
Production-like domains should follow platform conventions:
```text
ops-agents.nodedc.ru or ops-agents.<deployment-domain>
```
The service should support:
- local `.env`;
- staging `.env.staging`;
- production secret store;
- Docker image build;
- container startup migrations;
- health endpoint;
- preflight script validating URLs/secrets.

View File

@ -0,0 +1,418 @@
# MCP Tools Contract
Last updated: 2026-07-18.
## Position
The current Tasker / Plane fork does not expose a dedicated MCP server. It exposes REST endpoints and NODE.DC internal endpoints. The new module should expose MCP tools externally and translate those tool calls into validated Tasker adapter calls.
Codex should not call generic Tasker REST directly.
Current implementation status:
- Agent Gateway exposes `/mcp` as JSON-RPC over HTTP.
- Implemented MCP methods: `initialize`, `ping`, `tools/list`, `tools/call`.
- `tools/list` returns only tools allowed by the authenticated agent session scopes.
- `tools/call` uses the same product runtime as REST tool endpoints.
- Server-sent event streaming is intentionally not required for the first product slice.
## Authentication
MCP clients authenticate to Agent Gateway with an opaque agent token.
Recommended transport options:
- HTTPS MCP endpoint for general clients;
- local stdio connector later if useful;
- REST fallback for non-MCP clients.
Current route:
```text
POST /mcp
POST /ontology-mcp
```
`/mcp` accepts only an Ops-purpose token. `/ontology-mcp` accepts only the
separately minted Ontology-purpose token and proxies the read-only Ontology MCP.
The two endpoints, credentials, and MCP server entries remain independent.
Required request headers for authenticated tool calls:
```text
Authorization: Bearer <agent-token>
Accept: application/json
MCP-Protocol-Version: 2025-06-18
```
Token rules:
- token is opaque;
- server stores only token hash;
- token is scoped to one agent;
- token can be revoked immediately;
- token expires;
- owner blocked/annulled disables tokens.
## Tool list
### `tasker_get_agent_instructions`
Returns operating rules, allowed projects, card guide, and reporting expectations.
Required scope:
```text
workspace:read
```
### `tasker_list_projects`
Lists projects granted to the agent.
Required scope:
```text
project:read
```
### `tasker_get_project_context`
Returns project states, labels, members, and card-writing rules.
Required scope:
```text
project:read
```
### `tasker_search_issues`
Searches existing work items within granted projects.
Required scope:
```text
issue:read
```
### `tasker_get_issue`
Returns one issue with description, structured blocks, labels, state, assignees,
full comment bodies, and attachment metadata. The Tasker adapter caps comments at
1000 and reports truncation explicitly.
Required scope:
```text
issue:read
```
### `tasker_create_project`
Creates or idempotently resolves a project inside an explicitly granted
workspace. Only a long-lived agent-scoped session can call it; token-scoped run
grants cannot expand themselves. Tasker revalidates that the human owner is an
active workspace administrator before creating the project. The new project is
then added to the same agent's grants so subsequent card tools can address it.
Required scope:
```text
project:create
```
Allowed fields:
```text
workspace_slug
name
identifier (optional)
description (optional)
idempotency_key
```
### `tasker_create_issue`
Creates a work item.
Required scope:
```text
issue:create
```
Allowed fields:
```text
project_id
name
description_html
detail_layout
state_id
priority
label_ids
assignee_ids
start_date
target_date
parent_id
```
Validation:
- project must be granted;
- state must belong to project;
- labels must belong to project;
- assignees must be active project members;
- detail layout must match NODE.DC structured block schema.
### `tasker_update_issue`
Patches allowed issue fields.
Required scope:
```text
issue:update
```
Allowed fields:
```text
name
description_html
detail_layout
priority
start_date
target_date
parent_id
```
Deletion, archive, and project transfer are not allowed.
### `tasker_move_issue`
Changes issue state and optional sort order.
Required scope:
```text
issue:move
```
Allowed fields:
```text
state_id
sort_order
```
Validation:
- target state must belong to the same project;
- cancelled/completed moves are allowed only if the grant permits them.
### `tasker_set_issue_labels`
Sets or merges labels.
Required scope:
```text
issue:label
```
Validation:
- labels must already exist in project for MVP;
- creating new labels can be added later under `label:create`.
### `tasker_assign_issue`
Sets assignees.
Required scope:
```text
issue:assign
```
Validation:
- assignees must be active project members;
- if the user asks to add a workspace member to the project, use `tasker_add_existing_project_member` first.
### `tasker_add_existing_project_member`
Adds an existing workspace member to a granted project.
Required scope:
```text
project:member:add_existing
```
Validation:
- target user must already be an active workspace member;
- target user role cannot exceed workspace role;
- launcher-managed workspace rules must be respected;
- the request should be traceable to an explicit human instruction or reporting policy.
### `tasker_append_comment`
Adds a comment to an issue.
Required scope:
```text
issue:comment
```
Allowed fields:
```text
comment_html
```
### `tasker_attach_file`
Uploads a bounded file to an issue through Tasker's existing FileAsset and
S3/MinIO storage path. The MCP accepts base64 content only: it never reads a
client or server filesystem path and never fetches a remote URL. Raw content is
limited to 5 MiB and Tasker rechecks MIME allowlists, workspace storage limits,
project quota, and content deduplication.
Required scope:
```text
issue:attachment:write
```
Allowed fields:
```text
project_id
issue_id
file_name
content_type
content_base64
idempotency_key
```
### `tasker_update_structured_blocks`
Replaces or patches NODE.DC structured blocks in `detail_layout`.
Required scope:
```text
issue:structured_blocks:write
```
Supported blocks:
```text
text
checker
```
Canonical key:
```text
nodedc_structured_blocks
```
The tool should support high-level patch actions:
```text
append_text_block
append_checker
update_checker_item
replace_blocks
append_implementation_note
```
### `tasker_start_work_session`
Starts a reporting session for a project or issue.
Required scope:
```text
issue:comment
```
This is mainly for enterprise reporting mode.
### `tasker_finish_work_session`
Finishes a reporting session and appends summary/validation notes.
Required scope:
```text
issue:comment
issue:structured_blocks:write
```
## Idempotency
All write tools must accept:
```text
idempotency_key
```
Agent Gateway requires this value for all write tools. The key is scoped by agent, hashed together with the tool name and normalized arguments, and stored with a processing/completed state.
Behavior:
- first successful request stores the tool result for 24 hours;
- duplicate key with identical arguments returns the stored result and does not call Tasker again;
- duplicate key with different arguments returns `idempotency_key_conflict`;
- duplicate key while the first request is still processing returns `idempotency_key_in_progress`;
- failed writes release the key so the same operation can be retried.
## Denied tools
These should not exist in MVP:
```text
tasker_delete_issue
tasker_archive_issue
tasker_delete_comment
tasker_delete_label
tasker_delete_project
tasker_invite_workspace_member
tasker_raw_api_request
tasker_attach_server_path
tasker_attach_remote_url
```
## Instruction pack
`tasker_get_agent_instructions` should include the effective card guide. The local Codex setup file should instruct the agent to call this tool before planning Tasker changes.
Agent Gateway also exposes:
```text
GET /api/v1/agent-session/setup
```
This endpoint is authenticated by the same bearer token and returns:
- MCP server config template with `<agent-token>` placeholder;
- tools available to the current agent grants;
- generated AGENTS.md content with NODE.DC Tasker operating rules;
- no raw token echo.
Minimum instruction:
```text
Before creating or updating Tasker cards, call tasker_get_agent_instructions.
Use Tasker for durable project planning, current architecture, planned architecture, stage checkers, implementation notes, and validation results.
Do not create multiple top-level cards for substeps of one product topic.
Do not delete or archive cards.
Do not operate outside granted projects.
```

View File

@ -0,0 +1,217 @@
# Tasker API Audit
Last updated: 2026-05-14.
## Summary
Current Tasker / Plane fork is partially ready for the Codex Agent API use case through existing REST endpoints and NODE.DC structured blocks. It is not ready as a direct external API for agents because it has broad routes, session-oriented permissions, delete/archive endpoints, and no MCP layer.
The correct approach is to add a narrow internal Tasker adapter for Agent Gateway instead of exposing raw Plane API to local Codex.
## Existing useful API surface
### Issues
Routes exist for issue list/create/update/retrieve:
```text
GET /api/workspaces/:slug/projects/:project_id/issues/
POST /api/workspaces/:slug/projects/:project_id/issues/
GET /api/workspaces/:slug/projects/:project_id/issues/:issue_id/
PATCH /api/workspaces/:slug/projects/:project_id/issues/:issue_id/
```
The same route also supports `DELETE`, but Agent Gateway must never expose it.
Existing serializers already support:
- name;
- state;
- priority;
- dates;
- labels;
- assignees;
- parent issue;
- description HTML;
- `detail_layout`.
Validation already checks:
- assignees are active project members with sufficient role;
- labels belong to project;
- state belongs to project;
- parent belongs to workspace/project;
- description HTML is sanitized.
### Structured blocks
NODE.DC structured task content lives in:
```text
Issue.detail_layout["nodedc_structured_blocks"]
```
Known block types:
```text
text
checker
```
This is the right storage layer for:
- current architecture;
- planned architecture;
- stages;
- checkers;
- implementation notes.
Tasker already computes checker progress from this structure.
### Comments
Routes exist:
```text
GET /api/workspaces/:slug/projects/:project_id/issues/:issue_id/comments/
POST /api/workspaces/:slug/projects/:project_id/issues/:issue_id/comments/
PATCH /api/workspaces/:slug/projects/:project_id/issues/:issue_id/comments/:comment_id/
DELETE /api/workspaces/:slug/projects/:project_id/issues/:issue_id/comments/:comment_id/
```
Agent Gateway should expose comment creation and possibly own-comment edit later, but not comment deletion.
### Labels
Routes exist:
```text
GET /api/workspaces/:slug/projects/:project_id/issue-labels/
POST /api/workspaces/:slug/projects/:project_id/issue-labels/
PATCH /api/workspaces/:slug/projects/:project_id/issue-labels/:label_id/
DELETE /api/workspaces/:slug/projects/:project_id/issue-labels/:label_id/
```
MVP should let agents apply existing labels. Creating labels can be added later under an explicit admin scope.
### States
Routes exist:
```text
GET /api/workspaces/:slug/projects/:project_id/states/
PATCH /api/workspaces/:slug/projects/:project_id/states/:state_id/
```
Agent Gateway should allow moving issues to existing states. It should not allow state creation/deletion in MVP.
### Project members
Routes exist:
```text
GET /api/workspaces/:slug/projects/:project_id/members/
POST /api/workspaces/:slug/projects/:project_id/members/
PATCH /api/workspaces/:slug/projects/:project_id/members/:member_id/
DELETE /api/workspaces/:slug/projects/:project_id/members/:member_id/
```
Current code checks workspace membership and blocks launcher-managed workspace self-management.
Agent Gateway may expose `add_existing_project_member` only with an explicit scope and only for existing workspace members.
## Missing MCP layer
No dedicated MCP server exists in Tasker today. References to MCP are documentation/guideline-oriented, not an operational API server.
Required new layer:
```text
Agent Gateway MCP tools -> Agent Gateway service -> Tasker internal adapter
```
Tasker should not become the MCP host. Keeping MCP in Agent Gateway preserves standalone Tasker and keeps the external agent surface outside Plane.
## Required Tasker adapter additions
Add internal endpoints under a namespace such as:
```text
/api/internal/nodedc/agent/...
```
Current implemented adapter routes:
```text
POST /api/internal/nodedc/agent/projects/resolve
POST /api/internal/nodedc/agent/projects
GET /api/internal/nodedc/agent/projects/:project_id/context
GET /api/internal/nodedc/agent/issues?project_id=...
POST /api/internal/nodedc/agent/issues
GET /api/internal/nodedc/agent/issues/:issue_id
PATCH /api/internal/nodedc/agent/issues/:issue_id
POST /api/internal/nodedc/agent/issues/:issue_id/attachments
POST /api/internal/nodedc/agent/issues/:issue_id/move
POST /api/internal/nodedc/agent/issues/:issue_id/comments
PUT /api/internal/nodedc/agent/issues/:issue_id/labels
PUT /api/internal/nodedc/agent/issues/:issue_id/assignees
```
The implemented adapter uses NODE.DC internal bearer auth and receives normalized agent metadata in headers:
```text
X-NODEDC-Agent-Id
X-NODEDC-Agent-Owner-User-Id
X-NODEDC-Agent-Token-Id
```
The current adapter creates or reuses a dedicated bot actor with email `agent+<agent_id>@agents.nodedc.local` and `bot_type=nodedc_codex_agent`.
Issue detail returns full comment bodies plus attachment metadata. Project
creation is workspace-admin gated and idempotent by agent external id.
Attachment upload uses Plane's existing FileAsset, S3/MinIO, quota, MIME, and
deduplication implementation; it accepts no server path or remote URL.
These endpoints must use `NODEDC_INTERNAL_ACCESS_TOKEN` / `PLANE_NODEDC_ACCESS_TOKEN` style auth and must be callable only from Agent Gateway.
Suggested adapter endpoints:
```text
POST /api/internal/nodedc/agent/context/
POST /api/internal/nodedc/agent/issues/search/
POST /api/internal/nodedc/agent/issues/
PATCH /api/internal/nodedc/agent/issues/:issue_id/
POST /api/internal/nodedc/agent/issues/:issue_id/comments/
POST /api/internal/nodedc/agent/issues/:issue_id/structured-blocks/
POST /api/internal/nodedc/agent/projects/:project_id/members/add-existing/
```
The adapter should receive normalized Agent Gateway metadata:
```text
agent_id
owner_user_id
owner_email
workspace_slug
project_id
scopes
idempotency_key
```
The adapter should validate Tasker domain rules and return stable errors rather than leaking raw Plane serializer details.
## Why raw Plane API is not enough
Raw Plane API is too broad for external agents:
- it includes delete/archive routes;
- it assumes a session user, not an external agent identity;
- it has more fields than agents should control;
- it does not know Launcher module entitlements;
- it does not have agent idempotency;
- it does not produce agent-specific audit by default;
- it is not MCP-native.
## Compatibility note
Tasker must remain standalone-capable. All agent-specific behavior should be disabled when NODE.DC Agent Gateway env vars are absent.

View File

@ -0,0 +1,157 @@
# Threat Model
Last updated: 2026-07-18.
## Security objective
Let external local Codex agents maintain Tasker cards without turning Tasker into an open automation surface.
## Main threats
### Raw Tasker access leakage
Risk: a user copies a broad Tasker token or cookie into local Codex, allowing arbitrary API calls.
Mitigation:
- never issue Plane session cookies to agents;
- never expose raw Tasker API tokens;
- use opaque Agent Gateway tokens;
- only expose allowlisted MCP tools.
### Project scope escape
Risk: an agent writes to another project or workspace.
Mitigation:
- Agent Gateway grants are project-scoped;
- Tasker adapter revalidates workspace/project membership;
- every tool requires explicit `project_id`;
- gateway rejects projects outside grant set.
- project creation requires `project:create` on a workspace grant, an
agent-scoped token, and a second workspace-admin check inside Tasker;
- token-scoped AI Workspace sessions cannot add projects to their own grants.
### Attachment exfiltration or storage abuse
Risk: an agent reads arbitrary host files, fetches a remote payload, or bypasses
Tasker storage quotas through the attachment tool.
Mitigation:
- MCP accepts explicit base64 bytes only, never filesystem paths or remote URLs;
- Agent Gateway limits decoded content to 5 MiB and never logs the base64 body;
- Tasker enforces MIME allowlists, workspace storage limits, project quota,
S3/MinIO ownership, and content deduplication.
### Cross-service token confusion
Risk: an Ops token is replayed against Ontology or an Ontology token gains Tasker
write capabilities.
Mitigation:
- setup redemption mints two independent opaque token records;
- token purpose is persisted and checked on every endpoint;
- `/mcp` accepts only `ops`, `/ontology-mcp` only `ontology`;
- Ontology upstream access token remains server-side.
### Destructive action
Risk: an agent deletes or archives cards, labels, comments, projects, or members.
Mitigation:
- no delete/archive MCP tools in MVP;
- adapter rejects delete/archive intents;
- raw API proxy is forbidden.
### Privilege confusion
Risk: an agent acts as the human user and hides automation history.
Mitigation:
- create dedicated agent identity;
- store owner user separately;
- every audit event includes both `agent_id` and `owner_user_id`;
- UI displays agent-originated changes.
### Prompt injection
Risk: text inside a card tells Codex to exfiltrate token or call forbidden tools.
Mitigation:
- MCP tools enforce server-side scopes;
- instruction pack says Tasker content is untrusted;
- Gateway never exposes secrets through read tools;
- deny arbitrary HTTP fetch/proxy tools.
### Token theft
Risk: local token leaks from developer machine.
Mitigation:
- token hash storage;
- expiry;
- immediate revoke;
- last used metadata;
- rate limits;
- optional IP/device binding later.
### Lifecycle API exposure
Risk: an external caller creates agents, grants projects, or mints tokens without going through Launcher/Tasker entitlement.
Mitigation:
- lifecycle routes require `NODEDC_AGENT_GATEWAY_INTERNAL_TOKEN`;
- owner-scoped routes verify `owner_user_id` against the stored agent owner;
- external Codex tokens can call only agent-session, setup, tool, and MCP routes;
- raw agent token is returned only once on token creation.
### Owner lifecycle bypass
Risk: blocked/annulled user keeps active agent token.
Mitigation:
- Gateway checks Launcher owner status;
- blocked/annulled owner disables agent tokens;
- periodic sync plus request-time access check.
### Replay and duplicate writes
Risk: network retry creates duplicate cards/comments.
Mitigation:
- required idempotency keys for write tools;
- store operation result by agent and idempotency key;
- reject same key with different arguments;
- release failed writes so safe retries can run again.
### Reporting mode false confidence
Risk: enterprise admin assumes local Codex must report, but the developer bypasses the managed config.
Mitigation:
- UI distinguishes `connected`, `stale`, `never connected`;
- reporting mode is visibility and policy, not hard enforcement, unless a managed wrapper is used;
- CI/workflow checks can require Tasker session updates later.
## Hard rules
- No database access from Agent Gateway to Tasker DB.
- No arbitrary Tasker HTTP proxy.
- No user session cookie reuse.
- No delete/archive tools in MVP.
- No secrets in generated markdown instruction files.
- No token logging.
- No frontend access to service secrets.
- No cross-use between Ops and Ontology bearer tokens.
- No attachment from a filesystem path or remote URL.

View File

@ -0,0 +1,8 @@
ALTER TABLE agent_tokens
ADD COLUMN IF NOT EXISTS purpose text NOT NULL DEFAULT 'ops'
CHECK (purpose IN ('ops', 'ontology'));
CREATE INDEX IF NOT EXISTS agent_tokens_purpose_idx ON agent_tokens(purpose);
ALTER TABLE agent_setup_codes
ADD COLUMN IF NOT EXISTS used_ontology_token_id uuid REFERENCES agent_tokens(id) ON DELETE SET NULL;

View File

@ -0,0 +1,156 @@
import Fastify, { type FastifyInstance } from "fastify";
import { ZodError } from "zod";
import type { AppConfig } from "./config.js";
import { createPool, DatabaseNotConfiguredError } from "./db/pool.js";
import { ToolExecutionInputError } from "./mcp/tool-runtime.js";
import { AgentsRepository } from "./repositories/agents.js";
import { registerAgentRoutes } from "./routes/agents.js";
import { registerEngineGatewayRoutes } from "./routes/engine.js";
import { registerHealthRoutes } from "./routes/health.js";
import { registerInstallerRoutes } from "./routes/install.js";
import { registerMcpRoutes } from "./routes/mcp.js";
import { registerOntologyGatewayRoutes } from "./routes/ontology.js";
import { registerPublicRoutes } from "./routes/public.js";
import { registerToolRoutes } from "./routes/tools.js";
import { ForbiddenError } from "./security/authorization.js";
import { UnauthorizedError } from "./security/bearer.js";
import { InternalAuthNotConfiguredError } from "./security/internal.js";
import { TaskerAdapterError, TaskerAdapterNotConfiguredError, TaskerAdapterUnavailableError, TaskerClient } from "./tasker/client.js";
export async function buildApp(config: AppConfig): Promise<FastifyInstance> {
const pool = createPool(config);
const agentsRepository = pool ? new AgentsRepository(pool) : null;
const taskerClient = new TaskerClient({
baseUrl: config.NODEDC_TASKER_INTERNAL_URL,
internalAccessToken: config.NODEDC_INTERNAL_ACCESS_TOKEN,
});
const app = Fastify({
bodyLimit: 10 * 1024 * 1024,
logger: {
level: config.LOG_LEVEL,
},
});
app.addHook("onClose", async () => {
await pool?.end();
});
app.setErrorHandler((error, _request, reply) => {
if (error instanceof ZodError) {
void reply.status(400).send({
ok: false,
error: "validation_error",
details: error.issues,
});
return;
}
if (error instanceof DatabaseNotConfiguredError) {
void reply.status(503).send({
ok: false,
error: "database_not_configured",
message: "DATABASE_URL is required for Agent Gateway persistence endpoints.",
});
return;
}
if (error instanceof UnauthorizedError) {
void reply.status(401).send({
ok: false,
error: "unauthorized",
message: error.message,
});
return;
}
if (error instanceof InternalAuthNotConfiguredError) {
void reply.status(503).send({
ok: false,
error: "internal_auth_not_configured",
message: error.message,
});
return;
}
if (error instanceof ForbiddenError) {
void reply.status(403).send({
ok: false,
error: "forbidden",
message: error.message,
});
return;
}
if (error instanceof ToolExecutionInputError) {
void reply.status(error.httpStatus).send({
ok: false,
error: error.code,
message: error.message,
details: error.details,
});
return;
}
if (error instanceof TaskerAdapterNotConfiguredError) {
void reply.status(503).send({
ok: false,
error: "tasker_adapter_not_configured",
message: error.message,
});
return;
}
if (error instanceof TaskerAdapterError) {
void reply.status(error.statusCode).send({
ok: false,
error: "tasker_adapter_error",
message: error.message,
tasker_status: error.statusCode,
tasker_payload: error.payload,
});
return;
}
if (error instanceof TaskerAdapterUnavailableError) {
void reply.status(502).send({
ok: false,
error: "tasker_adapter_unavailable",
message: error.message,
});
return;
}
app.log.error(error);
void reply.status(500).send({
ok: false,
error: "internal_server_error",
message: "Agent Gateway request failed.",
});
});
await registerPublicRoutes(app);
await registerInstallerRoutes(app, { publicUrl: config.NODEDC_AGENT_GATEWAY_PUBLIC_URL });
await registerHealthRoutes(app, config, pool);
await registerAgentRoutes(app, {
agentsRepository,
codexInstallChannel: config.NODEDC_OPS_CODEX_INSTALL_CHANNEL,
codexNpmSpec: config.NODEDC_OPS_CODEX_NPM_SPEC,
publicUrl: config.NODEDC_AGENT_GATEWAY_PUBLIC_URL,
internalAccessToken: config.NODEDC_AGENT_GATEWAY_INTERNAL_TOKEN,
aiWorkspaceRunTokenTtlSeconds: config.NODEDC_AI_WORKSPACE_RUN_TOKEN_TTL_SECONDS,
});
await registerEngineGatewayRoutes(app, {
engineInternalUrl: config.NODEDC_ENGINE_INTERNAL_URL,
publicUrl: config.NODEDC_AGENT_GATEWAY_PUBLIC_URL,
});
await registerToolRoutes(app, { agentsRepository, taskerClient });
await registerMcpRoutes(app, { agentsRepository, taskerClient });
await registerOntologyGatewayRoutes(app, {
agentsRepository,
ontologyCoreUrl: config.NODEDC_ONTOLOGY_CORE_URL,
ontologyCoreAccessToken: config.NODEDC_ONTOLOGY_CORE_ACCESS_TOKEN ?? config.NODEDC_INTERNAL_ACCESS_TOKEN,
});
return app;
}

View File

@ -0,0 +1,36 @@
# NODE.DC Ops Codex
Installs NODE.DC Ops MCP access into a local Codex Desktop/CLI configuration.
## Setup
Generate a one-time setup code in NODE.DC Ops, then run:
```sh
npx --yes @nodedc/ops-codex setup ndcsetup_...
```
The setup command redeems the one-time code, writes independent `nodedc-ops-agent` and read-only `nodedc_ontology` MCP server blocks into `~/.codex/config.toml`, installs the `ops-context` skill, and checks both MCP tool lists.
Restart Codex Desktop after setup. A new chat is not enough if the existing Desktop process already loaded MCP config.
The installer auto-detects existing Codex homes. It checks `$CODEX_HOME`, `~/.codex`, common OS app-data Codex folders, and portable-style `.codex` folders near the current directory. If multiple existing Codex homes are found, setup writes all of them. If none are found, setup creates `~/.codex`.
## Commands
```sh
ops-codex setup <setup-code>
ops-codex status
ops-codex doctor
```
`doctor` checks the local config, installed skill, and MCP connectivity.
## Options
```sh
--gateway <url> Ops Agent Gateway URL. Defaults to https://ops-agents.nodedc.ru.
--codex-home <path> Codex home directory. Overrides auto-discovery.
```
For an unusual portable Codex layout, run setup from the portable app folder or pass `--codex-home <path>` explicitly.

View File

@ -0,0 +1,792 @@
#!/usr/bin/env node
import { mkdir, readFile, stat, writeFile } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
const DEFAULT_GATEWAY = "https://ops-agents.nodedc.ru";
const SERVER_NAME = "nodedc-ops-agent";
const ONTOLOGY_SERVER_NAME = "nodedc_ontology";
const SKILL_NAME = "ops-context";
const MCP_PROTOCOL_VERSION = "2025-06-18";
async function main() {
const args = parseArgs(process.argv.slice(2));
if (args.help) {
printHelp(args.command);
return;
}
if (args.command === "setup") {
await runSetup(args);
return;
}
if (args.command === "status") {
const report = await inspectLocalInstalls(args.codexHome, { smoke: false });
printInstallReport(report);
process.exitCode = report.ok ? 0 : 1;
return;
}
if (args.command === "doctor") {
const report = await inspectLocalInstalls(args.codexHome, { smoke: true });
printInstallReport(report);
process.exitCode = report.ok ? 0 : 1;
return;
}
throw new UsageError(`Unknown command: ${args.command}`);
}
async function runSetup(args) {
if (!args.setupCode) {
throw new UsageError("Missing setup code. Use: ops-codex setup <ndcsetup_...>");
}
const gateway = String(args.gateway || process.env.NODEDC_OPS_AGENT_GATEWAY || DEFAULT_GATEWAY).replace(/\/+$/, "");
const redeemPayload = await redeemSetupCode(gateway, args.setupCode);
const setup = redeemPayload.setup || {};
const mcpServer = setup.mcp_server || {};
const ops = redeemPayload.ops || {
serverName: SERVER_NAME,
mcpUrl: String(mcpServer.url || `${gateway}/mcp`),
token: redeemPayload.token,
};
const ontology = redeemPayload.ontology;
if (!ops.token || !ontology?.token) {
throw new Error("Setup code redeem did not return independent Ops and Ontology credentials.");
}
const opsEndpoint = String(ops.mcpUrl || `${gateway}/mcp`);
const ontologyEndpoint = String(ontology.mcpUrl || `${gateway}/ontology-mcp`);
const agentsMd = setup.agents_md || defaultSkillBody(opsEndpoint);
const targets = await resolveCodexTargets(args.codexHome);
for (const target of targets) {
const configPath = path.join(target.codexHome, "config.toml");
const skillPath = path.join(target.codexHome, "skills", SKILL_NAME, "SKILL.md");
await writeCodexConfig(configPath, [
{ serverName: String(ops.serverName || SERVER_NAME), endpoint: opsEndpoint, token: ops.token },
{
serverName: String(ontology.serverName || ONTOLOGY_SERVER_NAME),
endpoint: ontologyEndpoint,
token: ontology.token,
},
]);
await writeSkill(skillPath, agentsMd, opsEndpoint, ontologyEndpoint);
}
const opsToolCount = await smokeToolsList(opsEndpoint, ops.token);
const ontologyToolCount = await smokeToolsList(ontologyEndpoint, ontology.token);
console.log("NODE.DC Ops Codex setup complete.");
for (const target of targets) {
console.log("Config:", path.join(target.codexHome, "config.toml"));
console.log("Skill:", path.join(target.codexHome, "skills", SKILL_NAME, "SKILL.md"));
console.log("Target:", target.reason);
}
console.log("Ops MCP smoke tools:", opsToolCount);
console.log("Ontology MCP smoke tools:", ontologyToolCount);
console.log("Run: ops-codex doctor");
console.log("Restart Codex Desktop completely before testing. A new chat is not enough if the Desktop process already loaded MCP config.");
console.log("In the next session, tool discovery must expose tasker_list_projects. If it does not, Codex has not loaded the NODE.DC Ops MCP server.");
}
function parseArgs(rawArgs) {
let args = rawArgs[0] === "--" ? rawArgs.slice(1) : rawArgs;
let command = "setup";
if (args[0] === "setup" || args[0] === "status" || args[0] === "doctor") {
command = args[0];
args = args.slice(1);
} else if (args[0] === "help") {
return { codexHome: "", command: "help", gateway: "", help: true, setupCode: "" };
}
const parsed = {
codexHome: "",
command,
gateway: "",
help: false,
setupCode: "",
};
const positional = [];
for (let i = 0; i < args.length; i += 1) {
const arg = args[i];
if (arg === "-h" || arg === "--help") {
parsed.help = true;
continue;
}
if (arg === "--gateway") {
parsed.gateway = requireValue(args, ++i, "--gateway");
continue;
}
if (arg.startsWith("--gateway=")) {
parsed.gateway = arg.slice("--gateway=".length);
continue;
}
if (arg === "--setup-code" || arg === "--code") {
parsed.setupCode = requireValue(args, ++i, arg);
continue;
}
if (arg.startsWith("--setup-code=")) {
parsed.setupCode = arg.slice("--setup-code=".length);
continue;
}
if (arg.startsWith("--code=")) {
parsed.setupCode = arg.slice("--code=".length);
continue;
}
if (arg === "--codex-home") {
parsed.codexHome = requireValue(args, ++i, "--codex-home");
continue;
}
if (arg.startsWith("--codex-home=")) {
parsed.codexHome = arg.slice("--codex-home=".length);
continue;
}
if (arg.startsWith("-")) {
throw new UsageError(`Unknown argument: ${arg}`);
}
positional.push(arg);
}
if (parsed.command === "setup") {
if (!parsed.setupCode && positional[0]) {
parsed.setupCode = positional[0];
}
if (positional.length > 1) {
throw new UsageError(`Unexpected extra argument: ${positional[1]}`);
}
} else if (positional.length) {
throw new UsageError(`${parsed.command} does not accept positional arguments.`);
}
return parsed;
}
function requireValue(args, index, flag) {
const value = args[index];
if (!value || value.startsWith("--")) {
throw new UsageError(`Missing value for ${flag}.`);
}
return value;
}
function printHelp(command = "") {
if (command === "status") {
console.log(`Show local NODE.DC Ops Codex install status.
Usage:
ops-codex status [--codex-home <path>]
`);
return;
}
if (command === "doctor") {
console.log(`Check local NODE.DC Ops Codex install and MCP connectivity.
Usage:
ops-codex doctor [--codex-home <path>]
`);
return;
}
console.log(`Install NODE.DC Ops MCP access into local Codex.
Usage:
ops-codex setup <setup-code> [--gateway <url>] [--codex-home <path>]
ops-codex setup --code <setup-code> [--gateway <url>] [--codex-home <path>]
ops-codex status [--codex-home <path>]
ops-codex doctor [--codex-home <path>]
Registry form after publish:
npx --yes @nodedc/ops-codex setup <setup-code>
Self-host fallback:
npm exec --yes --package=<gateway>/install/nodedc-ops-codex-setup.tgz -- ops-codex setup <setup-code>
Options:
--code, --setup-code <code> One-time setup code generated by NODE.DC Ops.
--gateway <url> Ops Agent Gateway URL. Defaults to ${DEFAULT_GATEWAY}.
--codex-home <path> Codex home directory. Overrides auto-discovery.
-h, --help Show this help.
Codex home discovery:
By default, the installer writes every existing Codex-like home it can detect:
$CODEX_HOME, ~/.codex, common OS app-data Codex folders, and portable-style
.codex folders near the current directory. If none exist, it creates ~/.codex.
Legacy compatibility:
nodedc-ops-codex --setup-code <setup-code>
`);
}
async function redeemSetupCode(gateway, setupCode) {
let response;
try {
response = await fetch(`${gateway}/api/v1/setup-codes/redeem`, {
method: "POST",
headers: {
Accept: "application/json",
"Content-Type": "application/json",
},
body: JSON.stringify({ setup_code: setupCode }),
});
} catch (error) {
throw new Error(`Setup code redeem failed: ${error.message}`);
}
const bodyText = await response.text();
let data;
try {
data = bodyText ? JSON.parse(bodyText) : {};
} catch {
throw new Error(`Setup code redeem failed: HTTP ${response.status} ${bodyText}`);
}
if (!response.ok) {
throw new Error(`Setup code redeem failed: HTTP ${response.status} ${bodyText}`);
}
if (!data.ok || !data.token || !data.ontology?.token) {
throw new Error(`Setup code redeem failed: ${JSON.stringify(data)}`);
}
return data;
}
async function resolveCodexTargets(codexHomeArg) {
if (codexHomeArg) {
return [{ codexHome: expandHome(codexHomeArg), reason: "--codex-home" }];
}
if (process.env.CODEX_HOME) {
return [{ codexHome: expandHome(process.env.CODEX_HOME), reason: "CODEX_HOME" }];
}
const candidates = buildCodexHomeCandidates();
const existing = [];
for (const candidate of candidates) {
if (await looksLikeCodexHome(candidate.codexHome)) {
existing.push(candidate);
}
}
const dedupedExisting = dedupeTargets(existing);
if (dedupedExisting.length > 0) {
return dedupedExisting;
}
return [{ codexHome: defaultCodexHome(), reason: "default ~/.codex" }];
}
function buildCodexHomeCandidates() {
const home = os.homedir();
const candidates = [{ codexHome: defaultCodexHome(), reason: "default ~/.codex" }];
if (process.platform === "darwin") {
candidates.push(
{
codexHome: path.join(home, "Library", "Application Support", "Codex"),
reason: "macOS Application Support Codex",
},
{
codexHome: path.join(home, "Library", "Application Support", "OpenAI", "Codex"),
reason: "macOS Application Support OpenAI Codex",
}
);
}
if (process.platform === "win32") {
const appData = process.env.APPDATA || path.join(home, "AppData", "Roaming");
const localAppData = process.env.LOCALAPPDATA || path.join(home, "AppData", "Local");
candidates.push(
{ codexHome: path.join(appData, "Codex"), reason: "Windows APPDATA Codex" },
{ codexHome: path.join(appData, "OpenAI", "Codex"), reason: "Windows APPDATA OpenAI Codex" },
{ codexHome: path.join(localAppData, "Codex"), reason: "Windows LOCALAPPDATA Codex" },
{ codexHome: path.join(localAppData, "OpenAI", "Codex"), reason: "Windows LOCALAPPDATA OpenAI Codex" }
);
}
for (const portableHome of portableCodexHomeCandidates(process.cwd())) {
candidates.push(portableHome);
}
return dedupeTargets(candidates);
}
function portableCodexHomeCandidates(startDir) {
const candidates = [];
let current = path.resolve(startDir);
const stopAt = path.parse(current).root;
for (let depth = 0; depth < 4; depth += 1) {
candidates.push(
{ codexHome: path.join(current, ".codex"), reason: "portable .codex near current directory" },
{ codexHome: path.join(current, "data", ".codex"), reason: "portable data/.codex near current directory" },
{ codexHome: path.join(current, "profile", ".codex"), reason: "portable profile/.codex near current directory" },
{ codexHome: path.join(current, "portable", ".codex"), reason: "portable portable/.codex near current directory" }
);
if (current === stopAt) {
break;
}
current = path.dirname(current);
}
return candidates;
}
function defaultCodexHome() {
return path.join(os.homedir(), ".codex");
}
async function looksLikeCodexHome(codexHome) {
if (await pathExists(path.join(codexHome, "config.toml"))) {
return true;
}
if (await pathExists(path.join(codexHome, "skills"))) {
return true;
}
const baseName = path.basename(codexHome).toLowerCase();
return baseName === ".codex" && (await pathExists(codexHome));
}
async function pathExists(targetPath) {
try {
await stat(targetPath);
return true;
} catch (error) {
if (error && error.code === "ENOENT") {
return false;
}
throw error;
}
}
function dedupeTargets(targets) {
const seen = new Set();
const result = [];
for (const target of targets) {
const key = normalizePathKey(target.codexHome);
if (seen.has(key)) {
continue;
}
seen.add(key);
result.push({ codexHome: path.resolve(target.codexHome), reason: target.reason });
}
return result;
}
function normalizePathKey(value) {
const resolved = path.resolve(value);
return process.platform === "win32" ? resolved.toLowerCase() : resolved;
}
function expandHome(value) {
if (value === "~") {
return os.homedir();
}
if (value.startsWith("~/") || value.startsWith("~\\")) {
return path.join(os.homedir(), value.slice(2));
}
return path.resolve(value);
}
async function writeCodexConfig(configPath, servers) {
await mkdir(path.dirname(configPath), { recursive: true });
const original = await readTextIfExists(configPath);
let backupPath = "";
if (original !== null) {
backupPath = `${configPath}.nodedc-bak`;
await writeFile(backupPath, original, "utf8");
}
let nextText = stripServerSections(
original || "",
servers.map((server) => server.serverName)
).trimEnd();
if (nextText) {
nextText += "\n\n";
}
nextText += servers
.map((server) => buildMcpConfigBlock(server.serverName, server.endpoint, server.token))
.join("\n\n");
nextText += "\n";
await writeFile(configPath, nextText, "utf8");
if (backupPath) {
console.log("Backup:", backupPath);
}
}
async function readTextIfExists(filePath) {
try {
return await readFile(filePath, "utf8");
} catch (error) {
if (error && error.code === "ENOENT") {
return null;
}
throw error;
}
}
function stripServerSections(text, serverNames) {
const targets = new Set(serverNames.map((serverName) => `mcp_servers.${serverName}`));
const kept = [];
let skip = false;
for (const line of text.split(/\r?\n/)) {
const match = line.match(/^\s*\[([^\]]+)\]\s*(?:#.*)?$/);
if (match) {
const section = match[1].trim();
skip = [...targets].some((target) => section === target || section.startsWith(`${target}.`));
}
if (!skip) {
kept.push(line);
}
}
return kept.join("\n");
}
function buildMcpConfigBlock(serverName, endpoint, token) {
return [
`[mcp_servers.${serverName}]`,
`url = ${tomlString(endpoint)}`,
"enabled = true",
"required = false",
"startup_timeout_sec = 20",
"tool_timeout_sec = 60",
"",
`[mcp_servers.${serverName}.http_headers]`,
`Authorization = ${tomlString(`Bearer ${token}`)}`,
`Accept = ${tomlString("application/json")}`,
`${tomlString("MCP-Protocol-Version")} = ${tomlString(MCP_PROTOCOL_VERSION)}`,
].join("\n");
}
function tomlString(value) {
return JSON.stringify(String(value));
}
async function writeSkill(skillPath, agentsMd, endpoint, ontologyEndpoint) {
await mkdir(path.dirname(skillPath), { recursive: true });
await writeFile(skillPath, buildSkillBody(agentsMd, endpoint, ontologyEndpoint), "utf8");
}
function buildSkillBody(agentsMd, endpoint, ontologyEndpoint) {
return [
"---",
"name: ops-context",
"description: Use when working with NODE.DC Ops/Tasker cards, project cards, checkers, labels, comments, or when the user asks for ops-context. Always use the direct nodedc-ops-agent tasker_* MCP tools and never the old codex_apps OPS widgets.",
"---",
"",
"# ops-context",
"",
"Use this skill when the user asks to work with NODE.DC Ops, Tasker cards, project cards, checkers, labels, comments, or asks for ops-context.",
"",
"## Hard Rules",
"",
"- Treat Ops MCP as the source of truth for cards and project context.",
"- Treat `nodedc_ontology` as a separate read-only semantic authority. Use it before inventing entity ids, semantic types, aliases, relations, or platform routing assumptions.",
"- Ontology tools and credentials are never embedded or multiplexed into Ops MCP.",
"- Use only the direct nodedc-ops-agent MCP tools for Ops cards. The expected tool names are tasker_get_agent_instructions, tasker_list_projects, tasker_get_project_context, tasker_search_issues, and the other tasker_* tools listed below.",
"- Do not use Codex Apps workspace widgets for Ops: never call tools from codex_apps servers named nodedc_ops_readonly, nodedc_ops_gateway_readonly, ops-readonly, or ops_gateway_readonly. They are known-broken for Tasker cards and waste context.",
"- Do not open current workspace widgets, CodexPro cards, .ai-bridge handoff files, local thread lists, or broad filesystem searches to answer Ops card requests.",
"- MCP tools can be lazy-loaded. If mcp__nodedc_ops_agent.tasker_list_projects is not already visible in the active tools, do not answer yet.",
"- First call the available tool discovery/search tool, normally tool_search.tool_search_tool, with query exactly: tasker_list_projects nodedc-ops-agent NODE.DC Ops MCP",
"- After discovery returns NODE.DC Ops tools, immediately call mcp__nodedc_ops_agent.tasker_get_agent_instructions, then mcp__nodedc_ops_agent.tasker_list_projects, then mcp__nodedc_ops_agent.tasker_get_project_context for the target project.",
"- Resolve workspace/project from tasker_list_projects and granted project context. Do not guess a workspace slug from user wording.",
"- Stop only if no discovery/search tool is available, or discovery returns no mcp__nodedc_ops_agent tasker_* tools. Then tell the user: 'NODE.DC Ops MCP is installed in config.toml, but this Codex session did not expose the nodedc-ops-agent tools after tool discovery.' Do not fall back to filesystem search, config.toml parsing, curl, or Codex Apps widgets unless the user explicitly asks to debug installation.",
"- Never print Authorization headers, setup codes, or agent tokens.",
"- Every write must use the official Tasker MCP write tool and a fresh idempotency_key.",
"- Read a card with `tasker_get_issue` before claiming its comments or attachments are unavailable.",
"- Create projects only through `tasker_create_project` when the effective workspace grant includes `project:create`.",
"- Attach local files only through `tasker_attach_file`; never pass arbitrary server paths or remote URLs.",
"- Do not delete or archive cards, comments, labels, projects, states, members, or workspaces.",
"",
"## Installed Endpoint",
"",
endpoint,
"",
"## Separate read-only Ontology endpoint",
"",
ontologyEndpoint,
"",
"## Gateway Rules Snapshot",
"",
agentsMd.trim(),
"",
].join("\n");
}
function defaultSkillBody(endpoint) {
return [
"# NODE.DC Ops Agent Rules",
"",
`MCP endpoint: ${endpoint}`,
"",
"Call tasker_get_agent_instructions, tasker_list_projects, and tasker_get_project_context before writing Tasker cards.",
].join("\n");
}
async function inspectLocalInstall(codexHome, options) {
const configPath = path.join(codexHome, "config.toml");
const skillPath = path.join(codexHome, "skills", SKILL_NAME, "SKILL.md");
const checks = [];
const configText = await readTextIfExists(configPath);
const skillText = await readTextIfExists(skillPath);
const opsConfig = configText ? parseCodexMcpConfig(configText, SERVER_NAME) : null;
const ontologyConfig = configText ? parseCodexMcpConfig(configText, ONTOLOGY_SERVER_NAME) : null;
const endpoint = opsConfig?.server.url || "";
const authorization = opsConfig?.headers.Authorization || "";
const ontologyEndpoint = ontologyConfig?.server.url || "";
const ontologyAuthorization = ontologyConfig?.headers.Authorization || "";
checks.push({ ok: Boolean(configText), name: "config", detail: configPath });
checks.push({
ok: Boolean(opsConfig?.found),
name: "Ops MCP server",
detail: opsConfig?.found ? SERVER_NAME : "missing [mcp_servers.nodedc-ops-agent]",
});
checks.push({
ok: Boolean(endpoint),
name: "endpoint",
detail: endpoint || "missing url",
});
checks.push({
ok: Boolean(parsedConfig?.headers && authorization),
name: "authorization",
detail: authorization ? "present" : "missing http_headers.Authorization",
});
checks.push({
ok: Boolean(ontologyConfig?.found),
name: "Ontology MCP server",
detail: ontologyConfig?.found ? ONTOLOGY_SERVER_NAME : "missing [mcp_servers.nodedc_ontology]",
});
checks.push({
ok: Boolean(ontologyEndpoint),
name: "Ontology endpoint",
detail: ontologyEndpoint || "missing url",
});
checks.push({
ok: Boolean(ontologyAuthorization),
name: "Ontology authorization",
detail: ontologyAuthorization ? "present" : "missing http_headers.Authorization",
});
checks.push({
ok: Boolean(skillText),
name: "skill",
detail: skillPath,
});
checks.push({
ok: Boolean(skillText?.startsWith("---\nname: ops-context")),
name: "skill frontmatter",
detail: skillText ? "present" : "not checked",
});
checks.push({
ok: Boolean(skillText?.includes("tool_search.tool_search_tool")),
name: "lazy discovery rule",
detail: skillText ? "present" : "not checked",
});
let smoke = null;
if (options.smoke) {
if (endpoint && authorization && ontologyEndpoint && ontologyAuthorization) {
try {
const opsToolCount = await smokeToolsListWithAuthorization(endpoint, authorization);
const ontologyToolCount = await smokeToolsListWithAuthorization(ontologyEndpoint, ontologyAuthorization);
smoke = { ok: true, detail: `${opsToolCount} Ops tools; ${ontologyToolCount} Ontology tools` };
} catch (error) {
smoke = { ok: false, detail: error.message || String(error) };
}
} else {
smoke = { ok: false, detail: "Ops/Ontology endpoint or Authorization header is missing" };
}
checks.push({ ok: smoke.ok, name: "mcp smoke", detail: smoke.detail });
}
return {
codexHome,
configPath,
endpoint,
ok: checks.every((check) => check.ok),
skillPath,
checks,
};
}
async function inspectLocalInstalls(codexHomeArg, options) {
const targets = await resolveCodexTargets(codexHomeArg);
const reports = [];
for (const target of targets) {
reports.push({
target,
report: await inspectLocalInstall(target.codexHome, options),
});
}
return {
ok: reports.some((entry) => entry.report.ok),
reports,
};
}
function parseCodexMcpConfig(text, serverName) {
const target = `mcp_servers.${serverName}`;
const headers = `${target}.http_headers`;
let current = "";
const result = { found: false, headers: {}, server: {} };
for (const rawLine of text.split(/\r?\n/)) {
const line = rawLine.trim();
if (!line || line.startsWith("#")) {
continue;
}
const sectionMatch = line.match(/^\[([^\]]+)\]$/);
if (sectionMatch) {
current = sectionMatch[1].trim();
if (current === target || current === headers) {
result.found = true;
}
continue;
}
if (current !== target && current !== headers) {
continue;
}
const keyValue = line.match(/^("[^"]+"|[A-Za-z0-9_.-]+)\s*=\s*(.+)$/);
if (!keyValue) {
continue;
}
const key = parseTomlKey(keyValue[1]);
const value = parseTomlValue(keyValue[2]);
if (current === target) {
result.server[key] = value;
} else {
result.headers[key] = value;
}
}
return result;
}
function parseTomlKey(value) {
const trimmed = value.trim();
if (trimmed.startsWith('"') && trimmed.endsWith('"')) {
return JSON.parse(trimmed);
}
return trimmed;
}
function parseTomlValue(value) {
const trimmed = stripTomlInlineComment(value.trim());
if (trimmed.startsWith('"') && trimmed.endsWith('"')) {
try {
return JSON.parse(trimmed);
} catch {
return trimmed.slice(1, -1);
}
}
if (trimmed === "true") return true;
if (trimmed === "false") return false;
return trimmed;
}
function stripTomlInlineComment(value) {
let inString = false;
let escaped = false;
for (let i = 0; i < value.length; i += 1) {
const char = value[i];
if (escaped) {
escaped = false;
continue;
}
if (char === "\\") {
escaped = true;
continue;
}
if (char === '"') {
inString = !inString;
continue;
}
if (char === "#" && !inString) {
return value.slice(0, i).trimEnd();
}
}
return value;
}
function printInstallReport(result) {
console.log("NODE.DC Ops Codex status");
for (const entry of result.reports) {
const report = entry.report;
console.log("Codex home:", report.codexHome);
console.log("Target:", entry.target.reason);
for (const check of report.checks) {
console.log(`${check.ok ? "OK" : "FAIL"} ${check.name}: ${check.detail}`);
}
}
if (result.ok) {
console.log("Result: ready");
} else {
console.log("Result: needs attention");
}
}
async function smokeToolsList(endpoint, token) {
return smokeToolsListWithAuthorization(endpoint, `Bearer ${token}`);
}
async function smokeToolsListWithAuthorization(endpoint, authorization) {
let response;
try {
response = await fetch(endpoint, {
method: "POST",
headers: {
Authorization: authorization,
Accept: "application/json",
"Content-Type": "application/json",
"MCP-Protocol-Version": MCP_PROTOCOL_VERSION,
},
body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/list" }),
});
} catch (error) {
throw new Error(`MCP smoke check failed: ${error.message}`);
}
const bodyText = await response.text();
let data;
try {
data = bodyText ? JSON.parse(bodyText) : {};
} catch {
throw new Error(`MCP smoke check failed: HTTP ${response.status} ${bodyText}`);
}
if (!response.ok) {
throw new Error(`MCP smoke check failed: HTTP ${response.status} ${bodyText}`);
}
if (data.error) {
throw new Error(`MCP smoke check failed: ${JSON.stringify(data.error)}`);
}
const tools = data.result?.tools || [];
if (!Array.isArray(tools) || tools.length === 0) {
throw new Error("MCP smoke check failed: tools/list returned no tools.");
}
return tools.length;
}
class UsageError extends Error {}
main().catch((error) => {
if (error instanceof UsageError) {
console.error(error.message);
console.error("Run ops-codex --help for usage.");
process.exit(2);
}
console.error(error.message || String(error));
process.exit(1);
});

View File

@ -0,0 +1,29 @@
{
"name": "@nodedc/ops-codex",
"version": "0.1.3",
"description": "Install scoped NODE.DC Ops MCP and separate read-only Ontology MCP access into local Codex.",
"type": "module",
"bin": {
"ops-codex": "./bin/nodedc-ops-codex.mjs",
"nodedc-ops-codex": "./bin/nodedc-ops-codex.mjs"
},
"engines": {
"node": ">=18"
},
"files": [
"bin/",
"README.md"
],
"keywords": [
"nodedc",
"ops",
"tasker",
"codex",
"mcp"
],
"license": "UNLICENSED",
"private": false,
"publishConfig": {
"access": "public"
}
}

View File

@ -0,0 +1,37 @@
import { z } from "zod";
const optionalUrl = z.preprocess((value) => (value === "" ? undefined : value), z.string().url().optional());
const optionalSecret = z.preprocess((value) => (value === "" ? undefined : value), z.string().min(1).optional());
const npmPackageSpec = z.string().min(1).max(214).regex(/^\S+$/, "must not contain whitespace");
const configSchema = z.object({
NODE_ENV: z.enum(["development", "test", "production"]).default("development"),
HOST: z.string().min(1).default("0.0.0.0"),
PORT: z.coerce.number().int().min(1).max(65535).default(4100),
LOG_LEVEL: z.enum(["fatal", "error", "warn", "info", "debug", "trace", "silent"]).default("info"),
NODEDC_AGENT_GATEWAY_PUBLIC_URL: z.string().url().default("http://ops-agents.local.nodedc"),
NODEDC_AGENT_GATEWAY_INTERNAL_TOKEN: z.string().min(1).optional(),
NODEDC_OPS_CODEX_INSTALL_CHANNEL: z.enum(["tarball", "registry"]).default("tarball"),
NODEDC_OPS_CODEX_NPM_SPEC: npmPackageSpec.default("@nodedc/ops-codex"),
NODEDC_AI_WORKSPACE_RUN_TOKEN_TTL_SECONDS: z.coerce.number().int().min(300).max(86_400).default(43_200),
NODEDC_LAUNCHER_INTERNAL_URL: z.string().url().default("http://launcher.local.nodedc"),
NODEDC_TASKER_INTERNAL_URL: z.string().url().default("http://task.local.nodedc"),
NODEDC_ENGINE_INTERNAL_URL: z.string().url().default("http://172.22.0.222:3001"),
NODEDC_ONTOLOGY_CORE_URL: z.string().url().default("http://172.22.0.222:18104"),
NODEDC_ONTOLOGY_CORE_ACCESS_TOKEN: optionalSecret,
NODEDC_INTERNAL_ACCESS_TOKEN: z.string().min(1).optional(),
DATABASE_URL: optionalUrl,
});
export type AppConfig = z.infer<typeof configSchema>;
export function loadConfig(env: NodeJS.ProcessEnv = process.env): AppConfig {
const parsed = configSchema.safeParse(env);
if (!parsed.success) {
const details = parsed.error.issues.map((issue) => `${issue.path.join(".")}: ${issue.message}`).join("; ");
throw new Error(`Invalid Agent Gateway configuration: ${details}`);
}
return parsed.data;
}

View File

@ -0,0 +1,56 @@
export const allowedAgentScopes = [
"workspace:read",
"project:read",
"project:create",
"project:member:add_existing",
"issue:read",
"issue:create",
"issue:update",
"issue:move",
"issue:comment",
"issue:label",
"issue:assign",
"issue:attachment:write",
"issue:structured_blocks:write",
] as const;
export type AgentScope = (typeof allowedAgentScopes)[number];
export const deniedMvpCapabilities = [
"issue:delete",
"issue:archive",
"comment:delete",
"label:delete",
"state:create",
"state:delete",
"project:delete",
"workspace:settings",
"workspace:member:invite",
"workspace:member:remove",
"raw_tasker_api",
] as const;
export const taskAuthorPresetScopes: AgentScope[] = [
"workspace:read",
"project:read",
"project:create",
"project:member:add_existing",
"issue:read",
"issue:create",
"issue:update",
"issue:move",
"issue:comment",
"issue:label",
"issue:assign",
"issue:attachment:write",
"issue:structured_blocks:write",
];
export const reporterPresetScopes: AgentScope[] = [
"workspace:read",
"project:read",
"issue:read",
"issue:update",
"issue:comment",
"issue:structured_blocks:write",
];

View File

@ -0,0 +1,849 @@
import { createHash } from "node:crypto";
import { z } from "zod";
import type { AgentScope } from "../domain/scopes.js";
import { structuredBlocksSchema } from "../domain/structured-blocks.js";
import type { AgentsRepository, AgentSessionRecord } from "../repositories/agents.js";
import { ForbiddenError, requireProjectGrant, requireScope } from "../security/authorization.js";
import type { TaskerClient } from "../tasker/client.js";
type JsonSchema = Record<string, unknown>;
export type McpToolRuntimeDefinition = {
name: string;
title: string;
description: string;
requiredScopes: AgentScope[];
inputSchema: JsonSchema;
annotations?: Record<string, unknown>;
};
export type McpToolResult = {
content: Array<{
type: "text";
text: string;
}>;
structuredContent?: unknown;
isError?: boolean;
};
type ExecuteToolDeps = {
agentsRepository?: AgentsRepository | null;
taskerClient: TaskerClient;
};
type ExecuteToolOptions = {
source?: "mcp" | "rest";
idempotencyKey?: string | null;
};
const emptyInputSchema = {
type: "object",
additionalProperties: false,
};
const projectInputSchema = {
type: "object",
properties: {
project_id: { type: "string" },
workspace_slug: { type: "string" },
},
required: ["project_id"],
additionalProperties: false,
};
const projectAndIssueInputSchema = {
type: "object",
properties: {
issue_id: { type: "string" },
project_id: { type: "string" },
workspace_slug: { type: "string" },
},
required: ["issue_id", "project_id"],
additionalProperties: false,
};
const structuredBlocksJsonSchema = {
type: "array",
items: {
oneOf: [
{
type: "object",
properties: {
id: { type: "string" },
type: { const: "text" },
title: {
type: "string",
description: "Visible block title. Put headings here, not inside body markdown.",
},
body: {
type: "string",
description: "Plain block body without a leading markdown heading.",
},
},
required: ["id", "type", "title", "body"],
additionalProperties: false,
},
{
type: "object",
properties: {
id: { type: "string" },
type: { const: "checker" },
title: {
type: "string",
description: "Visible checklist title. Put headings here, not inside item text.",
},
items: {
type: "array",
items: {
type: "object",
properties: {
id: { type: "string" },
text: { type: "string" },
checked: { type: "boolean" },
},
required: ["id", "text"],
additionalProperties: false,
},
},
},
required: ["id", "type", "title", "items"],
additionalProperties: false,
},
],
},
};
export const mcpRuntimeTools: McpToolRuntimeDefinition[] = [
{
name: "tasker_get_agent_instructions",
title: "NODE.DC Ops: Get Agent Instructions",
description:
"Direct NODE.DC Ops MCP tool. Return effective Tasker card-writing rules, grants, scopes, and mode expectations. Use this before any Ops card read/write workflow.",
requiredScopes: ["workspace:read"],
inputSchema: emptyInputSchema,
annotations: { readOnlyHint: true },
},
{
name: "tasker_list_projects",
title: "NODE.DC Ops: List Granted Projects",
description:
"Direct NODE.DC Ops MCP tool. List Tasker/Ops projects granted to the current Codex agent. Use this instead of Codex Apps workspace widgets.",
requiredScopes: ["project:read"],
inputSchema: emptyInputSchema,
annotations: { readOnlyHint: true },
},
{
name: "tasker_get_project_context",
title: "NODE.DC Ops: Get Project Context",
description:
"Direct NODE.DC Ops MCP tool. Return states, labels, members, and card-writing context for one granted Tasker/Ops project.",
requiredScopes: ["project:read"],
inputSchema: projectInputSchema,
annotations: { readOnlyHint: true },
},
{
name: "tasker_create_project",
title: "NODE.DC Ops: Create Project",
description:
"Direct NODE.DC Ops MCP write tool. Create a new Tasker project inside an explicitly granted workspace and extend the current agent grant to the created project.",
requiredScopes: ["project:create"],
inputSchema: {
type: "object",
properties: {
workspace_slug: { type: "string" },
name: { type: "string" },
identifier: {
type: "string",
description: "Optional 1-12 character Tasker project identifier. A unique identifier is generated when omitted.",
},
description: { type: "string" },
idempotency_key: { type: "string" },
},
required: ["workspace_slug", "name"],
additionalProperties: false,
},
annotations: { destructiveHint: false, idempotentHint: false },
},
{
name: "tasker_search_issues",
title: "NODE.DC Ops: Search Cards",
description:
"Direct NODE.DC Ops MCP tool. Search Tasker/Ops cards and work items inside one granted project. Use this for 'show cards', 'find cards', and project context requests.",
requiredScopes: ["issue:read"],
inputSchema: {
...projectInputSchema,
properties: {
...projectInputSchema.properties,
query: { type: "string" },
},
},
annotations: { readOnlyHint: true },
},
{
name: "tasker_get_issue",
title: "NODE.DC Ops: Get Card",
description:
"Direct NODE.DC Ops MCP tool. Return one granted Tasker/Ops card with structured blocks, labels, assignees, full comment bodies, and attachment metadata.",
requiredScopes: ["issue:read"],
inputSchema: projectAndIssueInputSchema,
annotations: { readOnlyHint: true },
},
{
name: "tasker_create_issue",
title: "NODE.DC Ops: Create Card",
description:
"Direct NODE.DC Ops MCP write tool. Create a Tasker/Ops card with optional NODE.DC structured text/checker blocks.",
requiredScopes: ["issue:create"],
inputSchema: {
type: "object",
properties: {
project_id: { type: "string" },
workspace_slug: { type: "string" },
title: { type: "string" },
description: { type: "string" },
priority: { type: "string", enum: ["none", "low", "medium", "high", "urgent"] },
structured_blocks: structuredBlocksJsonSchema,
idempotency_key: { type: "string" },
},
required: ["project_id", "title"],
additionalProperties: false,
},
annotations: { destructiveHint: false, idempotentHint: false },
},
{
name: "tasker_update_issue",
title: "NODE.DC Ops: Update Card",
description:
"Direct NODE.DC Ops MCP write tool. Patch allowed Tasker/Ops card fields without delete, archive, or project transfer.",
requiredScopes: ["issue:update"],
inputSchema: {
type: "object",
properties: {
issue_id: { type: "string" },
project_id: { type: "string" },
workspace_slug: { type: "string" },
title: { type: "string" },
description: { type: "string" },
priority: { type: "string", enum: ["none", "low", "medium", "high", "urgent"] },
structured_blocks: structuredBlocksJsonSchema,
idempotency_key: { type: "string" },
},
required: ["issue_id", "project_id"],
additionalProperties: false,
},
annotations: { destructiveHint: false, idempotentHint: false },
},
{
name: "tasker_update_structured_blocks",
title: "NODE.DC Ops: Update Structured Blocks",
description:
"Direct NODE.DC Ops MCP write tool. Replace NODE.DC structured text/checker blocks in a Tasker/Ops card detail layout.",
requiredScopes: ["issue:update", "issue:structured_blocks:write"],
inputSchema: {
...projectAndIssueInputSchema,
properties: {
...projectAndIssueInputSchema.properties,
structured_blocks: structuredBlocksJsonSchema,
idempotency_key: { type: "string" },
},
required: ["issue_id", "project_id", "structured_blocks"],
},
annotations: { destructiveHint: false, idempotentHint: false },
},
{
name: "tasker_move_issue",
title: "NODE.DC Ops: Move Card",
description: "Direct NODE.DC Ops MCP write tool. Move a Tasker/Ops card to an existing state in the same granted project.",
requiredScopes: ["issue:move"],
inputSchema: {
...projectAndIssueInputSchema,
properties: {
...projectAndIssueInputSchema.properties,
state_id: { type: "string" },
idempotency_key: { type: "string" },
},
required: ["issue_id", "project_id", "state_id"],
},
annotations: { destructiveHint: false, idempotentHint: false },
},
{
name: "tasker_append_comment",
title: "NODE.DC Ops: Append Comment",
description: "Direct NODE.DC Ops MCP write tool. Append a comment to a granted Tasker/Ops card.",
requiredScopes: ["issue:comment"],
inputSchema: {
...projectAndIssueInputSchema,
properties: {
...projectAndIssueInputSchema.properties,
body: { type: "string" },
idempotency_key: { type: "string" },
},
required: ["issue_id", "project_id", "body"],
},
annotations: { destructiveHint: false, idempotentHint: false },
},
{
name: "tasker_attach_file",
title: "NODE.DC Ops: Attach File",
description:
"Direct NODE.DC Ops MCP write tool. Attach a base64-encoded local file to a granted card through Tasker's existing storage, quota, and deduplication path.",
requiredScopes: ["issue:attachment:write"],
inputSchema: {
...projectAndIssueInputSchema,
properties: {
...projectAndIssueInputSchema.properties,
file_name: { type: "string", description: "File name only; paths are rejected." },
mime_type: { type: "string" },
content_base64: {
type: "string",
maxLength: 7000000,
contentEncoding: "base64",
description: "Base64 file content. Raw decoded size must not exceed 5 MiB.",
},
idempotency_key: { type: "string" },
},
required: ["issue_id", "project_id", "file_name", "mime_type", "content_base64"],
},
annotations: { destructiveHint: false, idempotentHint: false },
},
{
name: "tasker_ensure_labels",
title: "NODE.DC Ops: Ensure Project Labels",
description:
"Direct NODE.DC Ops MCP write tool. Create missing labels in a granted project and return label ids for subsequent card labeling.",
requiredScopes: ["issue:label"],
inputSchema: {
...projectInputSchema,
properties: {
...projectInputSchema.properties,
labels: {
type: "array",
minItems: 1,
maxItems: 50,
items: {
type: "object",
properties: {
name: { type: "string" },
color: {
type: "string",
description: "Optional hex color in #RRGGBB format.",
},
},
required: ["name"],
additionalProperties: false,
},
},
idempotency_key: { type: "string" },
},
required: ["project_id", "labels"],
},
annotations: { destructiveHint: false, idempotentHint: true },
},
{
name: "tasker_set_issue_labels",
title: "NODE.DC Ops: Set Card Labels",
description: "Direct NODE.DC Ops MCP write tool. Replace card labels with existing or ensured labels from the granted project.",
requiredScopes: ["issue:label"],
inputSchema: {
...projectAndIssueInputSchema,
properties: {
...projectAndIssueInputSchema.properties,
label_ids: { type: "array", items: { type: "string" } },
idempotency_key: { type: "string" },
},
required: ["issue_id", "project_id", "label_ids"],
},
annotations: { destructiveHint: false, idempotentHint: true },
},
{
name: "tasker_assign_issue",
title: "NODE.DC Ops: Assign Card",
description: "Direct NODE.DC Ops MCP write tool. Replace card assignees with existing members of the granted project.",
requiredScopes: ["issue:assign"],
inputSchema: {
...projectAndIssueInputSchema,
properties: {
...projectAndIssueInputSchema.properties,
member_ids: { type: "array", items: { type: "string" } },
idempotency_key: { type: "string" },
},
required: ["issue_id", "project_id", "member_ids"],
},
annotations: { destructiveHint: false, idempotentHint: true },
},
];
const emptyArgsSchema = z.object({}).default({});
const projectArgsSchema = z.object({
project_id: z.string().min(1),
workspace_slug: z.string().min(1).nullish(),
});
const createProjectArgsSchema = z.object({
workspace_slug: z.string().min(1).max(255),
name: z.string().min(1).max(255),
identifier: z
.string()
.min(1)
.max(12)
.regex(/^[A-Za-z0-9_]+$/)
.optional(),
description: z.string().max(20_000).optional(),
});
const searchIssuesArgsSchema = projectArgsSchema.extend({
query: z.string().min(1).optional(),
});
const prioritySchema = z.enum(["none", "low", "medium", "high", "urgent"]);
const createIssueArgsSchema = z.object({
project_id: z.string().min(1),
workspace_slug: z.string().min(1).nullish(),
title: z.string().min(1).max(500),
description: z.string().max(20000).optional(),
priority: prioritySchema.optional(),
structured_blocks: structuredBlocksSchema.optional(),
idempotency_key: z.string().optional(),
});
const issueArgsSchema = z.object({
issue_id: z.string().min(1),
project_id: z.string().min(1),
workspace_slug: z.string().min(1).nullish(),
});
const updateIssueArgsSchema = issueArgsSchema.extend({
title: z.string().min(1).max(500).optional(),
description: z.string().max(20000).optional(),
priority: prioritySchema.optional(),
structured_blocks: structuredBlocksSchema.optional(),
});
const structuredBlocksArgsSchema = issueArgsSchema.extend({
structured_blocks: structuredBlocksSchema,
});
const moveIssueArgsSchema = issueArgsSchema.extend({
state_id: z.string().min(1),
});
const commentArgsSchema = issueArgsSchema.extend({
body: z.string().min(1).max(20000),
});
const attachFileArgsSchema = issueArgsSchema
.extend({
file_name: z
.string()
.min(1)
.max(255)
.refine((value) => !/[\\/\0]/.test(value), "file_name must not contain a path"),
mime_type: z
.string()
.min(3)
.max(255)
.regex(/^[A-Za-z0-9!#$&^_.+-]+\/[A-Za-z0-9!#$&^_.+-]+$/),
content_base64: z
.string()
.min(1)
.max(7_000_000)
.regex(/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/),
})
.superRefine((input, context) => {
if (Buffer.byteLength(input.content_base64, "base64") > 5 * 1024 * 1024) {
context.addIssue({
code: "custom",
path: ["content_base64"],
message: "Decoded attachment must not exceed 5 MiB.",
});
}
});
const ensureLabelsArgsSchema = projectArgsSchema.extend({
labels: z
.array(
z.object({
name: z.string().min(1).max(255),
color: z
.string()
.regex(/^#[0-9a-fA-F]{6}$/)
.optional(),
})
)
.min(1)
.max(50),
});
const labelsArgsSchema = issueArgsSchema.extend({
label_ids: z.array(z.string().min(1)).default([]),
});
const assigneesArgsSchema = issueArgsSchema.extend({
member_ids: z.array(z.string().min(1)).default([]),
});
export function getToolsForSession(session: AgentSessionRecord): McpToolRuntimeDefinition[] {
return mcpRuntimeTools.filter((tool) => tool.requiredScopes.every((scope) => hasScope(session, scope)));
}
export async function executeMcpTool(
session: AgentSessionRecord,
name: string,
rawArguments: unknown,
deps: ExecuteToolDeps,
options: ExecuteToolOptions = {}
): Promise<McpToolResult> {
const tool = mcpRuntimeTools.find((candidate) => candidate.name === name);
if (!tool) {
throw new Error(`Unknown MCP tool: ${name}`);
}
const { args, idempotencyKey } = prepareToolArguments(rawArguments, options.idempotencyKey);
const isWriteTool = tool.annotations?.readOnlyHint !== true;
if (!isWriteTool) {
return executeMcpToolOnce(session, name, args, deps);
}
if (!deps.agentsRepository) {
throw new ToolExecutionInputError("idempotency_unavailable", "Agent Gateway persistence is required for write tools.", 503);
}
if (!idempotencyKey) {
throw new ToolExecutionInputError("idempotency_key_required", "Write tools require an idempotency key.", 400);
}
const requestHash = hashToolRequest(name, args);
const claim = await deps.agentsRepository.claimIdempotencyKey(session.agent.id, idempotencyKey, requestHash);
if (claim.status === "replay") {
await deps.agentsRepository.createAuditEvent(session.agent.id, "agent.tool.replayed", session.agent.ownerUserId, {
source: options.source ?? "mcp",
toolName: name,
idempotencyKey,
});
return claim.responseBody as McpToolResult;
}
if (claim.status === "conflict") {
throw new ToolExecutionInputError("idempotency_key_conflict", "Idempotency key was already used with different arguments.", 409);
}
if (claim.status === "in_progress") {
throw new ToolExecutionInputError("idempotency_key_in_progress", "Idempotency key is currently processing.", 409, {
lockedUntil: claim.lockedUntil,
});
}
try {
const result = await executeMcpToolOnce(session, name, args, deps);
await deps.agentsRepository.completeIdempotencyKey(session.agent.id, idempotencyKey, result);
await deps.agentsRepository.createAuditEvent(session.agent.id, "agent.tool.executed", session.agent.ownerUserId, {
source: options.source ?? "mcp",
toolName: name,
idempotencyKey,
arguments: summarizeToolArguments(args),
});
return result;
} catch (error) {
await deps.agentsRepository.releaseIdempotencyKey(session.agent.id, idempotencyKey);
await deps.agentsRepository.createAuditEvent(session.agent.id, "agent.tool.failed", session.agent.ownerUserId, {
source: options.source ?? "mcp",
toolName: name,
idempotencyKey,
error: error instanceof Error ? error.name : "unknown_error",
message: error instanceof Error ? error.message : "Unknown tool execution error.",
});
throw error;
}
}
async function executeMcpToolOnce(
session: AgentSessionRecord,
name: string,
args: unknown,
deps: ExecuteToolDeps
): Promise<McpToolResult> {
switch (name) {
case "tasker_get_agent_instructions":
emptyArgsSchema.parse(args);
requireScope(session, "workspace:read");
return asToolResult(buildAgentInstructions(session));
case "tasker_list_projects":
emptyArgsSchema.parse(args);
requireScope(session, "project:read");
return asToolResult(await deps.taskerClient.listGrantedProjects(session));
case "tasker_get_project_context": {
const input = projectArgsSchema.parse(args);
requireScope(session, "project:read");
requireProjectGrant(session, { projectId: input.project_id, workspaceSlug: input.workspace_slug });
return asToolResult(await deps.taskerClient.getProjectContext(session, input.project_id, input.workspace_slug));
}
case "tasker_create_project": {
const input = createProjectArgsSchema.parse(args);
if (session.grantSource !== "agent") {
throw new ForbiddenError("Project creation requires an agent-scoped local token.");
}
const sourceGrant = requireWorkspaceScope(session, "project:create", input.workspace_slug);
if (!deps.agentsRepository) {
throw new ToolExecutionInputError("agent_repository_unavailable", "Agent Gateway persistence is required.", 503);
}
const payload = await deps.taskerClient.createProject(session, input);
const createdProject = extractCreatedProject(payload);
await deps.agentsRepository.upsertGrant(session.agent.id, {
workspaceSlug: input.workspace_slug,
projectId: createdProject.id,
scopes: sourceGrant.scopes,
mode: sourceGrant.mode,
createdByUserId: session.agent.ownerUserId,
});
return asToolResult(payload);
}
case "tasker_search_issues": {
const input = searchIssuesArgsSchema.parse(args);
requireToolAccess(session, "issue:read", input.project_id, input.workspace_slug);
return asToolResult(await deps.taskerClient.listIssues(session, input.project_id, input.workspace_slug, input.query));
}
case "tasker_get_issue": {
const input = issueArgsSchema.parse(args);
requireToolAccess(session, "issue:read", input.project_id, input.workspace_slug);
return asToolResult(await deps.taskerClient.getIssue(session, input.issue_id, input));
}
case "tasker_create_issue": {
const input = createIssueArgsSchema.parse(args);
requireToolAccess(session, "issue:create", input.project_id, input.workspace_slug);
return asToolResult(await deps.taskerClient.createIssue(session, input));
}
case "tasker_update_issue": {
const input = updateIssueArgsSchema.parse(args);
if (input.structured_blocks) {
requireProjectScopes(session, input.project_id, input.workspace_slug, ["issue:update", "issue:structured_blocks:write"]);
} else {
requireToolAccess(session, "issue:update", input.project_id, input.workspace_slug);
}
return asToolResult(await deps.taskerClient.updateIssue(session, input.issue_id, input));
}
case "tasker_update_structured_blocks": {
const input = structuredBlocksArgsSchema.parse(args);
requireProjectScopes(session, input.project_id, input.workspace_slug, ["issue:update", "issue:structured_blocks:write"]);
return asToolResult(
await deps.taskerClient.updateIssue(session, input.issue_id, {
project_id: input.project_id,
workspace_slug: input.workspace_slug,
structured_blocks: input.structured_blocks,
})
);
}
case "tasker_move_issue": {
const input = moveIssueArgsSchema.parse(args);
requireToolAccess(session, "issue:move", input.project_id, input.workspace_slug);
return asToolResult(await deps.taskerClient.moveIssue(session, input.issue_id, input));
}
case "tasker_append_comment": {
const input = commentArgsSchema.parse(args);
requireToolAccess(session, "issue:comment", input.project_id, input.workspace_slug);
return asToolResult(await deps.taskerClient.appendComment(session, input.issue_id, input));
}
case "tasker_attach_file": {
const input = attachFileArgsSchema.parse(args);
requireToolAccess(session, "issue:attachment:write", input.project_id, input.workspace_slug);
return asToolResult(await deps.taskerClient.attachFile(session, input.issue_id, input));
}
case "tasker_ensure_labels": {
const input = ensureLabelsArgsSchema.parse(args);
requireToolAccess(session, "issue:label", input.project_id, input.workspace_slug);
return asToolResult(await deps.taskerClient.ensureLabels(session, input));
}
case "tasker_set_issue_labels": {
const input = labelsArgsSchema.parse(args);
requireToolAccess(session, "issue:label", input.project_id, input.workspace_slug);
return asToolResult(await deps.taskerClient.setLabels(session, input.issue_id, input));
}
case "tasker_assign_issue": {
const input = assigneesArgsSchema.parse(args);
requireToolAccess(session, "issue:assign", input.project_id, input.workspace_slug);
return asToolResult(await deps.taskerClient.assignIssue(session, input.issue_id, input));
}
default:
throw new Error(`Unknown MCP tool: ${name}`);
}
}
function hasScope(session: AgentSessionRecord, scope: AgentScope): boolean {
return session.grants.some((grant) => grant.scopes.includes(scope));
}
function requireToolAccess(session: AgentSessionRecord, scope: AgentScope, projectId: string, workspaceSlug?: string | null): void {
requireProjectScopes(session, projectId, workspaceSlug, [scope]);
}
function requireWorkspaceScope(session: AgentSessionRecord, scope: AgentScope, workspaceSlug: string) {
requireScope(session, scope);
const grant = session.grants.find(
(candidate) => candidate.workspaceSlug === workspaceSlug && candidate.scopes.includes(scope)
);
if (!grant) {
throw new ForbiddenError(`Grant for workspace does not include required scope: ${scope}.`);
}
return grant;
}
function requireProjectScopes(
session: AgentSessionRecord,
projectId: string,
workspaceSlug: string | null | undefined,
scopes: AgentScope[]
): void {
for (const scope of scopes) {
requireScope(session, scope);
}
const grant = requireProjectGrant(session, { projectId, workspaceSlug });
for (const scope of scopes) {
if (!grant.scopes.includes(scope)) {
throw new ForbiddenError(`Grant for project does not include required scope: ${scope}.`);
}
}
}
function asToolResult(payload: unknown): McpToolResult {
return {
content: [
{
type: "text",
text: JSON.stringify(payload, null, 2),
},
],
structuredContent: payload,
isError: false,
};
}
function extractCreatedProject(payload: unknown): { id: string } {
if (!isPlainRecord(payload) || !isPlainRecord(payload.project) || typeof payload.project.id !== "string") {
throw new ToolExecutionInputError(
"tasker_project_response_invalid",
"Tasker did not return the created project id.",
502
);
}
return { id: payload.project.id };
}
export class ToolExecutionInputError extends Error {
constructor(
readonly code: string,
message: string,
readonly httpStatus: number,
readonly details?: Record<string, unknown>
) {
super(message);
this.name = "ToolExecutionInputError";
}
}
function prepareToolArguments(rawArguments: unknown, headerIdempotencyKey?: string | null): { args: unknown; idempotencyKey: string | null } {
if (!isPlainRecord(rawArguments)) {
return {
args: rawArguments ?? {},
idempotencyKey: normalizeIdempotencyKey(headerIdempotencyKey),
};
}
const { idempotency_key: bodyIdempotencyKey, ...args } = rawArguments;
return {
args,
idempotencyKey: normalizeIdempotencyKey(headerIdempotencyKey ?? (typeof bodyIdempotencyKey === "string" ? bodyIdempotencyKey : null)),
};
}
function normalizeIdempotencyKey(value?: string | null): string | null {
const normalized = value?.trim();
if (!normalized) {
return null;
}
if (normalized.length > 200) {
throw new ToolExecutionInputError("idempotency_key_invalid", "Idempotency key must be 200 characters or fewer.", 400);
}
return normalized;
}
function hashToolRequest(name: string, args: unknown): string {
return createHash("sha256").update(stableStringify({ name, args })).digest("hex");
}
function stableStringify(value: unknown): string {
if (Array.isArray(value)) {
return `[${value.map((item) => stableStringify(item)).join(",")}]`;
}
if (isPlainRecord(value)) {
return `{${Object.keys(value)
.sort()
.map((key) => `${JSON.stringify(key)}:${stableStringify(value[key])}`)
.join(",")}}`;
}
return JSON.stringify(value);
}
function summarizeToolArguments(args: unknown): Record<string, unknown> {
if (!isPlainRecord(args)) {
return {};
}
return {
workspace_slug: args.workspace_slug,
project_id: args.project_id,
issue_id: args.issue_id,
state_id: args.state_id,
project_name: args.name,
file_name: args.file_name,
attachment_bytes:
typeof args.content_base64 === "string" ? Buffer.byteLength(args.content_base64, "base64") : undefined,
labels_count: Array.isArray(args.labels) ? args.labels.length : undefined,
has_structured_blocks: Array.isArray(args.structured_blocks),
};
}
function isPlainRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function buildAgentInstructions(session: AgentSessionRecord): Record<string, unknown> {
return {
agent: {
id: session.agent.id,
display_name: session.agent.displayName,
owner_user_id: session.agent.ownerUserId,
},
grants: session.grants.map((grant) => ({
workspace_slug: grant.workspaceSlug,
project_id: grant.projectId,
mode: grant.mode,
scopes: grant.scopes,
})),
grant_source: session.grantSource,
rules: {
card_structure: [
"Keep the main issue description concise and conceptual.",
"Use structured text blocks for current architecture, planned architecture, and implementation notes.",
"Every structured text block must use its title field for the heading; do not duplicate markdown headings like ## Status inside the body.",
"Wrong structured text block: title omitted and body starts with ## Текущая архитектура. Correct: title is Текущая архитектура and body contains only the section content.",
"Use checker blocks with explicit titles for short verifiable phase items.",
"After implementation, add a factual implementation block with files touched and validation performed.",
],
labels: [
"Use tasker_ensure_labels before setting a label that is not already present in project context.",
"Use tasker_set_issue_labels with label ids returned by project context or tasker_ensure_labels.",
],
hard_limits: [
"Do not delete or archive issues.",
"Create projects only through tasker_create_project when the effective workspace grant includes project:create.",
"Attach files only through tasker_attach_file; never upload from arbitrary URLs or server paths.",
"Do not create states, workspace invites, or workspace settings changes.",
"Only create labels through tasker_ensure_labels inside granted projects.",
"Only assign existing project members.",
"Only use projects and workspaces present in effective grants.",
],
reporting_mode: "If a grant has mode=reporting, keep issue status and comments up to date without pretending to enforce unmanaged local Codex execution.",
},
};
}

View File

@ -0,0 +1,81 @@
import type { FastifyInstance } from "fastify";
import { DatabaseNotConfiguredError } from "../db/pool.js";
import type { AgentsRepository } from "../repositories/agents.js";
import { parseBearerToken } from "../security/bearer.js";
const MCP_PROTOCOL_VERSION = "2025-06-18";
type OntologyRouteDeps = {
agentsRepository: AgentsRepository | null;
ontologyCoreUrl: string;
ontologyCoreAccessToken?: string;
};
export async function registerOntologyGatewayRoutes(app: FastifyInstance, deps: OntologyRouteDeps): Promise<void> {
app.post("/ontology-mcp", async (request, reply) => {
if (!deps.agentsRepository) {
throw new DatabaseNotConfiguredError();
}
const token = parseBearerToken(request.headers.authorization);
const session = await deps.agentsRepository.findActiveSessionByToken(token, "ontology");
if (!session) {
return reply.status(401).send({
ok: false,
error: "ontology_agent_unauthorized",
message: "Ontology token is inactive, expired, revoked, or has the wrong purpose.",
});
}
const upstreamToken = deps.ontologyCoreAccessToken;
if (!upstreamToken) {
return reply.status(503).send({
ok: false,
error: "ontology_proxy_not_configured",
message: "Ontology Core access token is not configured.",
});
}
let upstream: Response;
try {
upstream = await fetch(new URL("/mcp", deps.ontologyCoreUrl), {
method: "POST",
redirect: "manual",
headers: {
Accept: readHeader(request.headers.accept) ?? "application/json, text/event-stream",
Authorization: `Bearer ${upstreamToken}`,
"Content-Type": "application/json",
"MCP-Protocol-Version": readHeader(request.headers["mcp-protocol-version"]) ?? MCP_PROTOCOL_VERSION,
},
body: JSON.stringify(request.body ?? {}),
signal: AbortSignal.timeout(60_000),
});
} catch {
return reply.status(503).send({
ok: false,
error: "ontology_proxy_unavailable",
message: "Ontology Core MCP is unavailable.",
});
}
const payload = await upstream.text();
reply.status(upstream.status);
reply.header("Cache-Control", "no-store");
reply.header("Content-Type", upstream.headers.get("content-type") ?? "application/json; charset=utf-8");
for (const header of ["mcp-protocol-version", "mcp-session-id", "vary"]) {
const value = upstream.headers.get(header);
if (value) {
reply.header(header, value);
}
}
return reply.send(payload);
});
}
function readHeader(value: string | string[] | undefined): string | undefined {
if (Array.isArray(value)) {
return value[0];
}
return value;
}

View File

@ -0,0 +1,204 @@
import type { FastifyInstance } from "fastify";
import { executeMcpTool } from "../mcp/tool-runtime.js";
import type { AgentsRepository } from "../repositories/agents.js";
import type { TaskerClient } from "../tasker/client.js";
import { authenticateAgent } from "./session.js";
type ToolRouteDeps = {
agentsRepository: AgentsRepository | null;
taskerClient: TaskerClient;
};
export async function registerToolRoutes(app: FastifyInstance, deps: ToolRouteDeps): Promise<void> {
app.post("/api/v1/tools/projects", async (request) => {
const session = await authenticateAgent(request, deps);
const result = await executeMcpTool(session, "tasker_create_project", request.body, deps, toolOptions(request));
return result.structuredContent;
});
app.get("/api/v1/tools/projects", async (request) => {
const session = await authenticateAgent(request, deps);
const result = await executeMcpTool(session, "tasker_list_projects", {}, deps, toolOptions(request));
return result.structuredContent;
});
app.get("/api/v1/tools/projects/:projectId/context", async (request) => {
const session = await authenticateAgent(request, deps);
const params = request.params as { projectId: string };
const query = request.query as { workspace_slug?: string };
const result = await executeMcpTool(
session,
"tasker_get_project_context",
{
project_id: params.projectId,
workspace_slug: query.workspace_slug,
},
deps,
toolOptions(request)
);
return result.structuredContent;
});
app.post("/api/v1/tools/projects/:projectId/labels/ensure", async (request) => {
const session = await authenticateAgent(request, deps);
const params = request.params as { projectId: string };
const result = await executeMcpTool(
session,
"tasker_ensure_labels",
{
...requestBodyRecord(request.body),
project_id: params.projectId,
},
deps,
toolOptions(request)
);
return result.structuredContent;
});
app.get("/api/v1/tools/issues", async (request) => {
const session = await authenticateAgent(request, deps);
const query = request.query as { project_id?: string; workspace_slug?: string; query?: string };
const result = await executeMcpTool(session, "tasker_search_issues", query, deps, toolOptions(request));
return result.structuredContent;
});
app.post("/api/v1/tools/issues", async (request) => {
const session = await authenticateAgent(request, deps);
const result = await executeMcpTool(session, "tasker_create_issue", request.body, deps, toolOptions(request));
return result.structuredContent;
});
app.get("/api/v1/tools/issues/:issueId", async (request) => {
const session = await authenticateAgent(request, deps);
const params = request.params as { issueId: string };
const query = request.query as { project_id?: string; workspace_slug?: string };
const result = await executeMcpTool(
session,
"tasker_get_issue",
{ ...query, issue_id: params.issueId },
deps,
toolOptions(request)
);
return result.structuredContent;
});
app.patch("/api/v1/tools/issues/:issueId", async (request) => {
const session = await authenticateAgent(request, deps);
const params = request.params as { issueId: string };
const result = await executeMcpTool(
session,
"tasker_update_issue",
{
...requestBodyRecord(request.body),
issue_id: params.issueId,
},
deps,
toolOptions(request)
);
return result.structuredContent;
});
app.post("/api/v1/tools/issues/:issueId/move", async (request) => {
const session = await authenticateAgent(request, deps);
const params = request.params as { issueId: string };
const result = await executeMcpTool(
session,
"tasker_move_issue",
{
...requestBodyRecord(request.body),
issue_id: params.issueId,
},
deps,
toolOptions(request)
);
return result.structuredContent;
});
app.post("/api/v1/tools/issues/:issueId/comments", async (request) => {
const session = await authenticateAgent(request, deps);
const params = request.params as { issueId: string };
const result = await executeMcpTool(
session,
"tasker_append_comment",
{
...requestBodyRecord(request.body),
issue_id: params.issueId,
},
deps,
toolOptions(request)
);
return result.structuredContent;
});
app.post("/api/v1/tools/issues/:issueId/attachments", async (request) => {
const session = await authenticateAgent(request, deps);
const params = request.params as { issueId: string };
const result = await executeMcpTool(
session,
"tasker_attach_file",
{
...requestBodyRecord(request.body),
issue_id: params.issueId,
},
deps,
toolOptions(request)
);
return result.structuredContent;
});
app.put("/api/v1/tools/issues/:issueId/labels", async (request) => {
const session = await authenticateAgent(request, deps);
const params = request.params as { issueId: string };
const result = await executeMcpTool(
session,
"tasker_set_issue_labels",
{
...requestBodyRecord(request.body),
issue_id: params.issueId,
},
deps,
toolOptions(request)
);
return result.structuredContent;
});
app.put("/api/v1/tools/issues/:issueId/assignees", async (request) => {
const session = await authenticateAgent(request, deps);
const params = request.params as { issueId: string };
const result = await executeMcpTool(
session,
"tasker_assign_issue",
{
...requestBodyRecord(request.body),
issue_id: params.issueId,
},
deps,
toolOptions(request)
);
return result.structuredContent;
});
}
function toolOptions(request: { headers: Record<string, string | string[] | undefined> }): { source: "rest"; idempotencyKey: string | null } {
return {
source: "rest",
idempotencyKey: readHeader(request.headers["idempotency-key"]),
};
}
function requestBodyRecord(body: unknown): Record<string, unknown> {
if (typeof body === "object" && body !== null && !Array.isArray(body)) {
return body as Record<string, unknown>;
}
return {};
}
function readHeader(value: string | string[] | undefined): string | null {
if (Array.isArray(value)) {
return value[0] ?? null;
}
return value ?? null;
}

View File

@ -0,0 +1,25 @@
import { createHash, randomBytes } from "node:crypto";
const TOKEN_PREFIX = "ndcag";
const ONTOLOGY_TOKEN_PREFIX = "ndcao";
const SETUP_CODE_PREFIX = "ndcsetup";
export function generateAgentToken(): string {
return `${TOKEN_PREFIX}_${randomBytes(32).toString("base64url")}`;
}
export function generateOntologyAgentToken(): string {
return `${ONTOLOGY_TOKEN_PREFIX}_${randomBytes(32).toString("base64url")}`;
}
export function hashAgentToken(token: string): string {
return createHash("sha256").update(token).digest("hex");
}
export function generateAgentSetupCode(): string {
return `${SETUP_CODE_PREFIX}_${randomBytes(32).toString("base64url")}`;
}
export function hashAgentSetupCode(code: string): string {
return createHash("sha256").update(code).digest("hex");
}

View File

@ -0,0 +1,332 @@
import type { AgentSessionRecord } from "../repositories/agents.js";
export class TaskerAdapterNotConfiguredError extends Error {
constructor() {
super("NODEDC_INTERNAL_ACCESS_TOKEN is required for Tasker adapter calls.");
this.name = "TaskerAdapterNotConfiguredError";
}
}
export class TaskerAdapterError extends Error {
constructor(
message: string,
readonly statusCode: number,
readonly payload: unknown
) {
super(message);
this.name = "TaskerAdapterError";
}
}
export class TaskerAdapterUnavailableError extends Error {
constructor(readonly causeError: unknown) {
super("Tasker internal adapter is unavailable.");
this.name = "TaskerAdapterUnavailableError";
}
}
export type TaskerClientConfig = {
baseUrl: string;
internalAccessToken?: string;
};
export type TaskerAgentContext = {
agentId: string;
ownerUserId: string;
tokenId: string;
};
export type GrantedProjectInput = {
workspace_slug: string;
project_id: string | null;
mode: string;
scopes: string[];
};
export type ListGrantedProjectsInput = {
grants: GrantedProjectInput[];
};
export type CreateIssueInput = {
project_id: string;
workspace_slug?: string | null;
title: string;
description?: string;
priority?: "none" | "low" | "medium" | "high" | "urgent";
structured_blocks?: unknown[];
};
export type CreateProjectInput = {
workspace_slug: string;
name: string;
identifier?: string;
description?: string;
};
export type GetIssueInput = {
project_id: string;
workspace_slug?: string | null;
};
export type UpdateIssueInput = {
project_id: string;
workspace_slug?: string | null;
title?: string;
description?: string;
priority?: "none" | "low" | "medium" | "high" | "urgent";
structured_blocks?: unknown[];
};
export type MoveIssueInput = {
project_id: string;
workspace_slug?: string | null;
state_id: string;
};
export type CommentInput = {
project_id: string;
workspace_slug?: string | null;
body: string;
};
export type AttachFileInput = {
project_id: string;
workspace_slug?: string | null;
file_name: string;
mime_type: string;
content_base64: string;
};
export type SetLabelsInput = {
project_id: string;
workspace_slug?: string | null;
label_ids: string[];
};
export type EnsureLabelsInput = {
project_id: string;
workspace_slug?: string | null;
labels: Array<{
name: string;
color?: string;
}>;
};
export type AssignIssueInput = {
project_id: string;
workspace_slug?: string | null;
member_ids: string[];
};
export class TaskerClient {
constructor(private readonly config: TaskerClientConfig) {}
async listGrantedProjects(session: AgentSessionRecord): Promise<unknown> {
return this.request("/api/internal/nodedc/agent/projects/resolve", {
method: "POST",
session,
body: {
grants: session.grants.map((grant) => ({
workspace_slug: grant.workspaceSlug,
project_id: grant.projectId,
mode: grant.mode,
scopes: grant.scopes,
})),
} satisfies ListGrantedProjectsInput,
});
}
async getProjectContext(session: AgentSessionRecord, projectId: string, workspaceSlug?: string | null): Promise<unknown> {
const searchParams = new URLSearchParams();
if (workspaceSlug) {
searchParams.set("workspace_slug", workspaceSlug);
}
return this.request(`/api/internal/nodedc/agent/projects/${encodeURIComponent(projectId)}/context?${searchParams.toString()}`, {
method: "GET",
session,
});
}
async createProject(session: AgentSessionRecord, input: CreateProjectInput): Promise<unknown> {
return this.request("/api/internal/nodedc/agent/projects", {
method: "POST",
session,
body: input,
});
}
async listIssues(session: AgentSessionRecord, projectId: string, workspaceSlug?: string | null, query?: string): Promise<unknown> {
const searchParams = new URLSearchParams({ project_id: projectId });
if (workspaceSlug) {
searchParams.set("workspace_slug", workspaceSlug);
}
if (query) {
searchParams.set("query", query);
}
return this.request(`/api/internal/nodedc/agent/issues?${searchParams.toString()}`, {
method: "GET",
session,
});
}
async getIssue(session: AgentSessionRecord, issueId: string, input: GetIssueInput): Promise<unknown> {
const searchParams = new URLSearchParams({ project_id: input.project_id });
if (input.workspace_slug) {
searchParams.set("workspace_slug", input.workspace_slug);
}
return this.request(`/api/internal/nodedc/agent/issues/${encodeURIComponent(issueId)}?${searchParams.toString()}`, {
method: "GET",
session,
});
}
async createIssue(session: AgentSessionRecord, input: CreateIssueInput): Promise<unknown> {
return this.request("/api/internal/nodedc/agent/issues", {
method: "POST",
session,
body: input,
});
}
async updateIssue(session: AgentSessionRecord, issueId: string, input: UpdateIssueInput): Promise<unknown> {
return this.request(`/api/internal/nodedc/agent/issues/${encodeURIComponent(issueId)}`, {
method: "PATCH",
session,
body: input,
});
}
async moveIssue(session: AgentSessionRecord, issueId: string, input: MoveIssueInput): Promise<unknown> {
return this.request(`/api/internal/nodedc/agent/issues/${encodeURIComponent(issueId)}/move`, {
method: "POST",
session,
body: input,
});
}
async appendComment(session: AgentSessionRecord, issueId: string, input: CommentInput): Promise<unknown> {
return this.request(`/api/internal/nodedc/agent/issues/${encodeURIComponent(issueId)}/comments`, {
method: "POST",
session,
body: input,
});
}
async attachFile(session: AgentSessionRecord, issueId: string, input: AttachFileInput): Promise<unknown> {
return this.request(`/api/internal/nodedc/agent/issues/${encodeURIComponent(issueId)}/attachments`, {
method: "POST",
session,
body: input,
});
}
async setLabels(session: AgentSessionRecord, issueId: string, input: SetLabelsInput): Promise<unknown> {
return this.request(`/api/internal/nodedc/agent/issues/${encodeURIComponent(issueId)}/labels`, {
method: "PUT",
session,
body: input,
});
}
async ensureLabels(session: AgentSessionRecord, input: EnsureLabelsInput): Promise<unknown> {
return this.request(`/api/internal/nodedc/agent/projects/${encodeURIComponent(input.project_id)}/labels/ensure`, {
method: "POST",
session,
body: input,
});
}
async assignIssue(session: AgentSessionRecord, issueId: string, input: AssignIssueInput): Promise<unknown> {
return this.request(`/api/internal/nodedc/agent/issues/${encodeURIComponent(issueId)}/assignees`, {
method: "PUT",
session,
body: input,
});
}
private async request(
path: string,
input: {
method: "GET" | "POST" | "PATCH" | "PUT";
session: AgentSessionRecord;
body?: unknown;
}
): Promise<unknown> {
if (!this.config.internalAccessToken) {
throw new TaskerAdapterNotConfiguredError();
}
const response = await this.fetchTasker(path, input);
const payload = await readResponsePayload(response);
if (!response.ok) {
throw new TaskerAdapterError("Tasker internal adapter request failed.", response.status, payload);
}
return payload;
}
private async fetchTasker(
path: string,
input: {
method: "GET" | "POST" | "PATCH" | "PUT";
session: AgentSessionRecord;
body?: unknown;
}
): Promise<Response> {
try {
const requestBody = attachAgentMetadata(input.session, input.body);
return await fetch(new URL(path, this.config.baseUrl), {
method: input.method,
headers: {
Authorization: `Bearer ${this.config.internalAccessToken}`,
"Content-Type": "application/json",
"X-NODEDC-Agent-Id": input.session.agent.id,
"X-NODEDC-Agent-Owner-User-Id": input.session.agent.ownerUserId,
"X-NODEDC-Agent-Token-Id": input.session.token.id,
},
body: requestBody === undefined ? undefined : JSON.stringify(requestBody),
});
} catch (error) {
throw new TaskerAdapterUnavailableError(error);
}
}
}
function attachAgentMetadata(session: AgentSessionRecord, body: unknown): unknown {
if (body === undefined || !isPlainRecord(body)) {
return body;
}
return {
...body,
_agent: {
display_name: session.agent.displayName,
avatar_url: session.agent.avatarUrl,
},
};
}
function isPlainRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
async function readResponsePayload(response: Response): Promise<unknown> {
const text = await response.text();
if (!text) {
return null;
}
try {
return JSON.parse(text);
} catch {
return text;
}
}

View File

@ -0,0 +1,179 @@
# Copyright (c) 2023-present Plane Software, Inc. and contributors
# SPDX-License-Identifier: AGPL-3.0-only
# See the LICENSE file for details.
"""plane URL Configuration"""
from django.conf import settings
from django.urls import include, path, re_path
from drf_spectacular.views import (
SpectacularAPIView,
SpectacularRedocView,
SpectacularSwaggerView,
)
from plane.authentication.views.nodedc_logout import (
NodeDCFrontChannelLogoutEndpoint,
NodeDCInternalSessionLogoutEndpoint,
)
from plane.authentication.views.nodedc_agent_adapter import (
NodeDCAgentIssueAssigneesEndpoint,
NodeDCAgentIssueAttachmentEndpoint,
NodeDCAgentIssueCommentEndpoint,
NodeDCAgentIssueLabelsEndpoint,
NodeDCAgentIssueListEndpoint,
NodeDCAgentIssueMoveEndpoint,
NodeDCAgentIssueUpdateEndpoint,
NodeDCAgentProjectContextEndpoint,
NodeDCAgentProjectCreateEndpoint,
NodeDCAgentProjectLabelsEnsureEndpoint,
NodeDCAgentProjectResolveEndpoint,
)
from plane.authentication.views.nodedc_workspace_adapter import (
NodeDCInternalProjectMembershipEnsureEndpoint,
NodeDCInternalProjectMembershipRemoveEndpoint,
NodeDCInternalUserProfileSyncEndpoint,
NodeDCInternalWorkspaceInviteApproveEndpoint,
NodeDCInternalWorkspaceInviteRejectEndpoint,
NodeDCInternalWorkspaceListEndpoint,
NodeDCInternalWorkspaceMembershipEnsureEndpoint,
NodeDCInternalWorkspaceMembershipRemoveEndpoint,
)
handler404 = "plane.app.views.error_404.custom_404_view"
urlpatterns = [
path(
"api/internal/nodedc/logout/",
NodeDCInternalSessionLogoutEndpoint.as_view(),
name="nodedc-internal-session-logout",
),
path(
"api/internal/nodedc/workspaces/",
NodeDCInternalWorkspaceListEndpoint.as_view(),
name="nodedc-internal-workspaces",
),
path(
"api/internal/nodedc/users/profile-sync/",
NodeDCInternalUserProfileSyncEndpoint.as_view(),
name="nodedc-internal-user-profile-sync",
),
path(
"api/internal/nodedc/workspace-memberships/ensure/",
NodeDCInternalWorkspaceMembershipEnsureEndpoint.as_view(),
name="nodedc-internal-workspace-membership-ensure",
),
path(
"api/internal/nodedc/workspace-memberships/remove/",
NodeDCInternalWorkspaceMembershipRemoveEndpoint.as_view(),
name="nodedc-internal-workspace-membership-remove",
),
path(
"api/internal/nodedc/workspace-invite-requests/approve/",
NodeDCInternalWorkspaceInviteApproveEndpoint.as_view(),
name="nodedc-internal-workspace-invite-approve",
),
path(
"api/internal/nodedc/workspace-invite-requests/reject/",
NodeDCInternalWorkspaceInviteRejectEndpoint.as_view(),
name="nodedc-internal-workspace-invite-reject",
),
path(
"api/internal/nodedc/project-memberships/ensure/",
NodeDCInternalProjectMembershipEnsureEndpoint.as_view(),
name="nodedc-internal-project-membership-ensure",
),
path(
"api/internal/nodedc/project-memberships/remove/",
NodeDCInternalProjectMembershipRemoveEndpoint.as_view(),
name="nodedc-internal-project-membership-remove",
),
path(
"api/internal/nodedc/agent/projects/resolve",
NodeDCAgentProjectResolveEndpoint.as_view(),
name="nodedc-agent-project-resolve",
),
path(
"api/internal/nodedc/agent/projects",
NodeDCAgentProjectCreateEndpoint.as_view(),
name="nodedc-agent-project-create",
),
path(
"api/internal/nodedc/agent/projects/<uuid:project_id>/context",
NodeDCAgentProjectContextEndpoint.as_view(),
name="nodedc-agent-project-context",
),
path(
"api/internal/nodedc/agent/projects/<uuid:project_id>/labels/ensure",
NodeDCAgentProjectLabelsEnsureEndpoint.as_view(),
name="nodedc-agent-project-labels-ensure",
),
path(
"api/internal/nodedc/agent/issues",
NodeDCAgentIssueListEndpoint.as_view(),
name="nodedc-agent-issue-list",
),
path(
"api/internal/nodedc/agent/issues/<uuid:issue_id>",
NodeDCAgentIssueUpdateEndpoint.as_view(),
name="nodedc-agent-issue-update",
),
path(
"api/internal/nodedc/agent/issues/<uuid:issue_id>/move",
NodeDCAgentIssueMoveEndpoint.as_view(),
name="nodedc-agent-issue-move",
),
path(
"api/internal/nodedc/agent/issues/<uuid:issue_id>/comments",
NodeDCAgentIssueCommentEndpoint.as_view(),
name="nodedc-agent-issue-comment",
),
path(
"api/internal/nodedc/agent/issues/<uuid:issue_id>/attachments",
NodeDCAgentIssueAttachmentEndpoint.as_view(),
name="nodedc-agent-issue-attachment",
),
path(
"api/internal/nodedc/agent/issues/<uuid:issue_id>/labels",
NodeDCAgentIssueLabelsEndpoint.as_view(),
name="nodedc-agent-issue-labels",
),
path(
"api/internal/nodedc/agent/issues/<uuid:issue_id>/assignees",
NodeDCAgentIssueAssigneesEndpoint.as_view(),
name="nodedc-agent-issue-assignees",
),
path("api/", include("plane.app.urls")),
path("api/public/", include("plane.space.urls")),
path("api/instances/", include("plane.license.urls")),
path("api/v1/", include("plane.api.urls")),
path("auth/", include("plane.authentication.urls")),
path(
"logout",
NodeDCFrontChannelLogoutEndpoint.as_view(),
name="nodedc-frontchannel-logout",
),
path("", include("plane.web.urls")),
]
if settings.ENABLE_DRF_SPECTACULAR:
urlpatterns += [
path("api/schema/", SpectacularAPIView.as_view(), name="schema"),
path(
"api/schema/swagger-ui/",
SpectacularSwaggerView.as_view(url_name="schema"),
name="swagger-ui",
),
path(
"api/schema/redoc/",
SpectacularRedocView.as_view(url_name="schema"),
name="redoc",
),
]
if settings.DEBUG:
try:
import debug_toolbar
urlpatterns = [re_path(r"^__debug__/", include(debug_toolbar.urls))] + urlpatterns
except ImportError:
pass

View File

@ -0,0 +1,69 @@
{
"schemaVersion": "nodedc.ops-mcp-workspace-tools.release.v1",
"release": "20260718-001",
"components": {
"tasker": {
"artifactId": "tasker-ops-mcp-capabilities-20260718-001",
"files": [
"plane-src/apps/api/plane/authentication/views/nodedc_agent_adapter.py",
"plane-src/apps/api/plane/urls.py",
"plane-src/apps/web/core/components/workspace/settings/codex-agent-api-settings.tsx"
],
"predecessors": {
"plane-src/apps/api/plane/authentication/views/nodedc_agent_adapter.py": "a7653f4b5d3eff905de6cac0013e44a492a4b38fc075af5330e928a367529581",
"plane-src/apps/api/plane/urls.py": "413c33527ea564735b38ce6a5531aaa93018c1dba3d09f6902879f2b98920d85",
"plane-src/apps/web/core/components/workspace/settings/codex-agent-api-settings.tsx": "fdee183d332327f72f000429f3ad3f780937aea72b7c03a47deaba6b4cc2a050"
},
"newPaths": []
},
"ops-agents": {
"artifactId": "ops-agents-workspace-tools-ontology-20260718-001",
"files": [
"README.md",
"docker-compose.synology.yml",
"docs/ARCHITECTURE.md",
"docs/MCP_TOOLS_CONTRACT.md",
"docs/TASKER_API_AUDIT.md",
"docs/THREAT_MODEL.md",
"migrations/006_agent_credential_purpose.sql",
"src/app.ts",
"src/assets/codex-npm/README.md",
"src/assets/codex-npm/bin/nodedc-ops-codex.mjs",
"src/assets/codex-npm/package.json",
"src/config.ts",
"src/domain/scopes.ts",
"src/mcp/tool-runtime.ts",
"src/repositories/agents.ts",
"src/routes/agents.ts",
"src/routes/ontology.ts",
"src/routes/tools.ts",
"src/security/agent-access.ts",
"src/tasker/client.ts"
],
"predecessors": {
"README.md": "e01239462fe92330fe3532abe4dd8f566e8ff3d47584611723ae1689dec3e7f9",
"docker-compose.synology.yml": "944034b86fd5b22acea4314c9217d7e12febee5cd757365c32ebbcaccc64f3a8",
"docs/ARCHITECTURE.md": "c2db9552eb8324b429b8d87ffdd7bb527b9b155f9763ad5f8e3629b5c7c8cdcf",
"docs/MCP_TOOLS_CONTRACT.md": "6699716ede1fab93216c4c54a52c2a834f4b4f53cae62e5e3ed5bb6d0c5fa854",
"docs/TASKER_API_AUDIT.md": "c8febffb41f2fb4e8d0edab9eb0b9f23419aafdfcc676e7976f52a9dfc6075a4",
"docs/THREAT_MODEL.md": "b7fb71693e25a8ab84db1e811f95858aafbccea6e6ad2eca8b597b3563a83657",
"src/app.ts": "4ebeae1ee45447272c4817667a403f0ed9addc6ff7f27af23ebadbb451a3e17b",
"src/assets/codex-npm/README.md": "cc4a7c6558570b0dd93e8a362fc91282a351e71afad161ec44c72473d72490bc",
"src/assets/codex-npm/bin/nodedc-ops-codex.mjs": "1aee52f7b748eeccee5fc9e8cea511b60ced879c51fe138e0f820181ee85324f",
"src/assets/codex-npm/package.json": "300a88a05265c68098bedda6b60ed9e9cd0d198520bf1eddaa3a1b2679bed2ea",
"src/config.ts": "0b65404fa52c23b6fbc628b8641f04213ed57698c8b0192daf9741ed98e508cc",
"src/domain/scopes.ts": "85d9400171019cbe10dd4a50959df4952a941f16d0657006d2402b7a9e126672",
"src/mcp/tool-runtime.ts": "fac6e5596a1481cdaf8c7b5d275758eb440fc54d2e938fe05dda82b018a928a5",
"src/repositories/agents.ts": "9b76cd605225d9781f04d9f51894560216396b02b09ceefb5783e82ffe663fca",
"src/routes/agents.ts": "a9115be462b72ac2eeec1e42cd6fc829465cc0f9f16b95a4837fa6bce300876e",
"src/routes/tools.ts": "05fbb324e83096535bc4ef7e84302e43705bf7568c80381f1f5f15f98bd04ee2",
"src/security/agent-access.ts": "fc734c49d534de9fdec2f2851741b1dc1ea57724d4d7cf363a0da4e4458002cd",
"src/tasker/client.ts": "685056d78bb5dbf4c973a6f5505862e0cb012dddeca0ab302580860372e9902e"
},
"newPaths": [
"migrations/006_agent_credential_purpose.sql",
"src/routes/ontology.ts"
]
}
}
}

View File

@ -0,0 +1,189 @@
#!/usr/bin/env python3
import hashlib
import importlib.machinery
import importlib.util
import json
import os
import subprocess
import tarfile
import tempfile
import unittest
from pathlib import Path
from unittest import mock
SCRIPT_DIR = Path(__file__).resolve().parent
BUILDER = SCRIPT_DIR / "build-engine-agent-full-grant-migration-artifact.mjs"
RUNNER_PATH = SCRIPT_DIR / "nodedc-deploy"
PATCH_ID = "engine-agent-full-grant-migration-20990101-001"
STORE_REL = "nodedc-source/server/engineAgents/store.js"
PREDECESSOR_SHA256 = "52daa43499d6d9a97fe7ffa891edb9212b7791e733f91dd3ca686d42739b7e9a"
TARGET_SHA256 = "2e62654c2dc12905efcc83a9dff45a818dd7b47924a10600160835c4416540e9"
def load_runner():
loader = importlib.machinery.SourceFileLoader(
"nodedc_agent_grant_migration_runner_under_test",
str(RUNNER_PATH),
)
spec = importlib.util.spec_from_loader(loader.name, loader)
module = importlib.util.module_from_spec(spec)
loader.exec_module(module)
return module
RUNNER = load_runner()
class EngineAgentFullGrantMigrationArtifactTest(unittest.TestCase):
def build(self, artifact_dir, patch_id=PATCH_ID):
environment = os.environ.copy()
environment["NODEDC_DEPLOY_ARTIFACT_DIR"] = str(artifact_dir)
result = subprocess.run(
["node", str(BUILDER), patch_id],
check=True,
capture_output=True,
text=True,
env=environment,
)
return json.loads(result.stdout)
def test_artifact_is_exact_reproducible_and_runner_accepted(self):
with tempfile.TemporaryDirectory(prefix="nodedc-agent-grant-migration-") as directory:
root = Path(directory)
first_dir = root / "first"
second_dir = root / "second"
first_dir.mkdir()
second_dir.mkdir()
first = self.build(first_dir)
second = self.build(second_dir)
first_artifact = Path(first["artifact"])
second_artifact = Path(second["artifact"])
self.assertEqual(first["entries"], [STORE_REL])
self.assertEqual(first["predecessorSha256"], PREDECESSOR_SHA256)
self.assertEqual(first["targetSha256"], TARGET_SHA256)
self.assertEqual(first["sha256"], hashlib.sha256(first_artifact.read_bytes()).hexdigest())
self.assertEqual(first["sha256"], second["sha256"])
self.assertEqual(first_artifact.read_bytes(), second_artifact.read_bytes())
with tarfile.open(first_artifact, "r:gz") as archive:
members = archive.getmembers()
names = {member.name for member in members}
manifest = archive.extractfile("manifest.env").read().decode("utf-8")
files = archive.extractfile("files.txt").read().decode("utf-8")
store = archive.extractfile(f"payload/{STORE_REL}").read()
self.assertEqual(
manifest,
f"id={PATCH_ID}\ncomponent=engine\ntype=app-overlay\n",
)
self.assertEqual(files, f"{STORE_REL}\n")
self.assertEqual(hashlib.sha256(store).hexdigest(), TARGET_SHA256)
self.assertFalse(any(member.issym() or member.islnk() for member in members))
self.assertFalse(any(
name.startswith("payload/nodedc-source/server/data/")
or name.startswith("payload/nodedc-source/dist/")
or name.startswith("payload/nodedc-source/server/credentialSink/")
for name in names
))
with tempfile.TemporaryDirectory(prefix="nodedc-agent-grant-runner-") as work:
manifest_value, entries, _payload = RUNNER.load_artifact(
first_artifact,
Path(work),
)
self.assertEqual(manifest_value["id"], PATCH_ID)
self.assertEqual(tuple(entries), RUNNER.ENGINE_AGENT_FULL_GRANT_MIGRATION_ARTIFACT_ENTRIES)
self.assertTrue(RUNNER.is_engine_agent_full_grant_migration_slice("engine", entries))
self.assertEqual(RUNNER.component_services("engine", entries), ("nodedc-backend",))
self.assertEqual(RUNNER.component_builds("engine", entries), ())
self.assertEqual(
RUNNER.component_healthchecks("engine", entries, ("nodedc-backend",)),
(
"http://127.0.0.1:3001/health",
"http://127.0.0.1:3001/internal/engine-credential-sink/v1/health",
),
)
def test_predecessor_and_runtime_acceptance_are_exact(self):
with tempfile.TemporaryDirectory(prefix="nodedc-agent-grant-predecessor-") as directory:
root = Path(directory)
store = root / STORE_REL
store.parent.mkdir(parents=True)
store.write_text("predecessor", encoding="utf-8")
override = root / RUNNER.ENGINE_DATA_PRODUCT_PUBLISH_GRANT_OVERRIDE_REL
override.parent.mkdir(parents=True)
override.write_text(
RUNNER.expected_engine_data_product_publish_grant_override(),
encoding="utf-8",
)
original_root = RUNNER.COMPONENTS["engine"]["payload_root"]
RUNNER.COMPONENTS["engine"]["payload_root"] = root
try:
with mock.patch.object(RUNNER, "sha256_file", return_value=PREDECESSOR_SHA256):
self.assertEqual(
RUNNER.preflight_engine_agent_full_grant_migration_predecessor(),
PREDECESSOR_SHA256,
)
with mock.patch.object(RUNNER, "sha256_file", return_value="0" * 64):
with self.assertRaisesRegex(RUNNER.DeployError, "predecessor drift"):
RUNNER.preflight_engine_agent_full_grant_migration_predecessor()
finally:
RUNNER.COMPONENTS["engine"]["payload_root"] = original_root
expected = f"store-v2-full-developer:{TARGET_SHA256}"
with mock.patch.object(
RUNNER,
"engine_backend_container_id",
return_value="container-id",
):
with mock.patch.object(
RUNNER,
"run_engine_backend_probe",
return_value=expected,
) as probe:
self.assertEqual(RUNNER.accept_engine_agent_full_grant_migration_runtime(), expected)
arguments, label = probe.call_args.args[:2]
self.assertEqual(label, "agent full grant migration")
self.assertEqual(arguments[0:2], ("node", "--input-type=module"))
self.assertIn(TARGET_SHA256, arguments)
self.assertEqual(probe.call_args.kwargs["container_id"], "container-id")
def test_builder_rejects_invalid_id_and_overwrite(self):
with tempfile.TemporaryDirectory(prefix="nodedc-agent-grant-negative-") as directory:
root = Path(directory)
environment = {**os.environ, "NODEDC_DEPLOY_ARTIFACT_DIR": str(root)}
invalid = subprocess.run(
["node", str(BUILDER), "engine-data-product-publish-grant-20990101-001"],
check=False,
capture_output=True,
text=True,
env=environment,
)
self.assertNotEqual(invalid.returncode, 0)
self.assertIn("fresh-patch-id", invalid.stderr)
issued = subprocess.run(
["node", str(BUILDER), "engine-agent-full-grant-migration-20260717-001"],
check=False,
capture_output=True,
text=True,
env=environment,
)
self.assertNotEqual(issued.returncode, 0)
self.assertIn("patch_id_already_issued", issued.stderr)
self.build(root)
duplicate = subprocess.run(
["node", str(BUILDER), PATCH_ID],
check=False,
capture_output=True,
text=True,
env=environment,
)
self.assertNotEqual(duplicate.returncode, 0)
self.assertIn("artifact_already_exists", duplicate.stderr)
if __name__ == "__main__":
unittest.main(verbosity=2)

View File

@ -0,0 +1,157 @@
import hashlib
import importlib.machinery
import importlib.util
import json
import os
from pathlib import Path
import subprocess
import tarfile
import tempfile
import unittest
from unittest import mock
SCRIPT_DIR = Path(__file__).resolve().parent
RUNNER_PATH = SCRIPT_DIR / "nodedc-deploy"
BUILDER_PATH = SCRIPT_DIR / "build-engine-composite-provider-v4-artifact.mjs"
ENGINE_ROOT = SCRIPT_DIR.parent.parent.parent / "NODEDC_ENGINE_INFRA"
PATCH_ID = "engine-composite-provider-v4-20991231-999"
def load_runner():
loader = importlib.machinery.SourceFileLoader(
"nodedc_engine_composite_provider_v4",
str(RUNNER_PATH),
)
spec = importlib.util.spec_from_loader(loader.name, loader)
module = importlib.util.module_from_spec(spec)
loader.exec_module(module)
return module
RUNNER = load_runner()
class EngineCompositeProviderV4Test(unittest.TestCase):
def test_predecessor_map_matches_the_applied_release_order(self):
self.assertEqual(
RUNNER.ENGINE_COMPOSITE_PROVIDER_V4_PREDECESSOR_SHA256,
{
"nodedc-source/server/assets/provider-packages/v1/catalog.json":
"992159fc457ec76ce1f45aad337604c8a72b29252d44fbccda515f0fd6ea6428",
"nodedc-source/server/dataProductPublishGrant/providerCatalog.js":
"901b8fad80018ce177b34ced804b39cb140a47e831414057f484296b373c651d",
"nodedc-source/server/dataProductPublishGrant/service.js":
"6a417b25b080c05b40c771df4e2dca163491af2c9083ff73dc7e54ad3b360241",
"nodedc-source/server/dataProductPublishGrant/store.js":
"a92303b2732e21f68c1ac732fa26e4983cbadf3515741cee983b916a283d754c",
},
)
def build(self, artifact_dir):
environment = os.environ.copy()
environment["NODEDC_ENGINE_SOURCE_ROOT"] = str(ENGINE_ROOT)
environment["NODEDC_DEPLOY_ARTIFACT_DIR"] = str(artifact_dir)
completed = subprocess.run(
["node", str(BUILDER_PATH), PATCH_ID],
check=True,
capture_output=True,
text=True,
env=environment,
)
return json.loads(completed.stdout)
def test_builder_is_deterministic_and_emits_exact_backend_only_slice(self):
current_sha256 = {
relative_path: hashlib.sha256((ENGINE_ROOT / relative_path).read_bytes()).hexdigest()
for relative_path in RUNNER.ENGINE_COMPOSITE_PROVIDER_V4_ARTIFACT_ENTRIES
}
if current_sha256 != RUNNER.ENGINE_COMPOSITE_PROVIDER_V4_TARGET_SHA256:
self.skipTest("historical v4 builder source has advanced to its exact successor")
with tempfile.TemporaryDirectory(prefix="nodedc-engine-composite-v4-") as directory:
root = Path(directory)
first = self.build(root / "first")
second = self.build(root / "second")
first_artifact = Path(first["artifact"])
second_artifact = Path(second["artifact"])
self.assertEqual(first_artifact.read_bytes(), second_artifact.read_bytes())
self.assertEqual(
first["sha256"],
hashlib.sha256(first_artifact.read_bytes()).hexdigest(),
)
self.assertEqual(first["services"], ["nodedc-backend"])
self.assertEqual(first["credentialValues"], "preserved")
self.assertEqual(
tuple(first["entries"]),
RUNNER.ENGINE_COMPOSITE_PROVIDER_V4_ARTIFACT_ENTRIES,
)
extract = root / "extract"
with tarfile.open(first_artifact, "r:gz") as archive:
archive.extractall(extract, filter="data")
entries = tuple(
(extract / "files.txt").read_text(encoding="utf-8").splitlines()
)
payload = extract / "payload"
self.assertTrue(RUNNER.is_engine_composite_provider_v4_slice("engine", entries))
self.assertEqual(RUNNER.component_services("engine", entries), ("nodedc-backend",))
self.assertEqual(
RUNNER.component_healthchecks("engine", entries, ("nodedc-backend",)),
("http://127.0.0.1:3001/health",),
)
RUNNER.validate_engine_composite_provider_v4_slice(payload, entries)
for relative_path, expected in RUNNER.ENGINE_COMPOSITE_PROVIDER_V4_TARGET_SHA256.items():
self.assertEqual(
hashlib.sha256((payload / relative_path).read_bytes()).hexdigest(),
expected,
)
def test_preflight_requires_every_exact_predecessor_and_immutable_backend(self):
with tempfile.TemporaryDirectory(prefix="nodedc-engine-composite-v4-preflight-") as directory:
root = Path(directory)
for relative_path in RUNNER.ENGINE_COMPOSITE_PROVIDER_V4_ARTIFACT_ENTRIES:
path = root / relative_path
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text("predecessor\n", encoding="utf-8")
def exact_sha(path):
relative_path = Path(path).relative_to(root).as_posix()
return RUNNER.ENGINE_COMPOSITE_PROVIDER_V4_PREDECESSOR_SHA256[relative_path]
with (
mock.patch.object(RUNNER, "component_root", return_value=root),
mock.patch.object(RUNNER, "sha256_file", side_effect=exact_sha),
mock.patch.object(
RUNNER,
"preflight_engine_credential_backend_runtime",
return_value={"mode": "verified-derived-retry"},
),
):
result = RUNNER.preflight_engine_composite_provider_v4_predecessor()
self.assertEqual(result["mode"], "exact-composite-provider-v3-to-v4")
self.assertEqual(
result["predecessor_sha256"],
RUNNER.ENGINE_COMPOSITE_PROVIDER_V4_PREDECESSOR_SHA256,
)
self.assertEqual(
result["target_sha256"],
RUNNER.ENGINE_COMPOSITE_PROVIDER_V4_TARGET_SHA256,
)
drift_path = root / RUNNER.ENGINE_COMPOSITE_PROVIDER_V4_ARTIFACT_ENTRIES[1]
def drift_sha(path):
if Path(path) == drift_path:
return "0" * 64
return exact_sha(path)
with (
mock.patch.object(RUNNER, "component_root", return_value=root),
mock.patch.object(RUNNER, "sha256_file", side_effect=drift_sha),
):
with self.assertRaises(RUNNER.DeployError):
RUNNER.preflight_engine_composite_provider_v4_predecessor()
if __name__ == "__main__":
unittest.main(verbosity=2)

View File

@ -0,0 +1,147 @@
#!/usr/bin/env python3
import hashlib
import importlib.machinery
import importlib.util
import json
import os
import shutil
import subprocess
import tarfile
import tempfile
import unittest
from pathlib import Path
SCRIPT_DIR = Path(__file__).resolve().parent
BUILDER = SCRIPT_DIR / "build-engine-credential-sink-recovery-artifact.mjs"
RUNNER_PATH = SCRIPT_DIR / "nodedc-deploy"
SOURCE = Path("/Volumes/docker/nodedc-deploy/applied/nodedc-engine-credential-sink-20260716-001.tgz")
SOURCE_SHA256 = "0a96add05fe59db8f490927f66e07a84490474a7afb3ef7de51e1d6fd96f86a2"
PATCH_ID = "engine-credential-sink-recovery-20990101-001"
def load_runner():
loader = importlib.machinery.SourceFileLoader(
"nodedc_recovery_runner_under_test",
str(RUNNER_PATH),
)
spec = importlib.util.spec_from_loader(loader.name, loader)
module = importlib.util.module_from_spec(spec)
loader.exec_module(module)
return module
RUNNER = load_runner()
class EngineCredentialSinkRecoveryArtifactTest(unittest.TestCase):
def build(self, artifact_dir, patch_id=PATCH_ID, source=SOURCE):
environment = os.environ.copy()
environment["NODEDC_DEPLOY_ARTIFACT_DIR"] = str(artifact_dir)
environment["NODEDC_ENGINE_CREDENTIAL_SINK_RECOVERY_SOURCE"] = str(source)
result = subprocess.run(
["node", str(BUILDER), patch_id],
check=True,
capture_output=True,
text=True,
env=environment,
)
return json.loads(result.stdout)
def test_recovery_changes_only_manifest_id_and_passes_runner_policy(self):
self.assertTrue(SOURCE.is_file())
self.assertEqual(hashlib.sha256(SOURCE.read_bytes()).hexdigest(), SOURCE_SHA256)
with tempfile.TemporaryDirectory(prefix="nodedc-engine-sink-recovery-") as directory:
root = Path(directory)
first_dir = root / "first"
second_dir = root / "second"
first_dir.mkdir()
second_dir.mkdir()
first = self.build(first_dir)
second = self.build(second_dir)
first_artifact = Path(first["artifact"])
second_artifact = Path(second["artifact"])
self.assertEqual(first["sourceArtifactSha256"], SOURCE_SHA256)
self.assertEqual(first["changed"], ["manifest.env:id"])
self.assertEqual(first["sha256"], second["sha256"])
self.assertEqual(first_artifact.read_bytes(), second_artifact.read_bytes())
with tarfile.open(SOURCE, "r:gz") as old, tarfile.open(first_artifact, "r:gz") as new:
old_files = old.extractfile("files.txt").read()
new_files = new.extractfile("files.txt").read()
self.assertEqual(old_files, new_files)
self.assertEqual(
new.extractfile("manifest.env").read().decode("utf-8"),
f"id={PATCH_ID}\ncomponent=engine\ntype=app-overlay\n",
)
for relative_path, expected_sha256 in first["payloadSha256"].items():
old_bytes = old.extractfile(f"payload/{relative_path}").read()
new_bytes = new.extractfile(f"payload/{relative_path}").read()
self.assertEqual(old_bytes, new_bytes)
self.assertEqual(hashlib.sha256(new_bytes).hexdigest(), expected_sha256)
with tempfile.TemporaryDirectory(prefix="nodedc-recovery-runner-") as work:
manifest, entries, _payload = RUNNER.load_artifact(
first_artifact,
Path(work),
)
self.assertEqual(manifest["id"], PATCH_ID)
self.assertEqual(tuple(entries), RUNNER.ENGINE_CREDENTIAL_SINK_ARTIFACT_ENTRIES)
self.assertEqual(RUNNER.component_services("engine", entries), ("nodedc-backend",))
def test_recovery_rejects_tampered_source_invalid_id_and_overwrite(self):
with tempfile.TemporaryDirectory(prefix="nodedc-engine-sink-recovery-negative-") as directory:
root = Path(directory)
tampered = root / "tampered.tgz"
shutil.copyfile(SOURCE, tampered)
with tampered.open("ab") as stream:
stream.write(b"tampered")
environment = os.environ.copy()
environment["NODEDC_DEPLOY_ARTIFACT_DIR"] = str(root / "artifacts")
environment["NODEDC_ENGINE_CREDENTIAL_SINK_RECOVERY_SOURCE"] = str(tampered)
result = subprocess.run(
["node", str(BUILDER), PATCH_ID],
check=False,
capture_output=True,
text=True,
env=environment,
)
self.assertNotEqual(result.returncode, 0)
self.assertIn("source_sha256_mismatch", result.stderr)
invalid = subprocess.run(
["node", str(BUILDER), "engine-credential-sink-20260716-001"],
check=False,
capture_output=True,
text=True,
env={
**os.environ,
"NODEDC_DEPLOY_ARTIFACT_DIR": str(root / "invalid"),
"NODEDC_ENGINE_CREDENTIAL_SINK_RECOVERY_SOURCE": str(SOURCE),
},
)
self.assertNotEqual(invalid.returncode, 0)
self.assertIn("fresh-recovery-patch-id", invalid.stderr)
artifact_dir = root / "duplicate"
artifact_dir.mkdir()
self.build(artifact_dir)
duplicate = subprocess.run(
["node", str(BUILDER), PATCH_ID],
check=False,
capture_output=True,
text=True,
env={
**os.environ,
"NODEDC_DEPLOY_ARTIFACT_DIR": str(artifact_dir),
"NODEDC_ENGINE_CREDENTIAL_SINK_RECOVERY_SOURCE": str(SOURCE),
},
)
self.assertNotEqual(duplicate.returncode, 0)
self.assertIn("artifact_already_exists", duplicate.stderr)
if __name__ == "__main__":
unittest.main(verbosity=2)

View File

@ -0,0 +1,204 @@
import hashlib
import importlib.machinery
import importlib.util
import io
import json
import os
from pathlib import Path
import subprocess
import tarfile
import tempfile
import unittest
from contextlib import redirect_stdout
from unittest import mock
SCRIPT_DIR = Path(__file__).resolve().parent
RUNNER_PATH = SCRIPT_DIR / "nodedc-deploy"
BUILDER_PATH = SCRIPT_DIR / "build-engine-depttrans-zone-authority-v1-artifact.mjs"
ENGINE_ROOT = SCRIPT_DIR.parent.parent.parent / "NODEDC_ENGINE_INFRA"
PATCH_ID = "engine-depttrans-zone-authority-v1-20991231-999"
def load_runner():
loader = importlib.machinery.SourceFileLoader(
"nodedc_engine_depttrans_zone_authority_v1",
str(RUNNER_PATH),
)
spec = importlib.util.spec_from_loader(loader.name, loader)
module = importlib.util.module_from_spec(spec)
loader.exec_module(module)
return module
RUNNER = load_runner()
class EngineDepttransZoneAuthorityV1Test(unittest.TestCase):
def build(self, artifact_dir):
current_sha256 = {
relative_path: hashlib.sha256((ENGINE_ROOT / relative_path).read_bytes()).hexdigest()
for relative_path in RUNNER.ENGINE_DEPTTRANS_ZONE_AUTHORITY_V1_ARTIFACT_ENTRIES
}
if current_sha256 != RUNNER.ENGINE_DEPTTRANS_ZONE_AUTHORITY_V1_TARGET_SHA256:
self.skipTest("historical Depttrans authority source has advanced to its exact successor")
environment = os.environ.copy()
environment["NODEDC_ENGINE_SOURCE_ROOT"] = str(ENGINE_ROOT)
environment["NODEDC_DEPLOY_ARTIFACT_DIR"] = str(artifact_dir)
completed = subprocess.run(
["node", str(BUILDER_PATH), PATCH_ID],
check=True,
capture_output=True,
text=True,
env=environment,
)
return json.loads(completed.stdout)
def test_builder_is_deterministic_and_emits_exact_backend_only_slice(self):
with tempfile.TemporaryDirectory(prefix="nodedc-engine-depttrans-authority-") as directory:
root = Path(directory)
first = self.build(root / "first")
second = self.build(root / "second")
first_artifact = Path(first["artifact"])
second_artifact = Path(second["artifact"])
self.assertEqual(first_artifact.read_bytes(), second_artifact.read_bytes())
self.assertEqual(first["sha256"], hashlib.sha256(first_artifact.read_bytes()).hexdigest())
self.assertEqual(first["services"], ["nodedc-backend"])
self.assertEqual(first["authorityBoundary"], "platform-service")
self.assertEqual(first["platformService"], "nodedc-map-gateway")
self.assertEqual(
tuple(first["entries"]),
RUNNER.ENGINE_DEPTTRANS_ZONE_AUTHORITY_V1_ARTIFACT_ENTRIES,
)
extract = root / "extract"
with tarfile.open(first_artifact, "r:gz") as archive:
archive.extractall(extract, filter="data")
entries = tuple((extract / "files.txt").read_text(encoding="utf-8").splitlines())
payload = extract / "payload"
self.assertTrue(RUNNER.is_engine_depttrans_zone_authority_v1_slice("engine", entries))
self.assertEqual(RUNNER.component_services("engine", entries), ("nodedc-backend",))
self.assertEqual(
RUNNER.component_healthchecks("engine", entries, ("nodedc-backend",)),
("http://127.0.0.1:3001/health",),
)
RUNNER.validate_engine_depttrans_zone_authority_v1_slice(payload, entries)
def test_preflight_requires_every_exact_live_predecessor(self):
with tempfile.TemporaryDirectory(prefix="nodedc-engine-depttrans-preflight-") as directory:
root = Path(directory)
for relative_path in RUNNER.ENGINE_DEPTTRANS_ZONE_AUTHORITY_V1_PREDECESSOR_SHA256:
path = root / relative_path
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text("predecessor\n", encoding="utf-8")
def exact_sha(path):
relative_path = Path(path).relative_to(root).as_posix()
return RUNNER.ENGINE_DEPTTRANS_ZONE_AUTHORITY_V1_PREDECESSOR_SHA256[relative_path]
with (
mock.patch.object(RUNNER, "component_root", return_value=root),
mock.patch.object(RUNNER, "sha256_file", side_effect=exact_sha),
mock.patch.object(
RUNNER,
"preflight_engine_credential_backend_runtime",
return_value={"mode": "verified-derived-retry"},
),
):
result = RUNNER.preflight_engine_depttrans_zone_authority_v1_predecessor()
self.assertEqual(result["mode"], "exact-platform-service-authority-v1")
self.assertEqual(
result["predecessor_sha256"],
RUNNER.ENGINE_DEPTTRANS_ZONE_AUTHORITY_V1_PREDECESSOR_SHA256,
)
with (
mock.patch.object(RUNNER, "component_root", return_value=root),
mock.patch.object(RUNNER, "sha256_file", return_value="0" * 64),
):
with self.assertRaises(RUNNER.DeployError):
RUNNER.preflight_engine_depttrans_zone_authority_v1_predecessor()
def test_plan_renders_new_path_as_absent_predecessor(self):
preflight = {
"predecessor_sha256": dict(
RUNNER.ENGINE_DEPTTRANS_ZONE_AUTHORITY_V1_PREDECESSOR_SHA256
),
"target_sha256": dict(
RUNNER.ENGINE_DEPTTRANS_ZONE_AUTHORITY_V1_TARGET_SHA256
),
"backend_mode": "verified-derived-retry",
}
output = io.StringIO()
with redirect_stdout(output):
RUNNER.print_engine_depttrans_zone_authority_v1_plan(preflight)
rendered = output.getvalue()
new_path = RUNNER.ENGINE_DEPTTRANS_ZONE_AUTHORITY_V1_NEW_PATHS[0]
self.assertIn(
f"engine_depttrans_zone_authority_predecessor_state[{new_path}]=absent",
rendered,
)
self.assertNotIn(
f"engine_depttrans_zone_authority_predecessor_sha256[{new_path}]",
rendered,
)
for changed_path in RUNNER.ENGINE_DEPTTRANS_ZONE_AUTHORITY_V1_ARTIFACT_ENTRIES:
self.assertIn(
f"engine_depttrans_zone_authority_target_sha256[{changed_path}]="
f"{RUNNER.ENGINE_DEPTTRANS_ZONE_AUTHORITY_V1_TARGET_SHA256[changed_path]}",
rendered,
)
self.assertIn("mcp_nginx=untouched", rendered)
def test_healthchecks_dispatch_exact_live_acceptance(self):
entries = RUNNER.ENGINE_DEPTTRANS_ZONE_AUTHORITY_V1_ARTIFACT_ENTRIES
root = Path("/engine")
def target_sha(path):
relative_path = Path(path).relative_to(root).as_posix()
return RUNNER.ENGINE_DEPTTRANS_ZONE_AUTHORITY_V1_TARGET_SHA256[relative_path]
with (
mock.patch.object(RUNNER, "component_root", return_value=root),
mock.patch.object(RUNNER, "sha256_file", side_effect=target_sha),
mock.patch.object(Path, "is_file", return_value=True),
mock.patch.object(Path, "is_symlink", return_value=False),
mock.patch.object(RUNNER, "healthcheck_compose_service"),
mock.patch.object(RUNNER, "component_healthchecks", return_value=()),
mock.patch.object(
RUNNER,
"preflight_engine_credential_backend_runtime",
return_value={"mode": "verified-derived-retry"},
),
mock.patch.object(
RUNNER,
"accept_engine_depttrans_zone_authority_v1_runtime",
return_value={"live": "accepted"},
) as acceptance,
mock.patch.object(RUNNER, "healthcheck_container"),
):
RUNNER.run_healthchecks("engine", entries, ("nodedc-backend",))
acceptance.assert_called_once_with()
def test_live_acceptance_probes_platform_service_and_fail_closed_drift(self):
expected_live = (
"engine-depttrans-zone-authority:platform-service:"
"map.zones.current.v2:nodedc-map-gateway"
)
with (
mock.patch.object(RUNNER, "component_root", return_value=ENGINE_ROOT),
mock.patch.object(RUNNER, "validate_engine_depttrans_zone_authority_v1_slice"),
mock.patch.object(RUNNER, "engine_backend_container_id", return_value="backend"),
mock.patch.object(
RUNNER,
"run_engine_backend_probe",
return_value=expected_live,
) as backend_probe,
):
result = RUNNER.accept_engine_depttrans_zone_authority_v1_runtime()
self.assertEqual(result["live"], expected_live)
self.assertEqual(backend_probe.call_args.args[1], "Engine Depttrans zone authority v1")
if __name__ == "__main__":
unittest.main(verbosity=2)

View File

@ -0,0 +1,387 @@
import hashlib
import importlib.machinery
import importlib.util
import json
import os
from pathlib import Path
import shutil
import subprocess
import tarfile
import tempfile
import unittest
from unittest import mock
SCRIPT_DIR = Path(__file__).resolve().parent
RUNNER_PATH = SCRIPT_DIR / "nodedc-deploy"
BUILDER_PATH = SCRIPT_DIR / "build-engine-l2-closed-loop-artifact.mjs"
ENGINE_ROOT = SCRIPT_DIR.parent.parent.parent / "NODEDC_ENGINE_INFRA"
PATCH_ID = "engine-l2-closed-loop-20991231-999"
SOURCE_COMMIT = "dda405cff27977622af0c218abb4d4420c408536"
BASELINE_ARTIFACT = (
SCRIPT_DIR.parent
/ "deploy-artifacts"
/ "nodedc-engine-provider-authority-diagnostics-20260723-029.tgz"
)
def load_runner():
loader = importlib.machinery.SourceFileLoader(
"nodedc_engine_l2_closed_loop",
str(RUNNER_PATH),
)
spec = importlib.util.spec_from_loader(loader.name, loader)
module = importlib.util.module_from_spec(spec)
loader.exec_module(module)
return module
RUNNER = load_runner()
class EngineL2ClosedLoopArtifactTest(unittest.TestCase):
def require_target_commit(self):
current = subprocess.run(
["git", "-C", str(ENGINE_ROOT), "rev-parse", "HEAD"],
check=True,
capture_output=True,
text=True,
).stdout.strip()
if current != SOURCE_COMMIT:
self.skipTest("historical Engine L2 closed-loop source has advanced")
def build(self, artifact_dir):
self.require_target_commit()
environment = os.environ.copy()
environment["NODEDC_ENGINE_SOURCE_ROOT"] = str(ENGINE_ROOT)
environment["NODEDC_DEPLOY_ARTIFACT_DIR"] = str(artifact_dir)
completed = subprocess.run(
["node", str(BUILDER_PATH), PATCH_ID],
check=True,
capture_output=True,
text=True,
env=environment,
)
return json.loads(completed.stdout)
def load_built_payload(self, root):
built = self.build(root / "artifact")
artifact = Path(built["artifact"])
manifest, entries, payload = RUNNER.load_artifact(
artifact,
root / "loaded",
)
return built, manifest, entries, payload
def materialize_partial_predecessor(self, payload, entries, root):
for relative_path in entries:
source = payload / relative_path
destination = root / relative_path
destination.parent.mkdir(parents=True, exist_ok=True)
if source.is_dir():
shutil.copytree(source, destination)
else:
shutil.copy2(source, destination)
with tarfile.open(BASELINE_ARTIFACT, "r:gz") as archive:
member = archive.extractfile(
"payload/nodedc-source/services/node-intelligence/activation.json"
)
self.assertIsNotNone(member)
(root / RUNNER.ENGINE_L2_CLOSED_LOOP_DESCRIPTOR_REL).write_bytes(
member.read()
)
shutil.copytree(
root / "nodedc-source/dist",
root / "nginx-html",
)
def test_builder_is_commit_bound_and_byte_deterministic(self):
with tempfile.TemporaryDirectory(prefix="nodedc-engine-l2-closed-loop-") as directory:
root = Path(directory)
first = self.build(root / "first")
second = self.build(root / "second")
first_artifact = Path(first["artifact"])
second_artifact = Path(second["artifact"])
self.assertEqual(first["sourceCommit"], SOURCE_COMMIT)
self.assertEqual(first_artifact.read_bytes(), second_artifact.read_bytes())
self.assertEqual(
first["sha256"],
hashlib.sha256(first_artifact.read_bytes()).hexdigest(),
)
self.assertEqual(first["services"], ["nodedc-backend", "app"])
self.assertEqual(first["mcpVersion"], "0.7.0")
def test_failed_030_identity_is_terminal_and_cannot_be_retried(self):
with self.assertRaisesRegex(
RUNNER.DeployError,
"terminal failed",
):
RUNNER.reject_terminal_engine_l2_failed_artifact(
{
"id": RUNNER.ENGINE_L2_CLOSED_LOOP_FAILED_PATCH_ID,
},
"0" * 64,
)
with self.assertRaisesRegex(
RUNNER.DeployError,
"terminal failed",
):
RUNNER.reject_terminal_engine_l2_failed_artifact(
{"id": "different-successor"},
RUNNER.ENGINE_L2_CLOSED_LOOP_FAILED_ARTIFACT_SHA256,
)
RUNNER.reject_terminal_engine_l2_failed_artifact(
{"id": "engine-l2-closed-loop-20260723-031"},
"1" * 64,
)
def test_artifact_is_a_safe_exact_engine_overlay(self):
with tempfile.TemporaryDirectory(prefix="nodedc-engine-l2-overlay-") as directory:
root = Path(directory)
built = self.build(root / "artifact")
artifact = Path(built["artifact"])
with tarfile.open(artifact, "r:gz") as archive:
members = archive.getmembers()
for member in members:
self.assertIn(member.type, (tarfile.REGTYPE, tarfile.DIRTYPE))
self.assertEqual(member.uid, 0)
self.assertEqual(member.gid, 0)
self.assertEqual(member.mtime, 0)
self.assertNotIn(".DS_Store", member.name)
self.assertNotIn("__MACOSX", member.name)
archive.extractall(root / "extract", filter="data")
manifest, entries, payload = RUNNER.load_artifact(
artifact,
root / "loaded",
)
self.assertEqual(manifest["component"], "engine")
self.assertEqual(manifest["type"], "app-overlay")
self.assertEqual(tuple(entries), tuple(built["entries"]))
self.assertEqual(
RUNNER.component_services("engine", entries),
("nodedc-backend", "app"),
)
self.assertEqual(
RUNNER.component_healthchecks(
"engine",
entries,
("nodedc-backend", "app"),
),
(
"http://127.0.0.1:8080/",
"http://127.0.0.1:3001/health",
),
)
self.assertTrue(RUNNER.component_publish_dist("engine", entries))
self.assertEqual(RUNNER.component_builds("engine", entries), ())
payload_files = {
path.relative_to(payload).as_posix()
for path in payload.rglob("*")
if path.is_file()
}
self.assertEqual(payload_files, set(built["targetSha256"]))
for relative_path, expected in built["targetSha256"].items():
self.assertEqual(
hashlib.sha256((payload / relative_path).read_bytes()).hexdigest(),
expected,
)
self.assertTrue(all("/tests/" not in path for path in payload_files))
self.assertTrue(all("/data/" not in path for path in payload_files))
self.assertTrue(all("/storage/" not in path for path in payload_files))
self.assertTrue(all("/logs/" not in path for path in payload_files))
self.assertTrue(all(not path.endswith(".env") for path in payload_files))
def test_preflight_accepts_only_the_exact_failed_030_partial_state(self):
with tempfile.TemporaryDirectory(prefix="nodedc-engine-l2-preflight-") as directory:
workspace = Path(directory)
_built, _manifest, entries, payload = self.load_built_payload(
workspace / "build"
)
live = workspace / "live"
self.materialize_partial_predecessor(payload, entries, live)
recovery = workspace / RUNNER.ENGINE_L2_CLOSED_LOOP_RECOVERY_BACKUP_ID
backend = {
"mode": "verified-derived-retry",
"container_id": "a" * 64,
}
with (
mock.patch.object(RUNNER, "component_root", return_value=live),
mock.patch.object(
RUNNER,
"validate_engine_l2_closed_loop_recovery_evidence",
return_value=recovery,
),
mock.patch.object(
RUNNER,
"validate_installed_engine_node_intelligence_source",
) as validate_source,
mock.patch.object(
RUNNER,
"preflight_engine_credential_backend_runtime",
return_value=backend,
) as validate_backend,
mock.patch.object(
RUNNER,
"engine_compose_service_container_id_for_gateway",
return_value="b" * 64,
) as app_lookup,
):
result = RUNNER.preflight_engine_l2_closed_loop_predecessor(
payload
)
self.assertEqual(
result["mode"],
"failed-030-partial-source-reconciliation",
)
self.assertEqual(result["backend_container_id"], "a" * 64)
self.assertEqual(result["app_container_id"], "b" * 64)
self.assertEqual(
result["target_gateway_sha256"],
RUNNER.ENGINE_L2_CLOSED_LOOP_TARGET_SHA256[
RUNNER.ENGINE_NODE_INTELLIGENCE_GATEWAY_REL
],
)
self.assertEqual(
validate_source.call_args.kwargs[
"expected_gateway_sha256"
],
RUNNER.ENGINE_L2_CLOSED_LOOP_PARTIAL_SHA256[
RUNNER.ENGINE_NODE_INTELLIGENCE_GATEWAY_REL
],
)
self.assertEqual(
validate_backend.call_args.kwargs[
"expected_node_intelligence_gateway_sha256"
],
RUNNER.ENGINE_L2_CLOSED_LOOP_PARTIAL_SHA256[
RUNNER.ENGINE_NODE_INTELLIGENCE_GATEWAY_REL
],
)
app_lookup.assert_called_once_with(
"app",
RUNNER.ENGINE_L2_CLOSED_LOOP_PARTIAL_SHA256[
RUNNER.ENGINE_NODE_INTELLIGENCE_GATEWAY_REL
],
)
index_path = live / "nodedc-source/dist/index.html"
index_path.write_bytes(index_path.read_bytes() + b"\n")
with (
mock.patch.object(RUNNER, "component_root", return_value=live),
mock.patch.object(
RUNNER,
"validate_engine_l2_closed_loop_recovery_evidence",
return_value=recovery,
),
mock.patch.object(
RUNNER,
"preflight_engine_credential_backend_runtime",
) as unreachable_backend,
):
with self.assertRaisesRegex(
RUNNER.DeployError,
"partial predecessor drift",
):
RUNNER.preflight_engine_l2_closed_loop_predecessor(payload)
unreachable_backend.assert_not_called()
def test_reconciliation_rollback_uses_failed_030_backup_and_restores_runtime(self):
entries = RUNNER.ENGINE_L2_CLOSED_LOOP_ARTIFACT_ENTRIES
services = ("nodedc-backend", "app")
with tempfile.TemporaryDirectory(prefix="nodedc-engine-l2-rollback-") as directory:
root = Path(directory)
candidate = root / "candidate-backup"
recovery = root / "recovery-backup"
candidate_entries = [*entries, "nginx-html"]
original_entries = [
rel
for rel in entries
if rel != RUNNER.ENGINE_L2_CLOSED_LOOP_DESCRIPTOR_REL
]
original_entries.append("nginx-html")
def backup_paths(path):
if path.parent == candidate:
return candidate_entries if path.name == "existing-files.txt" else []
if path.parent == recovery:
if path.name == "existing-files.txt":
return [
rel
for rel in original_entries
if rel != "nodedc-source/server/l2/graphRepository.js"
]
return ["nodedc-source/server/l2/graphRepository.js"]
raise AssertionError(path)
with (
mock.patch.object(
RUNNER,
"validate_engine_l2_closed_loop_recovery_evidence",
return_value=recovery,
),
mock.patch.object(
RUNNER,
"read_backup_path_list",
side_effect=backup_paths,
),
mock.patch.object(
RUNNER,
"restore_platform_overlay",
side_effect=(len(candidate_entries), len(original_entries)),
) as restore,
mock.patch.object(
RUNNER,
"validate_engine_l2_closed_loop_stable_source",
) as validate_stable,
mock.patch.object(RUNNER, "run_component_runtime") as run_runtime,
mock.patch.object(RUNNER, "healthcheck_compose_service") as check_service,
mock.patch.object(
RUNNER,
"component_healthchecks",
return_value=(
"http://127.0.0.1:8080/",
"http://127.0.0.1:3001/health",
),
),
mock.patch.object(RUNNER, "healthcheck_url") as check_url,
mock.patch.object(
RUNNER,
"preflight_engine_credential_backend_runtime",
return_value={"mode": "verified-derived-retry"},
),
):
result = RUNNER.rollback_engine_l2_closed_loop_reconciliation(
root,
candidate,
entries,
"20991231-235959",
True,
services,
)
self.assertTrue(result.startswith("stable-source+runtime-restored:"))
self.assertEqual(
[call.args[1] for call in restore.call_args_list],
[candidate, recovery],
)
validate_stable.assert_called_once_with(root)
run_runtime.assert_called_once_with(
"engine",
tuple(RUNNER.ENGINE_L2_CLOSED_LOOP_STABLE_SHA256),
services,
)
self.assertEqual(
[call.args for call in check_service.call_args_list],
[("engine", "nodedc-backend"), ("engine", "app")],
)
self.assertEqual(check_url.call_count, 2)
if __name__ == "__main__":
unittest.main(verbosity=2)

View File

@ -0,0 +1,162 @@
#!/usr/bin/env python3
import hashlib
import importlib.machinery
import importlib.util
import json
import os
import subprocess
import tarfile
import tempfile
import unittest
from pathlib import Path
SCRIPT_DIR = Path(__file__).resolve().parent
PLATFORM_ROOT = SCRIPT_DIR.parent.parent
ENGINE_ROOT = PLATFORM_ROOT.parent / "NODEDC_ENGINE_INFRA"
RUNNER_PATH = SCRIPT_DIR / "nodedc-deploy"
BUILDER = SCRIPT_DIR / "build-engine-mcp-autonomy-provider-v5-artifact.mjs"
PATCH_ID = "20991231-998"
CANONICAL_ARTIFACT = (
PLATFORM_ROOT
/ "infra/deploy-artifacts/nodedc-engine-mcp-autonomy-provider-v5-20260720-004.tgz"
)
CANONICAL_SHA256 = "3400954cdce078892a06b04b9bfd85a90f6ea775b1a0caf99eba1f880ef6601c"
def load_runner():
loader = importlib.machinery.SourceFileLoader(
"nodedc_engine_mcp_autonomy_provider_v5",
str(RUNNER_PATH),
)
spec = importlib.util.spec_from_loader(loader.name, loader)
module = importlib.util.module_from_spec(spec)
loader.exec_module(module)
return module
RUNNER = load_runner()
class EngineMcpAutonomyProviderV5Test(unittest.TestCase):
def build(self, artifact_dir):
try:
current_sha256 = {
relative_path: hashlib.sha256((ENGINE_ROOT / relative_path).read_bytes()).hexdigest()
for relative_path in RUNNER.ENGINE_MCP_AUTONOMY_PROVIDER_V5_ARTIFACT_ENTRIES
}
except FileNotFoundError:
self.skipTest("historical MCP autonomy/provider v5 source paths are no longer present")
if current_sha256 != RUNNER.ENGINE_MCP_AUTONOMY_PROVIDER_V5_TARGET_SHA256:
self.skipTest("historical MCP autonomy/provider v5 source has advanced to its exact successor")
environment = os.environ.copy()
environment["NODEDC_ENGINE_SOURCE_ROOT"] = str(ENGINE_ROOT)
environment["NODEDC_DEPLOY_ARTIFACT_DIR"] = str(artifact_dir)
completed = subprocess.run(
["node", str(BUILDER), PATCH_ID],
cwd=PLATFORM_ROOT,
env=environment,
check=True,
capture_output=True,
text=True,
)
return json.loads(completed.stdout)
def test_canonical_artifact_digest_and_members_are_pinned(self):
self.assertEqual(
hashlib.sha256(CANONICAL_ARTIFACT.read_bytes()).hexdigest(),
CANONICAL_SHA256,
)
self.assertEqual(CANONICAL_ARTIFACT.read_bytes()[4:8], b"\0\0\0\0")
with tarfile.open(CANONICAL_ARTIFACT, "r:gz") as archive:
for member in archive:
self.assertTrue(member.isfile() or member.isdir())
self.assertFalse(Path(member.name).name.startswith("._"))
def test_builder_is_reproducible_and_slice_is_backend_only(self):
with tempfile.TemporaryDirectory(prefix="nodedc-engine-mcp-autonomy-v5-") as directory:
root = Path(directory)
first = self.build(root / "first")
second = self.build(root / "second")
first_artifact = Path(first["artifact"])
second_artifact = Path(second["artifact"])
self.assertEqual(first_artifact.read_bytes(), second_artifact.read_bytes())
self.assertEqual(
first["artifactSha256"],
hashlib.sha256(first_artifact.read_bytes()).hexdigest(),
)
self.assertEqual(first["services"], ["nodedc-backend"])
self.assertEqual(
first["authority"],
"mcp-capability-intersect-user-objective",
)
extract = root / "extract"
with tarfile.open(first_artifact, "r:gz") as archive:
archive.extractall(extract, filter="data")
loaded = root / "loaded"
loaded.mkdir()
manifest, entries, payload = RUNNER.load_artifact(first_artifact, loaded)
descriptor = RUNNER.validate_engine_mcp_autonomy_provider_v5_payload(
payload,
entries,
)
self.assertEqual(manifest["component"], "engine")
self.assertEqual(
tuple(entries),
RUNNER.ENGINE_MCP_AUTONOMY_PROVIDER_V5_ARTIFACT_ENTRIES,
)
self.assertEqual(RUNNER.component_services("engine", entries), ("nodedc-backend",))
self.assertEqual(RUNNER.component_builds("engine", entries), ())
self.assertEqual(
descriptor["source"]["gatewaySha256"],
RUNNER.ENGINE_MCP_AUTONOMY_PROVIDER_V5_TARGET_SHA256[
RUNNER.ENGINE_NODE_INTELLIGENCE_GATEWAY_REL
],
)
def test_payload_pins_authority_policy_archive_and_catalog_lineage(self):
with tempfile.TemporaryDirectory(prefix="nodedc-engine-mcp-autonomy-v5-policy-") as directory:
result = self.build(Path(directory) / "artifact")
extract = Path(directory) / "extract"
with tarfile.open(result["artifact"], "r:gz") as archive:
archive.extractall(extract, filter="data")
payload = extract / "payload"
installer = (
payload
/ "nodedc-source/server/assets/engine-agent-npm/bin/nodedc-engine-codex-agent.mjs"
).read_text(encoding="utf-8")
catalog = json.loads((
payload / "nodedc-source/server/assets/provider-packages/v1/catalog.json"
).read_text(encoding="utf-8"))
self.assertIn("MCP tool availability establishes capability authority", installer)
self.assertIn("machine safety barrier, not a permission ceremony", installer)
self.assertIn("Three identical failures with no new evidence", installer)
self.assertNotIn("only with explicit user confirmation", installer)
self.assertEqual(
[item["id"] for item in catalog["packages"]],
["gelios.provider.v4", "gelios.provider.v5"],
)
self.assertTrue(all(
capability["dataProductIds"] == ["fleet.positions.current.v4"]
for package in catalog["packages"] if package["id"] == "gelios.provider.v5"
for capability in package["capabilities"]
))
def test_payload_tampering_is_rejected(self):
with tempfile.TemporaryDirectory(prefix="nodedc-engine-mcp-autonomy-v5-tamper-") as directory:
result = self.build(Path(directory) / "artifact")
extract = Path(directory) / "extract"
RUNNER.safe_extract(Path(result["artifact"]), extract)
entries = RUNNER.parse_files_list(extract / "files.txt")
package = extract / "payload/nodedc-source/server/assets/engine-agent-npm/package.json"
package.write_text("{}\n", encoding="utf-8")
with self.assertRaisesRegex(RUNNER.DeployError, "target sha256 mismatch"):
RUNNER.validate_engine_mcp_autonomy_provider_v5_payload(
extract / "payload",
entries,
)
if __name__ == "__main__":
unittest.main(verbosity=2)

View File

@ -0,0 +1,198 @@
#!/usr/bin/env python3
import hashlib
import importlib.machinery
import importlib.util
import json
import os
import subprocess
import tarfile
import tempfile
import unittest
from unittest import mock
from pathlib import Path
SCRIPT_DIR = Path(__file__).resolve().parent
PLATFORM_ROOT = SCRIPT_DIR.parent.parent
RUNNER_PATH = SCRIPT_DIR / "nodedc-deploy"
BUILDER_PATH = SCRIPT_DIR / "build-engine-mcp-control-plane-artifact.mjs"
TRANSITION_ID = "20260718-003"
STAGE_ARTIFACT = (
PLATFORM_ROOT
/ "infra/deploy-artifacts"
/ "nodedc-engine-mcp-control-plane-20260718-003.tgz"
)
STAGE_ARTIFACT_SHA256 = "249aef9527666c562e9648b15737e66cc5c1dc7c0788b58ea714da270b5eb4ba"
def load_runner():
loader = importlib.machinery.SourceFileLoader("nodedc_engine_mcp_runner", str(RUNNER_PATH))
spec = importlib.util.spec_from_loader(loader.name, loader)
module = importlib.util.module_from_spec(spec)
loader.exec_module(module)
return module
RUNNER = load_runner()
class EngineMcpControlPlaneTest(unittest.TestCase):
@classmethod
def setUpClass(cls):
cls.temporary = tempfile.TemporaryDirectory(prefix="nodedc-engine-mcp-control-plane-")
cls.root = Path(cls.temporary.name)
stage_bytes = STAGE_ARTIFACT.read_bytes()
if hashlib.sha256(stage_bytes).hexdigest() != STAGE_ARTIFACT_SHA256:
raise RuntimeError("engine_mcp_control_plane_stage_artifact_sha256_mismatch")
source_stage = cls.root / "immutable-source"
source_stage.mkdir()
RUNNER.safe_extract(STAGE_ARTIFACT, source_stage)
cls.engine_source_root = source_stage / "payload"
cls.results = []
for index in range(2):
output = cls.root / f"build-{index}"
output.mkdir()
env = os.environ.copy()
env["NODEDC_DEPLOY_ARTIFACT_DIR"] = str(output)
env["NODEDC_ENGINE_SOURCE_ROOT"] = str(cls.engine_source_root)
result = subprocess.run(
["node", str(BUILDER_PATH), TRANSITION_ID],
cwd=PLATFORM_ROOT,
env=env,
check=True,
capture_output=True,
text=True,
)
cls.results.append(json.loads(result.stdout))
@classmethod
def tearDownClass(cls):
cls.temporary.cleanup()
def artifact(self, index=0):
return Path(self.results[index]["artifact"])
def extract(self, artifact, directory):
RUNNER.safe_extract(artifact, directory)
return directory / "payload", RUNNER.parse_files_list(directory / "files.txt")
def test_artifact_is_byte_reproducible_and_canonical(self):
first = self.artifact(0).read_bytes()
second = self.artifact(1).read_bytes()
self.assertEqual(first, second)
self.assertEqual(first, STAGE_ARTIFACT.read_bytes())
self.assertEqual(first[4:8], b"\0\0\0\0")
self.assertEqual(self.results[0]["artifactSha256"], hashlib.sha256(first).hexdigest())
with tarfile.open(self.artifact(0), "r:gz") as archive:
for member in archive:
self.assertTrue(member.isfile() or member.isdir())
self.assertFalse(Path(member.name).name.startswith("._"))
def test_runner_selects_only_existing_backend(self):
with tempfile.TemporaryDirectory() as directory:
manifest, entries, payload = RUNNER.load_artifact(
self.artifact(0),
Path(directory),
)
descriptor = RUNNER.validate_engine_mcp_control_plane_payload(payload, entries)
override_text = (
payload
/ RUNNER.ENGINE_DATA_PRODUCT_READ_GRANT_OVERRIDE_REL
).read_text(encoding="utf-8")
self.assertEqual(manifest["component"], "engine")
self.assertEqual(tuple(entries), RUNNER.ENGINE_MCP_CONTROL_PLANE_ARTIFACT_ENTRIES)
self.assertEqual(RUNNER.component_services("engine", entries), ("nodedc-backend",))
self.assertEqual(descriptor["action"], "activate")
self.assertEqual(
descriptor["source"]["gatewaySha256"],
RUNNER.ENGINE_MCP_CONTROL_PLANE_TARGET_SHA256[
RUNNER.ENGINE_NODE_INTELLIGENCE_GATEWAY_REL
],
)
self.assertEqual(override_text, RUNNER.expected_engine_data_product_read_grant_override())
def test_runtime_preparation_adds_only_private_reader_state(self):
with (
mock.patch.object(
RUNNER,
"ensure_engine_edp_managed_provisioner_keypair",
) as keypair,
mock.patch.object(
RUNNER,
"ensure_engine_data_product_grant_private_state",
) as private_state,
):
RUNNER.prepare_component_runtime(
"engine",
RUNNER.ENGINE_MCP_CONTROL_PLANE_ARTIFACT_ENTRIES,
)
keypair.assert_called_once_with()
private_state.assert_called_once_with(include_reader=True)
def test_candidate_preserves_node_intelligence_identity_except_exact_control_plane_digests(self):
with tempfile.TemporaryDirectory() as directory:
work = Path(directory)
payload, entries = self.extract(self.artifact(0), work)
candidate = RUNNER.validate_engine_mcp_control_plane_payload(payload, entries)
self.assertEqual(candidate["releaseId"], RUNNER.ENGINE_NODE_INTELLIGENCE_RELEASE_ID)
self.assertEqual(candidate["upstream"]["commit"], RUNNER.ENGINE_NODE_INTELLIGENCE_UPSTREAM_COMMIT)
self.assertEqual(candidate["image"]["tag"], RUNNER.ENGINE_NODE_INTELLIGENCE_IMAGE)
self.assertEqual(candidate["expectedCurrent"], "inactive")
self.assertEqual(
candidate["source"]["upstreamProjectionSha256"],
RUNNER.ENGINE_MCP_CONTROL_PLANE_TARGET_SHA256[
"nodedc-source/server/nodeIntelligence/upstreamProjection.js"
],
)
def test_descriptor_boundary_and_source_digest_are_enforced(self):
with tempfile.TemporaryDirectory() as directory:
work = Path(directory)
payload, entries = self.extract(self.artifact(0), work)
descriptor_path = payload / RUNNER.ENGINE_MCP_CONTROL_PLANE_DESCRIPTOR_REL
descriptor = json.loads(descriptor_path.read_text(encoding="utf-8"))
descriptor["source"]["catalogSha256"] = "0" * 64
descriptor_path.write_text(json.dumps(descriptor), encoding="utf-8")
# Payload validation permits only a structurally valid descriptor;
# predecessor equality is the state-aware barrier for untouched
# node-intelligence source identities.
with self.assertRaisesRegex(
RUNNER.DeployError,
"descriptor crosses node-intelligence source boundary",
):
installed = json.loads(json.dumps(descriptor))
installed["source"]["catalogSha256"] = "1" * 64
installed["source"]["gatewaySha256"] = (
RUNNER.ENGINE_MCP_CONTROL_PLANE_PREDECESSOR_SHA256[
RUNNER.ENGINE_NODE_INTELLIGENCE_GATEWAY_REL
]
)
from unittest import mock
with (
mock.patch.object(
RUNNER,
"current_engine_node_intelligence_descriptor",
return_value=installed,
),
mock.patch.object(RUNNER, "validate_installed_engine_node_intelligence_source"),
):
RUNNER.preflight_engine_mcp_control_plane_predecessor(payload)
def test_existing_artifact_is_not_overwritten(self):
env = os.environ.copy()
env["NODEDC_DEPLOY_ARTIFACT_DIR"] = str(self.artifact(0).parent)
env["NODEDC_ENGINE_SOURCE_ROOT"] = str(self.engine_source_root)
result = subprocess.run(
["node", str(BUILDER_PATH), TRANSITION_ID],
cwd=PLATFORM_ROOT,
env=env,
check=False,
capture_output=True,
text=True,
)
self.assertNotEqual(result.returncode, 0)
self.assertIn("artifact_already_exists", result.stderr)
if __name__ == "__main__":
unittest.main(verbosity=2)

View File

@ -0,0 +1,356 @@
import hashlib
import importlib.machinery
import importlib.util
import io
import json
import os
from pathlib import Path
import subprocess
import tarfile
import tempfile
import unittest
from contextlib import redirect_stdout
from unittest import mock
SCRIPT_DIR = Path(__file__).resolve().parent
RUNNER_PATH = SCRIPT_DIR / "nodedc-deploy"
BUILDER_PATH = (
SCRIPT_DIR / "build-engine-mcp-execution-plan-materialization-artifact.mjs"
)
ENGINE_ROOT = SCRIPT_DIR.parent.parent.parent / "NODEDC_ENGINE_INFRA"
PATCH_ID = "engine-mcp-execution-plan-materialization-20991231-999"
def load_runner():
loader = importlib.machinery.SourceFileLoader(
"nodedc_engine_mcp_execution_plan_materialization",
str(RUNNER_PATH),
)
spec = importlib.util.spec_from_loader(loader.name, loader)
module = importlib.util.module_from_spec(spec)
loader.exec_module(module)
return module
RUNNER = load_runner()
class EngineMcpExecutionPlanMaterializationTest(unittest.TestCase):
def require_current_target_source(self):
for relative_path, expected in (
RUNNER.ENGINE_MCP_EXECUTION_PLAN_MATERIALIZATION_TARGET_SHA256.items()
):
if relative_path == RUNNER.ENGINE_NODE_INTELLIGENCE_DESCRIPTOR_REL:
continue
path = ENGINE_ROOT / relative_path
if (
not path.is_file()
or hashlib.sha256(path.read_bytes()).hexdigest() != expected
):
self.skipTest(
"execution plan materialization source has advanced"
)
def build(self, artifact_dir):
environment = os.environ.copy()
environment["NODEDC_ENGINE_SOURCE_ROOT"] = str(ENGINE_ROOT)
environment["NODEDC_DEPLOY_ARTIFACT_DIR"] = str(artifact_dir)
completed = subprocess.run(
["node", str(BUILDER_PATH), PATCH_ID],
check=True,
capture_output=True,
text=True,
env=environment,
)
return json.loads(completed.stdout)
def test_builder_emits_exact_deterministic_backend_only_slice(self):
self.require_current_target_source()
with tempfile.TemporaryDirectory(
prefix="nodedc-engine-mcp-execution-plan-materialization-"
) as directory:
root = Path(directory)
first = self.build(root / "first")
second = self.build(root / "second")
first_artifact = Path(first["artifact"])
second_artifact = Path(second["artifact"])
self.assertEqual(first_artifact.read_bytes(), second_artifact.read_bytes())
self.assertEqual(first["services"], ["nodedc-backend"])
self.assertEqual(first["mcpVersion"], "0.9.0")
self.assertEqual(
first["providerLogicAuthority"],
"trusted-provider-package",
)
self.assertEqual(
first["unmanagedGraphPolicy"],
"explicit-adoption-required",
)
self.assertEqual(
tuple(first["entries"]),
RUNNER.ENGINE_MCP_EXECUTION_PLAN_MATERIALIZATION_ARTIFACT_ENTRIES,
)
extract = root / "extract"
with tarfile.open(first_artifact, "r:gz") as archive:
archive.extractall(extract, filter="data")
entries = tuple(
(extract / "files.txt").read_text(encoding="utf-8").splitlines()
)
payload = extract / "payload"
self.assertTrue(
RUNNER.is_engine_mcp_execution_plan_materialization_slice(
"engine",
entries,
)
)
self.assertFalse(
RUNNER.is_engine_mcp_telemetry_catalog_slice("engine", entries)
)
self.assertEqual(
RUNNER.component_services("engine", entries),
("nodedc-backend",),
)
RUNNER.validate_engine_mcp_execution_plan_materialization_slice(
payload,
entries,
)
node_intelligence_descriptor = json.loads(
(
payload / RUNNER.ENGINE_NODE_INTELLIGENCE_DESCRIPTOR_REL
).read_text(encoding="utf-8")
)
self.assertEqual(
node_intelligence_descriptor["source"]["gatewaySha256"],
RUNNER.ENGINE_MCP_EXECUTION_PLAN_MATERIALIZATION_TARGET_SHA256[
RUNNER.ENGINE_NODE_INTELLIGENCE_GATEWAY_REL
],
)
with tempfile.TemporaryDirectory(
prefix="nodedc-engine-mcp-execution-plan-load-"
) as load_directory:
loaded = RUNNER.load_artifact(
first_artifact,
Path(load_directory),
)
self.assertEqual(tuple(loaded[1]), entries)
def test_preflight_requires_035_foundation_and_absent_new_paths(self):
with tempfile.TemporaryDirectory(
prefix="nodedc-engine-mcp-execution-plan-preflight-"
) as directory:
root = Path(directory)
hashes = {}
for mapping in (
RUNNER.ENGINE_MCP_EXECUTION_PLAN_MATERIALIZATION_PREDECESSOR_SHA256,
RUNNER.ENGINE_MCP_EXECUTION_PLAN_MATERIALIZATION_FOUNDATION_SHA256,
):
for relative_path, expected in mapping.items():
path = root / relative_path
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text("installed\n", encoding="utf-8")
hashes[path] = expected
with (
mock.patch.object(RUNNER, "component_root", return_value=root),
mock.patch.object(
RUNNER,
"sha256_file",
side_effect=lambda path: hashes[Path(path)],
),
mock.patch.object(
RUNNER,
"preflight_engine_credential_backend_runtime",
return_value={"mode": "verified-derived-retry"},
),
):
result = (
RUNNER
.preflight_engine_mcp_execution_plan_materialization_predecessor()
)
self.assertEqual(
result["mode"],
"telemetry-catalog-v1-to-execution-plan-materialization-v1",
)
self.assertEqual(
result["foundation_sha256"],
RUNNER.ENGINE_MCP_EXECUTION_PLAN_MATERIALIZATION_FOUNDATION_SHA256,
)
new_path = (
root
/ RUNNER.ENGINE_MCP_EXECUTION_PLAN_MATERIALIZATION_NEW_PATHS[0]
)
new_path.parent.mkdir(parents=True, exist_ok=True)
new_path.write_text("{}\n", encoding="utf-8")
with (
mock.patch.object(RUNNER, "component_root", return_value=root),
mock.patch.object(
RUNNER,
"sha256_file",
side_effect=lambda path: hashes[Path(path)],
),
):
with self.assertRaises(RUNNER.DeployError):
(
RUNNER
.preflight_engine_mcp_execution_plan_materialization_predecessor()
)
def test_plan_renders_external_two_phase_boundary(self):
self.require_current_target_source()
with tempfile.TemporaryDirectory(
prefix="nodedc-engine-mcp-execution-plan-plan-"
) as directory:
root = Path(directory)
artifact = Path(self.build(root / "artifacts")["artifact"])
live_root = root / "live"
live_root.mkdir()
preflight = {
"mode": "telemetry-catalog-v1-to-execution-plan-materialization-v1",
"predecessor_sha256": dict(
RUNNER
.ENGINE_MCP_EXECUTION_PLAN_MATERIALIZATION_PREDECESSOR_SHA256
),
"foundation_sha256": dict(
RUNNER
.ENGINE_MCP_EXECUTION_PLAN_MATERIALIZATION_FOUNDATION_SHA256
),
"target_sha256": dict(
RUNNER.ENGINE_MCP_EXECUTION_PLAN_MATERIALIZATION_TARGET_SHA256
),
"new_paths": tuple(
RUNNER.ENGINE_MCP_EXECUTION_PLAN_MATERIALIZATION_NEW_PATHS
),
"backend_mode": "verified-derived-retry",
}
output = io.StringIO()
with (
mock.patch.object(RUNNER, "validate_artifact_location"),
mock.patch.object(RUNNER, "ensure_layout"),
mock.patch.object(RUNNER, "TMP_DIR", root),
mock.patch.object(RUNNER, "component_root", return_value=live_root),
mock.patch.object(
RUNNER,
"component_compose_root",
return_value=live_root,
),
mock.patch.object(
RUNNER,
"preflight_engine_mcp_execution_plan_materialization_predecessor",
return_value=preflight,
),
mock.patch.object(
RUNNER,
"preflight_engine_credential_backend_runtime",
return_value={"mode": "verified-derived-retry"},
),
mock.patch.object(RUNNER, "state_has_sha", return_value=False),
mock.patch.object(RUNNER, "state_has_patch_id", return_value=False),
redirect_stdout(output),
):
RUNNER.plan_artifact(artifact)
plan = output.getvalue()
self.assertIn("services=nodedc-backend", plan)
self.assertIn("engine_mcp_version=0.9.0", plan)
self.assertIn("engine_mcp_surface=external-codex", plan)
self.assertIn(
"engine_mcp_provider_logic_authority=trusted-provider-package",
plan,
)
self.assertIn(
"engine_mcp_unmanaged_graph_policy=explicit-adoption-required",
plan,
)
self.assertIn("engine_mcp_plan_phase=read-only", plan)
self.assertIn("engine_mcp_apply_phase=opaque-plan-ref-only", plan)
self.assertIn("l2_graph=untouched", plan)
self.assertIn("embedded_ai_workspace=untouched", plan)
self.assertIn("state=new", plan)
def test_healthchecks_dispatch_materialization_acceptance(self):
entries = (
RUNNER.ENGINE_MCP_EXECUTION_PLAN_MATERIALIZATION_ARTIFACT_ENTRIES
)
with tempfile.TemporaryDirectory(
prefix="nodedc-engine-mcp-execution-plan-health-"
) as directory:
root = Path(directory)
all_hashes = {
**RUNNER.ENGINE_MCP_EXECUTION_PLAN_MATERIALIZATION_TARGET_SHA256,
**RUNNER.ENGINE_MCP_EXECUTION_PLAN_MATERIALIZATION_FOUNDATION_SHA256,
}
for relative_path in all_hashes:
path = root / relative_path
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text("target\n", encoding="utf-8")
def target_hash(path):
relative_path = Path(path).relative_to(root).as_posix()
return all_hashes[relative_path]
with (
mock.patch.object(RUNNER, "component_root", return_value=root),
mock.patch.object(RUNNER, "sha256_file", side_effect=target_hash),
mock.patch.object(RUNNER, "healthcheck_compose_service"),
mock.patch.object(RUNNER, "component_healthchecks", return_value=()),
mock.patch.object(
RUNNER,
"preflight_engine_credential_backend_runtime",
return_value={"mode": "verified-derived-retry"},
),
mock.patch.object(
RUNNER,
"accept_engine_mcp_execution_plan_materialization_runtime",
return_value={"live": "accepted"},
) as acceptance,
mock.patch.object(RUNNER, "healthcheck_container"),
):
RUNNER.run_healthchecks(
"engine",
entries,
("nodedc-backend",),
)
acceptance.assert_called_once_with()
def test_live_acceptance_probes_provider_neutral_two_phase_contract(self):
expected_live = (
"engine-mcp-execution-plan-materialization:"
"0.9.0:provider-package-authority:two-phase:v1"
)
with (
mock.patch.object(RUNNER, "component_root", return_value=ENGINE_ROOT),
mock.patch.object(
RUNNER,
"validate_engine_mcp_execution_plan_materialization_slice",
),
mock.patch.object(
RUNNER,
"engine_backend_container_id",
return_value="backend",
),
mock.patch.object(
RUNNER,
"run_engine_backend_probe",
return_value=expected_live,
) as backend_probe,
):
result = (
RUNNER
.accept_engine_mcp_execution_plan_materialization_runtime()
)
self.assertEqual(result["live"], expected_live)
self.assertEqual(
backend_probe.call_args.args[1],
"Engine MCP execution plan materialization",
)
probe_source = backend_probe.call_args.args[0][-1]
self.assertIn(
"engine_plan_l2_execution_plan_materialization",
probe_source,
)
self.assertIn("unmanaged_existing", probe_source)
self.assertIn("additionalProperties!==false", probe_source)
if __name__ == "__main__":
unittest.main(verbosity=2)

View File

@ -0,0 +1,350 @@
import hashlib
import importlib.machinery
import importlib.util
import io
import json
import os
from pathlib import Path
import subprocess
import tarfile
import tempfile
import unittest
from contextlib import redirect_stdout
from unittest import mock
SCRIPT_DIR = Path(__file__).resolve().parent
RUNNER_PATH = SCRIPT_DIR / "nodedc-deploy"
BUILDER_PATH = (
SCRIPT_DIR
/ "build-engine-mcp-execution-plan-module-ownership-artifact.mjs"
)
ENGINE_ROOT = SCRIPT_DIR.parent.parent.parent / "NODEDC_ENGINE_INFRA"
PATCH_ID = "engine-mcp-execution-plan-module-ownership-20991231-999"
def load_runner():
loader = importlib.machinery.SourceFileLoader(
"nodedc_engine_mcp_execution_plan_module_ownership",
str(RUNNER_PATH),
)
spec = importlib.util.spec_from_loader(loader.name, loader)
module = importlib.util.module_from_spec(spec)
loader.exec_module(module)
return module
RUNNER = load_runner()
class EngineMcpExecutionPlanModuleOwnershipTest(unittest.TestCase):
def require_current_target_source(self):
for relative_path, expected in (
RUNNER
.ENGINE_MCP_EXECUTION_PLAN_MODULE_OWNERSHIP_TARGET_SHA256
.items()
):
path = ENGINE_ROOT / relative_path
if (
not path.is_file()
or hashlib.sha256(path.read_bytes()).hexdigest() != expected
):
self.skipTest("execution plan module ownership source has advanced")
def build(self, artifact_dir):
environment = os.environ.copy()
environment["NODEDC_ENGINE_SOURCE_ROOT"] = str(ENGINE_ROOT)
environment["NODEDC_DEPLOY_ARTIFACT_DIR"] = str(artifact_dir)
completed = subprocess.run(
["node", str(BUILDER_PATH), PATCH_ID],
check=True,
capture_output=True,
text=True,
env=environment,
)
return json.loads(completed.stdout)
def test_builder_emits_exact_deterministic_backend_only_successor(self):
self.require_current_target_source()
with tempfile.TemporaryDirectory(
prefix="nodedc-engine-mcp-execution-plan-module-ownership-"
) as directory:
root = Path(directory)
first = self.build(root / "first")
second = self.build(root / "second")
first_artifact = Path(first["artifact"])
second_artifact = Path(second["artifact"])
self.assertEqual(first_artifact.read_bytes(), second_artifact.read_bytes())
self.assertEqual(first["services"], ["nodedc-backend"])
self.assertEqual(first["mcpVersion"], "0.10.0")
self.assertEqual(
first["materializationStrategies"],
[
"create_or_reconcile_owned",
"adopt_existing",
"adopt_existing_module",
],
)
self.assertEqual(
tuple(first["entries"]),
RUNNER
.ENGINE_MCP_EXECUTION_PLAN_MODULE_OWNERSHIP_ARTIFACT_ENTRIES,
)
extract = root / "extract"
with tarfile.open(first_artifact, "r:gz") as archive:
archive.extractall(extract, filter="data")
entries = tuple(
(extract / "files.txt").read_text(encoding="utf-8").splitlines()
)
payload = extract / "payload"
self.assertTrue(
RUNNER.is_engine_mcp_execution_plan_module_ownership_slice(
"engine",
entries,
)
)
self.assertFalse(
RUNNER.is_engine_mcp_execution_plan_telemetry_runtime_slice(
"engine",
entries,
)
)
self.assertEqual(
RUNNER.component_services("engine", entries),
("nodedc-backend",),
)
RUNNER.validate_engine_mcp_execution_plan_module_ownership_slice(
payload,
entries,
)
with tempfile.TemporaryDirectory(
prefix="nodedc-engine-mcp-execution-plan-module-load-"
) as load_directory:
loaded = RUNNER.load_artifact(
first_artifact,
Path(load_directory),
)
self.assertEqual(tuple(loaded[1]), entries)
def test_preflight_requires_exact_037_foundation_and_absent_descriptor(self):
with tempfile.TemporaryDirectory(
prefix="nodedc-engine-mcp-execution-plan-module-preflight-"
) as directory:
root = Path(directory)
hashes = {}
for mapping in (
RUNNER
.ENGINE_MCP_EXECUTION_PLAN_MODULE_OWNERSHIP_PREDECESSOR_SHA256,
RUNNER
.ENGINE_MCP_EXECUTION_PLAN_MODULE_OWNERSHIP_FOUNDATION_SHA256,
):
for relative_path, expected in mapping.items():
path = root / relative_path
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text("installed\n", encoding="utf-8")
hashes[path] = expected
with (
mock.patch.object(RUNNER, "component_root", return_value=root),
mock.patch.object(
RUNNER,
"sha256_file",
side_effect=lambda path: hashes[Path(path)],
),
mock.patch.object(
RUNNER,
"preflight_engine_credential_backend_runtime",
return_value={"mode": "verified-derived-retry"},
),
):
result = (
RUNNER
.preflight_engine_mcp_execution_plan_module_ownership_predecessor()
)
self.assertEqual(
result["mode"],
"execution-plan-telemetry-runtime-v2-to-module-ownership-v3",
)
self.assertEqual(
result["foundation_sha256"],
RUNNER
.ENGINE_MCP_EXECUTION_PLAN_MODULE_OWNERSHIP_FOUNDATION_SHA256,
)
new_path = (
root
/ RUNNER
.ENGINE_MCP_EXECUTION_PLAN_MODULE_OWNERSHIP_NEW_PATHS[0]
)
new_path.parent.mkdir(parents=True, exist_ok=True)
new_path.write_text("{}\n", encoding="utf-8")
with (
mock.patch.object(RUNNER, "component_root", return_value=root),
mock.patch.object(
RUNNER,
"sha256_file",
side_effect=lambda path: hashes[Path(path)],
),
):
with self.assertRaises(RUNNER.DeployError):
(
RUNNER
.preflight_engine_mcp_execution_plan_module_ownership_predecessor()
)
def test_plan_renders_module_and_shared_boundary_contract(self):
self.require_current_target_source()
with tempfile.TemporaryDirectory(
prefix="nodedc-engine-mcp-execution-plan-module-plan-"
) as directory:
root = Path(directory)
artifact = Path(self.build(root / "artifacts")["artifact"])
live_root = root / "live"
live_root.mkdir()
preflight = {
"mode":
"execution-plan-telemetry-runtime-v2-to-module-ownership-v3",
"predecessor_sha256": dict(
RUNNER
.ENGINE_MCP_EXECUTION_PLAN_MODULE_OWNERSHIP_PREDECESSOR_SHA256
),
"foundation_sha256": dict(
RUNNER
.ENGINE_MCP_EXECUTION_PLAN_MODULE_OWNERSHIP_FOUNDATION_SHA256
),
"target_sha256": dict(
RUNNER
.ENGINE_MCP_EXECUTION_PLAN_MODULE_OWNERSHIP_TARGET_SHA256
),
"new_paths": tuple(
RUNNER
.ENGINE_MCP_EXECUTION_PLAN_MODULE_OWNERSHIP_NEW_PATHS
),
"backend_mode": "verified-derived-retry",
}
output = io.StringIO()
with (
mock.patch.object(RUNNER, "validate_artifact_location"),
mock.patch.object(RUNNER, "ensure_layout"),
mock.patch.object(RUNNER, "TMP_DIR", root),
mock.patch.object(RUNNER, "component_root", return_value=live_root),
mock.patch.object(
RUNNER,
"component_compose_root",
return_value=live_root,
),
mock.patch.object(
RUNNER,
"preflight_engine_mcp_execution_plan_module_ownership_predecessor",
return_value=preflight,
),
mock.patch.object(
RUNNER,
"preflight_engine_credential_backend_runtime",
return_value={"mode": "verified-derived-retry"},
),
mock.patch.object(RUNNER, "state_has_sha", return_value=False),
mock.patch.object(RUNNER, "state_has_patch_id", return_value=False),
redirect_stdout(output),
):
RUNNER.plan_artifact(artifact)
plan = output.getvalue()
self.assertIn("services=nodedc-backend", plan)
self.assertIn("engine_mcp_version=0.10.0", plan)
self.assertIn(
"engine_mcp_module_node_bindings=exact-one-to-one",
plan,
)
self.assertIn(
"engine_mcp_shared_manual_webhook="
"compatible-config-preserved",
plan,
)
self.assertIn("engine_provider_package_added=gelios.provider.v9", plan)
self.assertIn("l2_graph=untouched", plan)
self.assertIn("embedded_ai_workspace=untouched", plan)
self.assertIn("state=new", plan)
def test_healthchecks_dispatch_versioned_module_acceptance(self):
entries = (
RUNNER
.ENGINE_MCP_EXECUTION_PLAN_MODULE_OWNERSHIP_ARTIFACT_ENTRIES
)
with tempfile.TemporaryDirectory(
prefix="nodedc-engine-mcp-execution-plan-module-health-"
) as directory:
root = Path(directory)
all_hashes = {
**RUNNER
.ENGINE_MCP_EXECUTION_PLAN_MODULE_OWNERSHIP_TARGET_SHA256,
**RUNNER
.ENGINE_MCP_EXECUTION_PLAN_MODULE_OWNERSHIP_FOUNDATION_SHA256,
}
for relative_path in all_hashes:
path = root / relative_path
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text("target\n", encoding="utf-8")
def target_hash(path):
relative_path = Path(path).relative_to(root).as_posix()
return all_hashes[relative_path]
with (
mock.patch.object(RUNNER, "component_root", return_value=root),
mock.patch.object(RUNNER, "sha256_file", side_effect=target_hash),
mock.patch.object(RUNNER, "healthcheck_compose_service"),
mock.patch.object(RUNNER, "component_healthchecks", return_value=()),
mock.patch.object(
RUNNER,
"preflight_engine_credential_backend_runtime",
return_value={"mode": "verified-derived-retry"},
),
mock.patch.object(
RUNNER,
"accept_engine_mcp_execution_plan_module_ownership_runtime",
return_value={"live": "accepted"},
) as acceptance,
mock.patch.object(RUNNER, "healthcheck_container"),
):
RUNNER.run_healthchecks(
"engine",
entries,
("nodedc-backend",),
)
acceptance.assert_called_once_with()
def test_live_acceptance_probes_provider_neutral_module_boundary(self):
expected_live = (
"engine-mcp-execution-plan-module-ownership:"
"0.10.0:module-scoped:shared-boundary:v3"
)
with (
mock.patch.object(RUNNER, "component_root", return_value=ENGINE_ROOT),
mock.patch.object(
RUNNER,
"validate_engine_mcp_execution_plan_module_ownership_slice",
),
mock.patch.object(
RUNNER,
"engine_backend_container_id",
return_value="backend",
),
mock.patch.object(
RUNNER,
"run_engine_backend_probe",
return_value=expected_live,
) as backend_probe,
):
result = (
RUNNER
.accept_engine_mcp_execution_plan_module_ownership_runtime()
)
self.assertEqual(result["live"], expected_live)
probe_source = backend_probe.call_args.args[0][-1]
self.assertIn("adopt_existing_module", probe_source)
self.assertIn("preserveBoundNodeIds", probe_source)
self.assertIn("gelios|robot2b", probe_source)
if __name__ == "__main__":
unittest.main(verbosity=2)

View File

@ -0,0 +1,352 @@
import hashlib
import importlib.machinery
import importlib.util
import io
import json
import os
from pathlib import Path
import subprocess
import tarfile
import tempfile
import unittest
from contextlib import redirect_stdout
from unittest import mock
SCRIPT_DIR = Path(__file__).resolve().parent
RUNNER_PATH = SCRIPT_DIR / "nodedc-deploy"
BUILDER_PATH = (
SCRIPT_DIR
/ "build-engine-mcp-execution-plan-telemetry-runtime-artifact.mjs"
)
ENGINE_ROOT = SCRIPT_DIR.parent.parent.parent / "NODEDC_ENGINE_INFRA"
PATCH_ID = "engine-mcp-execution-plan-telemetry-runtime-20991231-999"
def load_runner():
loader = importlib.machinery.SourceFileLoader(
"nodedc_engine_mcp_execution_plan_telemetry_runtime",
str(RUNNER_PATH),
)
spec = importlib.util.spec_from_loader(loader.name, loader)
module = importlib.util.module_from_spec(spec)
loader.exec_module(module)
return module
RUNNER = load_runner()
class EngineMcpExecutionPlanTelemetryRuntimeTest(unittest.TestCase):
def require_current_target_source(self):
for relative_path, expected in (
RUNNER
.ENGINE_MCP_EXECUTION_PLAN_TELEMETRY_RUNTIME_TARGET_SHA256
.items()
):
path = ENGINE_ROOT / relative_path
if (
not path.is_file()
or hashlib.sha256(path.read_bytes()).hexdigest() != expected
):
self.skipTest(
"execution plan telemetry runtime source has advanced"
)
def build(self, artifact_dir):
environment = os.environ.copy()
environment["NODEDC_ENGINE_SOURCE_ROOT"] = str(ENGINE_ROOT)
environment["NODEDC_DEPLOY_ARTIFACT_DIR"] = str(artifact_dir)
completed = subprocess.run(
["node", str(BUILDER_PATH), PATCH_ID],
check=True,
capture_output=True,
text=True,
env=environment,
)
return json.loads(completed.stdout)
def test_builder_emits_exact_deterministic_backend_only_successor(self):
self.require_current_target_source()
with tempfile.TemporaryDirectory(
prefix="nodedc-engine-mcp-execution-plan-telemetry-runtime-"
) as directory:
root = Path(directory)
first = self.build(root / "first")
second = self.build(root / "second")
first_artifact = Path(first["artifact"])
second_artifact = Path(second["artifact"])
self.assertEqual(first_artifact.read_bytes(), second_artifact.read_bytes())
self.assertEqual(first["services"], ["nodedc-backend"])
self.assertEqual(first["mcpVersion"], "0.9.0")
self.assertEqual(first["compilerVersions"], ["1.1.0", "1.2.0"])
self.assertTrue(first["legacyRuntimePreserved"])
self.assertEqual(first["unprojectedParameters"], "discarded")
self.assertEqual(
tuple(first["entries"]),
RUNNER
.ENGINE_MCP_EXECUTION_PLAN_TELEMETRY_RUNTIME_ARTIFACT_ENTRIES,
)
extract = root / "extract"
with tarfile.open(first_artifact, "r:gz") as archive:
archive.extractall(extract, filter="data")
entries = tuple(
(extract / "files.txt").read_text(encoding="utf-8").splitlines()
)
payload = extract / "payload"
self.assertTrue(
RUNNER.is_engine_mcp_execution_plan_telemetry_runtime_slice(
"engine",
entries,
)
)
self.assertFalse(
RUNNER.is_engine_mcp_execution_plan_materialization_slice(
"engine",
entries,
)
)
self.assertEqual(
RUNNER.component_services("engine", entries),
("nodedc-backend",),
)
RUNNER.validate_engine_mcp_execution_plan_telemetry_runtime_slice(
payload,
entries,
)
with tempfile.TemporaryDirectory(
prefix="nodedc-engine-mcp-execution-plan-telemetry-load-"
) as load_directory:
loaded = RUNNER.load_artifact(
first_artifact,
Path(load_directory),
)
self.assertEqual(tuple(loaded[1]), entries)
def test_preflight_requires_exact_036_foundation_and_absent_descriptor(self):
with tempfile.TemporaryDirectory(
prefix="nodedc-engine-mcp-execution-plan-telemetry-preflight-"
) as directory:
root = Path(directory)
hashes = {}
for mapping in (
RUNNER
.ENGINE_MCP_EXECUTION_PLAN_TELEMETRY_RUNTIME_PREDECESSOR_SHA256,
RUNNER
.ENGINE_MCP_EXECUTION_PLAN_TELEMETRY_RUNTIME_FOUNDATION_SHA256,
):
for relative_path, expected in mapping.items():
path = root / relative_path
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text("installed\n", encoding="utf-8")
hashes[path] = expected
with (
mock.patch.object(RUNNER, "component_root", return_value=root),
mock.patch.object(
RUNNER,
"sha256_file",
side_effect=lambda path: hashes[Path(path)],
),
mock.patch.object(
RUNNER,
"preflight_engine_credential_backend_runtime",
return_value={"mode": "verified-derived-retry"},
),
):
result = (
RUNNER
.preflight_engine_mcp_execution_plan_telemetry_runtime_predecessor()
)
self.assertEqual(
result["mode"],
"execution-plan-materialization-v1-to-telemetry-runtime-v2",
)
self.assertEqual(
result["foundation_sha256"],
RUNNER
.ENGINE_MCP_EXECUTION_PLAN_TELEMETRY_RUNTIME_FOUNDATION_SHA256,
)
new_path = (
root
/ RUNNER
.ENGINE_MCP_EXECUTION_PLAN_TELEMETRY_RUNTIME_NEW_PATHS[0]
)
new_path.parent.mkdir(parents=True, exist_ok=True)
new_path.write_text("{}\n", encoding="utf-8")
with (
mock.patch.object(RUNNER, "component_root", return_value=root),
mock.patch.object(
RUNNER,
"sha256_file",
side_effect=lambda path: hashes[Path(path)],
),
):
with self.assertRaises(RUNNER.DeployError):
(
RUNNER
.preflight_engine_mcp_execution_plan_telemetry_runtime_predecessor()
)
def test_plan_renders_closed_provider_neutral_telemetry_boundary(self):
self.require_current_target_source()
with tempfile.TemporaryDirectory(
prefix="nodedc-engine-mcp-execution-plan-telemetry-plan-"
) as directory:
root = Path(directory)
artifact = Path(self.build(root / "artifacts")["artifact"])
live_root = root / "live"
live_root.mkdir()
preflight = {
"mode": "execution-plan-materialization-v1-to-telemetry-runtime-v2",
"predecessor_sha256": dict(
RUNNER
.ENGINE_MCP_EXECUTION_PLAN_TELEMETRY_RUNTIME_PREDECESSOR_SHA256
),
"foundation_sha256": dict(
RUNNER
.ENGINE_MCP_EXECUTION_PLAN_TELEMETRY_RUNTIME_FOUNDATION_SHA256
),
"target_sha256": dict(
RUNNER
.ENGINE_MCP_EXECUTION_PLAN_TELEMETRY_RUNTIME_TARGET_SHA256
),
"new_paths": tuple(
RUNNER
.ENGINE_MCP_EXECUTION_PLAN_TELEMETRY_RUNTIME_NEW_PATHS
),
"backend_mode": "verified-derived-retry",
}
output = io.StringIO()
with (
mock.patch.object(RUNNER, "validate_artifact_location"),
mock.patch.object(RUNNER, "ensure_layout"),
mock.patch.object(RUNNER, "TMP_DIR", root),
mock.patch.object(RUNNER, "component_root", return_value=live_root),
mock.patch.object(
RUNNER,
"component_compose_root",
return_value=live_root,
),
mock.patch.object(
RUNNER,
"preflight_engine_mcp_execution_plan_telemetry_runtime_predecessor",
return_value=preflight,
),
mock.patch.object(
RUNNER,
"preflight_engine_credential_backend_runtime",
return_value={"mode": "verified-derived-retry"},
),
mock.patch.object(RUNNER, "state_has_sha", return_value=False),
mock.patch.object(RUNNER, "state_has_patch_id", return_value=False),
redirect_stdout(output),
):
RUNNER.plan_artifact(artifact)
plan = output.getvalue()
self.assertIn("services=nodedc-backend", plan)
self.assertIn("engine_mcp_version=0.9.0", plan)
self.assertIn(
"engine_mcp_compiler_supported=1.1.0,1.2.0",
plan,
)
self.assertIn("engine_mcp_legacy_runtime_preserved=yes", plan)
self.assertIn(
"engine_mcp_telemetry_authority="
"trusted-projection+visible-declared-sensor",
plan,
)
self.assertIn("engine_mcp_unprojected_parameters=discarded", plan)
self.assertIn(
"engine_mcp_raw_provider_payload_at_publish=forbidden",
plan,
)
self.assertIn("l2_graph=untouched", plan)
self.assertIn("embedded_ai_workspace=untouched", plan)
self.assertIn("state=new", plan)
def test_healthchecks_dispatch_versioned_runtime_acceptance(self):
entries = (
RUNNER
.ENGINE_MCP_EXECUTION_PLAN_TELEMETRY_RUNTIME_ARTIFACT_ENTRIES
)
with tempfile.TemporaryDirectory(
prefix="nodedc-engine-mcp-execution-plan-telemetry-health-"
) as directory:
root = Path(directory)
all_hashes = {
**RUNNER
.ENGINE_MCP_EXECUTION_PLAN_TELEMETRY_RUNTIME_TARGET_SHA256,
**RUNNER
.ENGINE_MCP_EXECUTION_PLAN_TELEMETRY_RUNTIME_FOUNDATION_SHA256,
}
for relative_path in all_hashes:
path = root / relative_path
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text("target\n", encoding="utf-8")
def target_hash(path):
relative_path = Path(path).relative_to(root).as_posix()
return all_hashes[relative_path]
with (
mock.patch.object(RUNNER, "component_root", return_value=root),
mock.patch.object(RUNNER, "sha256_file", side_effect=target_hash),
mock.patch.object(RUNNER, "healthcheck_compose_service"),
mock.patch.object(RUNNER, "component_healthchecks", return_value=()),
mock.patch.object(
RUNNER,
"preflight_engine_credential_backend_runtime",
return_value={"mode": "verified-derived-retry"},
),
mock.patch.object(
RUNNER,
"accept_engine_mcp_execution_plan_telemetry_runtime",
return_value={"live": "accepted"},
) as acceptance,
mock.patch.object(RUNNER, "healthcheck_container"),
):
RUNNER.run_healthchecks(
"engine",
entries,
("nodedc-backend",),
)
acceptance.assert_called_once_with()
def test_live_acceptance_probes_version_and_provider_neutral_boundary(self):
expected_live = (
"engine-mcp-execution-plan-telemetry-runtime:"
"0.9.0:compiler-1.2.0:declared-projected:v2"
)
with (
mock.patch.object(RUNNER, "component_root", return_value=ENGINE_ROOT),
mock.patch.object(
RUNNER,
"validate_engine_mcp_execution_plan_telemetry_runtime_slice",
),
mock.patch.object(
RUNNER,
"engine_backend_container_id",
return_value="backend",
),
mock.patch.object(
RUNNER,
"run_engine_backend_probe",
return_value=expected_live,
) as backend_probe,
):
result = (
RUNNER
.accept_engine_mcp_execution_plan_telemetry_runtime()
)
self.assertEqual(result["live"], expected_live)
probe_source = backend_probe.call_args.args[0][-1]
self.assertIn("1.1.0,1.2.0", probe_source)
self.assertIn("msgParam", probe_source)
self.assertIn("descriptor.telemetryProjection", probe_source)
self.assertIn("gelios|robot2b", probe_source)
if __name__ == "__main__":
unittest.main(verbosity=2)

View File

@ -0,0 +1,325 @@
import hashlib
import importlib.machinery
import importlib.util
import io
import json
import os
from pathlib import Path
import subprocess
import tarfile
import tempfile
import unittest
from contextlib import redirect_stdout
from unittest import mock
SCRIPT_DIR = Path(__file__).resolve().parent
RUNNER_PATH = SCRIPT_DIR / "nodedc-deploy"
BUILDER_PATH = (
SCRIPT_DIR / "build-engine-mcp-execution-profile-decoder-artifact.mjs"
)
ENGINE_ROOT = SCRIPT_DIR.parent.parent.parent / "NODEDC_ENGINE_INFRA"
PATCH_ID = "engine-mcp-execution-profile-decoder-20991231-999"
def load_runner():
loader = importlib.machinery.SourceFileLoader(
"nodedc_engine_mcp_execution_profile_decoder",
str(RUNNER_PATH),
)
spec = importlib.util.spec_from_loader(loader.name, loader)
module = importlib.util.module_from_spec(spec)
loader.exec_module(module)
return module
RUNNER = load_runner()
class EngineMcpExecutionProfileDecoderTest(unittest.TestCase):
def require_current_target_source(self):
for relative_path, expected in (
RUNNER.ENGINE_MCP_EXECUTION_PROFILE_DECODER_TARGET_SHA256.items()
):
path = ENGINE_ROOT / relative_path
if (
not path.is_file()
or hashlib.sha256(path.read_bytes()).hexdigest() != expected
):
self.skipTest(
"execution profile decoder source has advanced "
"to its exact successor"
)
def build(self, artifact_dir):
environment = os.environ.copy()
environment["NODEDC_ENGINE_SOURCE_ROOT"] = str(ENGINE_ROOT)
environment["NODEDC_DEPLOY_ARTIFACT_DIR"] = str(artifact_dir)
completed = subprocess.run(
["node", str(BUILDER_PATH), PATCH_ID],
check=True,
capture_output=True,
text=True,
env=environment,
)
return json.loads(completed.stdout)
def test_builder_emits_exact_deterministic_two_file_slice(self):
self.require_current_target_source()
with tempfile.TemporaryDirectory(
prefix="nodedc-engine-mcp-profile-decoder-"
) as directory:
root = Path(directory)
first = self.build(root / "first")
second = self.build(root / "second")
first_artifact = Path(first["artifact"])
second_artifact = Path(second["artifact"])
self.assertEqual(first_artifact.read_bytes(), second_artifact.read_bytes())
self.assertEqual(
first["sha256"],
hashlib.sha256(first_artifact.read_bytes()).hexdigest(),
)
self.assertEqual(first["services"], ["nodedc-backend"])
self.assertEqual(first["valuesIncluded"], False)
self.assertEqual(first["rawExecutionDataIncluded"], False)
self.assertEqual(
tuple(first["entries"]),
RUNNER.ENGINE_MCP_EXECUTION_PROFILE_DECODER_ARTIFACT_ENTRIES,
)
extract = root / "extract"
with tarfile.open(first_artifact, "r:gz") as archive:
archive.extractall(extract, filter="data")
entries = tuple(
(extract / "files.txt").read_text(encoding="utf-8").splitlines()
)
payload = extract / "payload"
self.assertTrue(
RUNNER.is_engine_mcp_execution_profile_decoder_slice(
"engine",
entries,
)
)
self.assertFalse(
RUNNER.is_engine_provider_target_host_policy_slice(
"engine",
entries,
)
)
self.assertEqual(
RUNNER.component_services("engine", entries),
("nodedc-backend",),
)
self.assertEqual(
RUNNER.component_healthchecks(
"engine",
entries,
("nodedc-backend",),
),
("http://127.0.0.1:3001/health",),
)
RUNNER.validate_engine_mcp_execution_profile_decoder_slice(
payload,
entries,
)
with tempfile.TemporaryDirectory(
prefix="nodedc-engine-mcp-profile-plan-"
) as plan_directory:
loaded = RUNNER.load_artifact(
first_artifact,
Path(plan_directory),
)
self.assertEqual(loaded[0]["component"], "engine")
self.assertEqual(tuple(loaded[1]), entries)
def test_preflight_requires_exact_predecessor_and_absent_descriptor(self):
with tempfile.TemporaryDirectory(
prefix="nodedc-engine-mcp-profile-preflight-"
) as directory:
root = Path(directory)
relative_path = next(
iter(
RUNNER.ENGINE_MCP_EXECUTION_PROFILE_DECODER_PREDECESSOR_SHA256
)
)
path = root / relative_path
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text("predecessor\n", encoding="utf-8")
expected = (
RUNNER.ENGINE_MCP_EXECUTION_PROFILE_DECODER_PREDECESSOR_SHA256[
relative_path
]
)
with (
mock.patch.object(RUNNER, "component_root", return_value=root),
mock.patch.object(RUNNER, "sha256_file", return_value=expected),
mock.patch.object(
RUNNER,
"preflight_engine_credential_backend_runtime",
return_value={"mode": "verified-derived-retry"},
),
):
result = (
RUNNER.preflight_engine_mcp_execution_profile_decoder_predecessor()
)
self.assertEqual(
result["mode"],
"exact-flatted-numeric-string-preservation",
)
descriptor = (
root / RUNNER.ENGINE_MCP_EXECUTION_PROFILE_DECODER_DESCRIPTOR_REL
)
descriptor.parent.mkdir(parents=True, exist_ok=True)
descriptor.write_text("{}\n", encoding="utf-8")
with (
mock.patch.object(RUNNER, "component_root", return_value=root),
mock.patch.object(RUNNER, "sha256_file", return_value=expected),
):
with self.assertRaises(RUNNER.DeployError):
RUNNER.preflight_engine_mcp_execution_profile_decoder_predecessor()
def test_plan_renders_exact_external_mcp_boundary(self):
self.require_current_target_source()
with tempfile.TemporaryDirectory(
prefix="nodedc-engine-mcp-profile-plan-"
) as directory:
root = Path(directory)
built = self.build(root / "artifacts")
artifact = Path(built["artifact"])
live_root = root / "live"
live_root.mkdir()
preflight = {
"mode": "exact-flatted-numeric-string-preservation",
"predecessor_sha256": dict(
RUNNER.ENGINE_MCP_EXECUTION_PROFILE_DECODER_PREDECESSOR_SHA256
),
"target_sha256": dict(
RUNNER.ENGINE_MCP_EXECUTION_PROFILE_DECODER_TARGET_SHA256
),
"new_paths": tuple(
RUNNER.ENGINE_MCP_EXECUTION_PROFILE_DECODER_NEW_PATHS
),
"backend_mode": "verified-derived-retry",
}
output = io.StringIO()
with (
mock.patch.object(RUNNER, "validate_artifact_location"),
mock.patch.object(RUNNER, "ensure_layout"),
mock.patch.object(RUNNER, "TMP_DIR", root),
mock.patch.object(RUNNER, "component_root", return_value=live_root),
mock.patch.object(
RUNNER,
"component_compose_root",
return_value=live_root,
),
mock.patch.object(
RUNNER,
"preflight_engine_mcp_execution_profile_decoder_predecessor",
return_value=preflight,
),
mock.patch.object(
RUNNER,
"preflight_engine_credential_backend_runtime",
return_value={"mode": "verified-derived-retry"},
),
mock.patch.object(RUNNER, "state_has_sha", return_value=False),
mock.patch.object(RUNNER, "state_has_patch_id", return_value=False),
redirect_stdout(output),
):
RUNNER.plan_artifact(artifact)
plan = output.getvalue()
self.assertIn("services=nodedc-backend", plan)
self.assertIn(
"engine_mcp_observability_transition="
"exact-flatted-numeric-string-preservation",
plan,
)
self.assertIn("engine_mcp_surface=external-codex", plan)
self.assertIn("engine_mcp_profile_values_included=no", plan)
self.assertIn("mcp_nginx=untouched", plan)
self.assertIn("embedded_ai_workspace=untouched", plan)
self.assertIn(
"engine_mcp_profile_decoder_predecessor_state"
f"[{RUNNER.ENGINE_MCP_EXECUTION_PROFILE_DECODER_DESCRIPTOR_REL}]="
"absent",
plan,
)
self.assertIn("state=new", plan)
def test_healthchecks_dispatch_safe_profile_acceptance(self):
entries = RUNNER.ENGINE_MCP_EXECUTION_PROFILE_DECODER_ARTIFACT_ENTRIES
with tempfile.TemporaryDirectory(
prefix="nodedc-engine-mcp-profile-health-"
) as directory:
root = Path(directory)
for relative_path in entries:
path = root / relative_path
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text("target\n", encoding="utf-8")
def target_hash(path):
relative_path = Path(path).relative_to(root).as_posix()
return (
RUNNER.ENGINE_MCP_EXECUTION_PROFILE_DECODER_TARGET_SHA256[
relative_path
]
)
with (
mock.patch.object(RUNNER, "component_root", return_value=root),
mock.patch.object(RUNNER, "sha256_file", side_effect=target_hash),
mock.patch.object(RUNNER, "healthcheck_compose_service"),
mock.patch.object(RUNNER, "component_healthchecks", return_value=()),
mock.patch.object(
RUNNER,
"preflight_engine_credential_backend_runtime",
return_value={"mode": "verified-derived-retry"},
),
mock.patch.object(
RUNNER,
"accept_engine_mcp_execution_profile_decoder_runtime",
return_value={"live": "accepted"},
) as acceptance,
mock.patch.object(RUNNER, "healthcheck_container"),
):
RUNNER.run_healthchecks(
"engine",
entries,
("nodedc-backend",),
)
acceptance.assert_called_once_with()
def test_live_acceptance_uses_safe_profile_contract(self):
self.require_current_target_source()
expected_live = (
"engine-mcp-execution-profile-decoder:"
"flatted-numeric-strings:safe-profile:v1"
)
with (
mock.patch.object(RUNNER, "component_root", return_value=ENGINE_ROOT),
mock.patch.object(
RUNNER,
"engine_backend_container_id",
return_value="backend",
),
mock.patch.object(
RUNNER,
"run_engine_backend_probe",
return_value=expected_live,
) as backend_probe,
):
result = RUNNER.accept_engine_mcp_execution_profile_decoder_runtime()
self.assertEqual(result["live"], expected_live)
self.assertEqual(
backend_probe.call_args.args[1],
"Engine MCP execution profile decoder",
)
probe_source = backend_probe.call_args.args[0][-1]
self.assertIn("module.toSafeNodeOutputProfile", probe_source)
self.assertIn("profile.valuesIncluded!==false", probe_source)
self.assertIn(".json.fact", probe_source)
if __name__ == "__main__":
unittest.main(verbosity=2)

View File

@ -0,0 +1,349 @@
import hashlib
import importlib.machinery
import importlib.util
import io
import json
import os
from pathlib import Path
import subprocess
import tarfile
import tempfile
import unittest
from contextlib import redirect_stdout
from unittest import mock
SCRIPT_DIR = Path(__file__).resolve().parent
RUNNER_PATH = SCRIPT_DIR / "nodedc-deploy"
BUILDER_PATH = (
SCRIPT_DIR / "build-engine-mcp-l1-credential-provenance-artifact.mjs"
)
ENGINE_ROOT = SCRIPT_DIR.parent.parent.parent / "NODEDC_ENGINE_INFRA"
PATCH_ID = "engine-mcp-l1-credential-provenance-20991231-999"
def load_runner():
loader = importlib.machinery.SourceFileLoader(
"nodedc_engine_mcp_l1_credential_provenance",
str(RUNNER_PATH),
)
spec = importlib.util.spec_from_loader(loader.name, loader)
module = importlib.util.module_from_spec(spec)
loader.exec_module(module)
return module
RUNNER = load_runner()
class EngineMcpL1CredentialProvenanceTest(unittest.TestCase):
def require_current_target_source(self):
for relative_path, expected in (
RUNNER.ENGINE_MCP_L1_CREDENTIAL_PROVENANCE_TARGET_SHA256.items()
):
path = ENGINE_ROOT / relative_path
if (
not path.is_file()
or hashlib.sha256(path.read_bytes()).hexdigest() != expected
):
self.skipTest("L1 credential provenance source has advanced")
def build(self, artifact_dir, engine_root=ENGINE_ROOT):
environment = os.environ.copy()
environment["NODEDC_ENGINE_SOURCE_ROOT"] = str(engine_root)
environment["NODEDC_DEPLOY_ARTIFACT_DIR"] = str(artifact_dir)
completed = subprocess.run(
["node", str(BUILDER_PATH), PATCH_ID],
check=True,
capture_output=True,
text=True,
env=environment,
)
return json.loads(completed.stdout)
def test_builder_is_deterministic_exact_and_backend_only(self):
self.require_current_target_source()
with tempfile.TemporaryDirectory(
prefix="nodedc-engine-mcp-l1-credential-provenance-"
) as directory:
root = Path(directory)
first = self.build(root / "first")
second = self.build(root / "second")
first_artifact = Path(first["artifact"])
second_artifact = Path(second["artifact"])
self.assertEqual(first_artifact.read_bytes(), second_artifact.read_bytes())
self.assertEqual(first["services"], ["nodedc-backend"])
self.assertEqual(first["mcpVersion"], "0.11.0")
self.assertEqual(first["credentialScope"], "same-l1-workflow")
self.assertEqual(
first["localProvenanceSources"],
["manual", "workflow-ref", "credentials-file"],
)
self.assertTrue(first["referencedSourceRequiresSyncPayload"])
self.assertFalse(first["crossL1Sharing"])
self.assertFalse(first["credentialValuesIncluded"])
extract = root / "extract"
with tarfile.open(first_artifact, "r:gz") as archive:
archive.extractall(extract, filter="data")
entries = tuple(
(extract / "files.txt").read_text(encoding="utf-8").splitlines()
)
payload = extract / "payload"
self.assertEqual(
entries,
RUNNER.ENGINE_MCP_L1_CREDENTIAL_PROVENANCE_ARTIFACT_ENTRIES,
)
self.assertTrue(
RUNNER.is_engine_mcp_l1_credential_provenance_slice(
"engine",
entries,
)
)
self.assertEqual(
RUNNER.component_services("engine", entries),
("nodedc-backend",),
)
RUNNER.validate_engine_mcp_l1_credential_provenance_slice(
payload,
entries,
)
(root / "load").mkdir()
manifest, loaded_entries, _ = RUNNER.load_artifact(
first_artifact,
root / "load",
)
self.assertEqual(manifest["component"], "engine")
self.assertEqual(tuple(loaded_entries), entries)
def test_builder_rejects_source_drift(self):
self.require_current_target_source()
with tempfile.TemporaryDirectory(
prefix="nodedc-engine-mcp-l1-credential-provenance-drift-"
) as directory:
root = Path(directory)
engine_copy = root / "engine"
for relative_path in (
RUNNER.ENGINE_MCP_L1_CREDENTIAL_PROVENANCE_ARTIFACT_ENTRIES
):
source = ENGINE_ROOT / relative_path
destination = engine_copy / relative_path
destination.parent.mkdir(parents=True, exist_ok=True)
destination.write_bytes(source.read_bytes())
route = engine_copy / "nodedc-source/server/routes/n8n.js"
route.write_text(
route.read_text(encoding="utf-8") + "\n// drift\n",
encoding="utf-8",
)
with self.assertRaises(subprocess.CalledProcessError):
self.build(root / "artifact", engine_copy)
def test_descriptor_requires_sync_proof_and_keeps_cross_l1_closed(self):
self.require_current_target_source()
descriptor = json.loads(
(
ENGINE_ROOT
/ RUNNER.ENGINE_MCP_L1_CREDENTIAL_PROVENANCE_DESCRIPTOR_REL
).read_text(encoding="utf-8")
)
self.assertEqual(
descriptor["predecessor"],
"engine-mcp-l1-credential-reuse-v1",
)
self.assertTrue(
descriptor["visibilityProof"][
"referencedSourceRequiresSyncPayload"
]
)
self.assertTrue(
descriptor["visibilityProof"]["logicalKeyEqualityRequired"]
)
self.assertFalse(descriptor["crossL1Sharing"])
self.assertFalse(
descriptor["candidateBoundary"]["managedEntriesAllowed"]
)
def test_preflight_requires_043_foundation_and_absent_v2_descriptor(self):
with tempfile.TemporaryDirectory(
prefix="nodedc-engine-mcp-l1-credential-provenance-preflight-"
) as directory:
root = Path(directory)
hashes = {}
for mapping in (
RUNNER.ENGINE_MCP_L1_CREDENTIAL_PROVENANCE_PREDECESSOR_SHA256,
RUNNER.ENGINE_MCP_L1_CREDENTIAL_PROVENANCE_FOUNDATION_SHA256,
):
for relative_path, expected in mapping.items():
path = root / relative_path
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text("installed\n", encoding="utf-8")
hashes[path] = expected
with (
mock.patch.object(RUNNER, "component_root", return_value=root),
mock.patch.object(
RUNNER,
"sha256_file",
side_effect=lambda path: hashes[Path(path)],
),
mock.patch.object(
RUNNER,
"preflight_engine_credential_backend_runtime",
return_value={"mode": "verified-derived-retry"},
),
):
result = (
RUNNER
.preflight_engine_mcp_l1_credential_provenance_predecessor()
)
self.assertEqual(
result["mode"],
"l1-credential-reuse-v1-to-provenance-v2",
)
self.assertIn(
RUNNER.ENGINE_MCP_L1_CREDENTIAL_REUSE_DESCRIPTOR_REL,
result["foundation_sha256"],
)
def test_plan_renders_exact_provenance_boundary(self):
self.require_current_target_source()
with tempfile.TemporaryDirectory(
prefix="nodedc-engine-mcp-l1-credential-provenance-plan-"
) as directory:
root = Path(directory)
artifact = Path(self.build(root / "artifacts")["artifact"])
live_root = root / "live"
live_root.mkdir()
preflight = {
"mode": "l1-credential-reuse-v1-to-provenance-v2",
"predecessor_sha256": dict(
RUNNER
.ENGINE_MCP_L1_CREDENTIAL_PROVENANCE_PREDECESSOR_SHA256
),
"foundation_sha256": dict(
RUNNER.ENGINE_MCP_L1_CREDENTIAL_PROVENANCE_FOUNDATION_SHA256
),
"target_sha256": dict(
RUNNER.ENGINE_MCP_L1_CREDENTIAL_PROVENANCE_TARGET_SHA256
),
"new_paths": tuple(
RUNNER.ENGINE_MCP_L1_CREDENTIAL_PROVENANCE_NEW_PATHS
),
"backend_mode": "verified-derived-retry",
}
output = io.StringIO()
with (
mock.patch.object(RUNNER, "validate_artifact_location"),
mock.patch.object(RUNNER, "ensure_layout"),
mock.patch.object(RUNNER, "TMP_DIR", root),
mock.patch.object(RUNNER, "component_root", return_value=live_root),
mock.patch.object(
RUNNER,
"component_compose_root",
return_value=live_root,
),
mock.patch.object(
RUNNER,
"preflight_engine_mcp_l1_credential_provenance_predecessor",
return_value=preflight,
),
mock.patch.object(
RUNNER,
"preflight_engine_credential_backend_runtime",
return_value={"mode": "verified-derived-retry"},
),
mock.patch.object(RUNNER, "state_has_sha", return_value=False),
mock.patch.object(RUNNER, "state_has_patch_id", return_value=False),
redirect_stdout(output),
):
RUNNER.plan_artifact(artifact)
plan = output.getvalue()
self.assertIn("services=nodedc-backend", plan)
self.assertIn(
"engine_mcp_local_provenance_sources="
"manual,workflow-ref,credentials-file",
plan,
)
self.assertIn(
"engine_mcp_referenced_source_requires_sync_payload=yes",
plan,
)
self.assertIn("engine_mcp_logical_key_equality_required=yes", plan)
self.assertIn("engine_mcp_cross_l1_sharing=no", plan)
self.assertIn("l2_graph=untouched", plan)
self.assertIn("state=new", plan)
def test_healthchecks_dispatch_provenance_acceptance(self):
entries = RUNNER.ENGINE_MCP_L1_CREDENTIAL_PROVENANCE_ARTIFACT_ENTRIES
with tempfile.TemporaryDirectory(
prefix="nodedc-engine-mcp-l1-credential-provenance-health-"
) as directory:
root = Path(directory)
all_hashes = {
**RUNNER.ENGINE_MCP_L1_CREDENTIAL_PROVENANCE_TARGET_SHA256,
**RUNNER.ENGINE_MCP_L1_CREDENTIAL_PROVENANCE_FOUNDATION_SHA256,
}
for relative_path in all_hashes:
path = root / relative_path
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text("target\n", encoding="utf-8")
def target_hash(path):
relative_path = Path(path).relative_to(root).as_posix()
return all_hashes[relative_path]
with (
mock.patch.object(RUNNER, "component_root", return_value=root),
mock.patch.object(RUNNER, "sha256_file", side_effect=target_hash),
mock.patch.object(RUNNER, "healthcheck_compose_service"),
mock.patch.object(RUNNER, "component_healthchecks", return_value=()),
mock.patch.object(
RUNNER,
"preflight_engine_credential_backend_runtime",
return_value={"mode": "verified-derived-retry"},
),
mock.patch.object(
RUNNER,
"accept_engine_mcp_l1_credential_provenance_runtime",
return_value={"live": "accepted"},
) as acceptance,
mock.patch.object(RUNNER, "healthcheck_container"),
):
RUNNER.run_healthchecks(
"engine",
entries,
("nodedc-backend",),
)
acceptance.assert_called_once_with()
def test_live_acceptance_proves_workflow_ref_sync_boundary(self):
expected_live = (
"engine-mcp-l1-credential-provenance:"
"0.11.0:workflow-ref-sync-proof:v2"
)
with (
mock.patch.object(RUNNER, "component_root", return_value=ENGINE_ROOT),
mock.patch.object(
RUNNER,
"validate_engine_mcp_l1_credential_provenance_slice",
),
mock.patch.object(
RUNNER,
"engine_backend_container_id",
return_value="backend",
),
mock.patch.object(
RUNNER,
"run_engine_backend_probe",
return_value=expected_live,
) as backend_probe,
):
result = RUNNER.accept_engine_mcp_l1_credential_provenance_runtime()
self.assertEqual(result["live"], expected_live)
probe_source = backend_probe.call_args.args[0][-1]
self.assertIn("engineAgentCredentialMayProveL1Provenance", probe_source)
self.assertIn("workflowRefWithoutSync", probe_source)
self.assertIn("engineAgentCredentialMayReuseWithinL1", probe_source)
if __name__ == "__main__":
unittest.main(verbosity=2)

View File

@ -0,0 +1,345 @@
import hashlib
import importlib.machinery
import importlib.util
import io
import json
import os
from pathlib import Path
import subprocess
import tarfile
import tempfile
import unittest
from contextlib import redirect_stdout
from unittest import mock
SCRIPT_DIR = Path(__file__).resolve().parent
RUNNER_PATH = SCRIPT_DIR / "nodedc-deploy"
BUILDER_PATH = (
SCRIPT_DIR / "build-engine-mcp-l1-credential-reuse-artifact.mjs"
)
ENGINE_ROOT = SCRIPT_DIR.parent.parent.parent / "NODEDC_ENGINE_INFRA"
PATCH_ID = "engine-mcp-l1-credential-reuse-20991231-999"
def load_runner():
loader = importlib.machinery.SourceFileLoader(
"nodedc_engine_mcp_l1_credential_reuse",
str(RUNNER_PATH),
)
spec = importlib.util.spec_from_loader(loader.name, loader)
module = importlib.util.module_from_spec(spec)
loader.exec_module(module)
return module
RUNNER = load_runner()
class EngineMcpL1CredentialReuseTest(unittest.TestCase):
def require_current_target_source(self):
for relative_path, expected in (
RUNNER.ENGINE_MCP_L1_CREDENTIAL_REUSE_TARGET_SHA256.items()
):
path = ENGINE_ROOT / relative_path
if (
not path.is_file()
or hashlib.sha256(path.read_bytes()).hexdigest() != expected
):
self.skipTest("L1 credential reuse source has advanced")
def build(self, artifact_dir, engine_root=ENGINE_ROOT):
environment = os.environ.copy()
environment["NODEDC_ENGINE_SOURCE_ROOT"] = str(engine_root)
environment["NODEDC_DEPLOY_ARTIFACT_DIR"] = str(artifact_dir)
completed = subprocess.run(
["node", str(BUILDER_PATH), PATCH_ID],
check=True,
capture_output=True,
text=True,
env=environment,
)
return json.loads(completed.stdout)
def test_builder_emits_exact_deterministic_backend_only_successor(self):
self.require_current_target_source()
with tempfile.TemporaryDirectory(
prefix="nodedc-engine-mcp-l1-credential-reuse-"
) as directory:
root = Path(directory)
first = self.build(root / "first")
second = self.build(root / "second")
first_artifact = Path(first["artifact"])
second_artifact = Path(second["artifact"])
self.assertEqual(first_artifact.read_bytes(), second_artifact.read_bytes())
self.assertEqual(first["services"], ["nodedc-backend"])
self.assertEqual(first["mcpVersion"], "0.11.0")
self.assertEqual(first["tool"], "engine_list_l2_credential_refs")
self.assertEqual(first["credentialScope"], "same-l1-workflow")
self.assertFalse(first["crossL1Sharing"])
self.assertEqual(first["managedGrants"], "target-local")
self.assertFalse(first["credentialValuesIncluded"])
extract = root / "extract"
with tarfile.open(first_artifact, "r:gz") as archive:
archive.extractall(extract, filter="data")
entries = tuple(
(extract / "files.txt").read_text(encoding="utf-8").splitlines()
)
payload = extract / "payload"
self.assertEqual(
entries,
RUNNER.ENGINE_MCP_L1_CREDENTIAL_REUSE_ARTIFACT_ENTRIES,
)
self.assertTrue(
RUNNER.is_engine_mcp_l1_credential_reuse_slice(
"engine",
entries,
)
)
self.assertEqual(
RUNNER.component_services("engine", entries),
("nodedc-backend",),
)
RUNNER.validate_engine_mcp_l1_credential_reuse_slice(
payload,
entries,
)
(root / "load").mkdir()
manifest, loaded_entries, _ = RUNNER.load_artifact(
first_artifact,
root / "load",
)
self.assertEqual(manifest["component"], "engine")
self.assertEqual(tuple(loaded_entries), entries)
def test_builder_rejects_source_drift(self):
self.require_current_target_source()
with tempfile.TemporaryDirectory(
prefix="nodedc-engine-mcp-l1-credential-reuse-drift-"
) as directory:
root = Path(directory)
engine_copy = root / "engine"
for relative_path in (
RUNNER.ENGINE_MCP_L1_CREDENTIAL_REUSE_ARTIFACT_ENTRIES
):
source = ENGINE_ROOT / relative_path
destination = engine_copy / relative_path
destination.parent.mkdir(parents=True, exist_ok=True)
destination.write_bytes(source.read_bytes())
route = engine_copy / "nodedc-source/server/routes/n8n.js"
route.write_text(
route.read_text(encoding="utf-8") + "\n// drift\n",
encoding="utf-8",
)
with self.assertRaises(subprocess.CalledProcessError):
self.build(root / "artifact", engine_copy)
def test_descriptor_closes_identity_and_cross_l1_boundaries(self):
self.require_current_target_source()
descriptor = json.loads(
(
ENGINE_ROOT
/ RUNNER.ENGINE_MCP_L1_CREDENTIAL_REUSE_DESCRIPTOR_REL
).read_text(encoding="utf-8")
)
self.assertEqual(descriptor["mcpVersion"], "0.11.0")
self.assertEqual(
descriptor["visibilityProof"]["scope"],
"same-l1-workflow",
)
self.assertEqual(
descriptor["binding"]["applyOperation"],
"assignCredentialRef",
)
self.assertFalse(descriptor["crossL1Sharing"])
self.assertEqual(descriptor["managedGrants"], "target-local")
self.assertIn(
"credential-value",
descriptor["binding"]["neverReturns"],
)
def test_preflight_requires_exact_foundation_and_absent_descriptor(self):
with tempfile.TemporaryDirectory(
prefix="nodedc-engine-mcp-l1-credential-reuse-preflight-"
) as directory:
root = Path(directory)
hashes = {}
for mapping in (
RUNNER.ENGINE_MCP_L1_CREDENTIAL_REUSE_PREDECESSOR_SHA256,
RUNNER.ENGINE_MCP_L1_CREDENTIAL_REUSE_FOUNDATION_SHA256,
):
for relative_path, expected in mapping.items():
path = root / relative_path
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text("installed\n", encoding="utf-8")
hashes[path] = expected
with (
mock.patch.object(RUNNER, "component_root", return_value=root),
mock.patch.object(
RUNNER,
"sha256_file",
side_effect=lambda path: hashes[Path(path)],
),
mock.patch.object(
RUNNER,
"preflight_engine_credential_backend_runtime",
return_value={"mode": "verified-derived-retry"},
),
):
result = (
RUNNER
.preflight_engine_mcp_l1_credential_reuse_predecessor()
)
self.assertEqual(
result["mode"],
"normalized-identity-search-v1-to-l1-credential-reuse-v1",
)
self.assertEqual(
result["foundation_sha256"],
RUNNER.ENGINE_MCP_L1_CREDENTIAL_REUSE_FOUNDATION_SHA256,
)
def test_plan_renders_closed_same_l1_boundary(self):
self.require_current_target_source()
with tempfile.TemporaryDirectory(
prefix="nodedc-engine-mcp-l1-credential-reuse-plan-"
) as directory:
root = Path(directory)
artifact = Path(self.build(root / "artifacts")["artifact"])
live_root = root / "live"
live_root.mkdir()
preflight = {
"mode": "normalized-identity-search-v1-to-l1-credential-reuse-v1",
"predecessor_sha256": dict(
RUNNER.ENGINE_MCP_L1_CREDENTIAL_REUSE_PREDECESSOR_SHA256
),
"foundation_sha256": dict(
RUNNER.ENGINE_MCP_L1_CREDENTIAL_REUSE_FOUNDATION_SHA256
),
"target_sha256": dict(
RUNNER.ENGINE_MCP_L1_CREDENTIAL_REUSE_TARGET_SHA256
),
"new_paths": tuple(
RUNNER.ENGINE_MCP_L1_CREDENTIAL_REUSE_NEW_PATHS
),
"backend_mode": "verified-derived-retry",
}
output = io.StringIO()
with (
mock.patch.object(RUNNER, "validate_artifact_location"),
mock.patch.object(RUNNER, "ensure_layout"),
mock.patch.object(RUNNER, "TMP_DIR", root),
mock.patch.object(RUNNER, "component_root", return_value=live_root),
mock.patch.object(
RUNNER,
"component_compose_root",
return_value=live_root,
),
mock.patch.object(
RUNNER,
"preflight_engine_mcp_l1_credential_reuse_predecessor",
return_value=preflight,
),
mock.patch.object(
RUNNER,
"preflight_engine_credential_backend_runtime",
return_value={"mode": "verified-derived-retry"},
),
mock.patch.object(RUNNER, "state_has_sha", return_value=False),
mock.patch.object(RUNNER, "state_has_patch_id", return_value=False),
redirect_stdout(output),
):
RUNNER.plan_artifact(artifact)
plan = output.getvalue()
self.assertIn("services=nodedc-backend", plan)
self.assertIn(
"engine_mcp_tool=engine_list_l2_credential_refs",
plan,
)
self.assertIn(
"engine_mcp_apply_operation=assignCredentialRef",
plan,
)
self.assertIn("engine_mcp_credential_scope=same-l1-workflow", plan)
self.assertIn("engine_mcp_cross_l1_sharing=no", plan)
self.assertIn("engine_mcp_managed_grants=target-local", plan)
self.assertIn("engine_mcp_credential_values_included=no", plan)
self.assertIn("l2_graph=untouched", plan)
self.assertIn("state=new", plan)
def test_healthchecks_dispatch_l1_credential_reuse_acceptance(self):
entries = RUNNER.ENGINE_MCP_L1_CREDENTIAL_REUSE_ARTIFACT_ENTRIES
with tempfile.TemporaryDirectory(
prefix="nodedc-engine-mcp-l1-credential-reuse-health-"
) as directory:
root = Path(directory)
all_hashes = {
**RUNNER.ENGINE_MCP_L1_CREDENTIAL_REUSE_TARGET_SHA256,
**RUNNER.ENGINE_MCP_L1_CREDENTIAL_REUSE_FOUNDATION_SHA256,
}
for relative_path in all_hashes:
path = root / relative_path
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text("target\n", encoding="utf-8")
def target_hash(path):
relative_path = Path(path).relative_to(root).as_posix()
return all_hashes[relative_path]
with (
mock.patch.object(RUNNER, "component_root", return_value=root),
mock.patch.object(RUNNER, "sha256_file", side_effect=target_hash),
mock.patch.object(RUNNER, "healthcheck_compose_service"),
mock.patch.object(RUNNER, "component_healthchecks", return_value=()),
mock.patch.object(
RUNNER,
"preflight_engine_credential_backend_runtime",
return_value={"mode": "verified-derived-retry"},
),
mock.patch.object(
RUNNER,
"accept_engine_mcp_l1_credential_reuse_runtime",
return_value={"live": "accepted"},
) as acceptance,
mock.patch.object(RUNNER, "healthcheck_container"),
):
RUNNER.run_healthchecks(
"engine",
entries,
("nodedc-backend",),
)
acceptance.assert_called_once_with()
def test_live_acceptance_probes_only_safe_opaque_same_l1_scope(self):
expected_live = (
"engine-mcp-l1-credential-reuse:"
"0.11.0:same-l1:opaque-ref:v1"
)
with (
mock.patch.object(RUNNER, "component_root", return_value=ENGINE_ROOT),
mock.patch.object(
RUNNER,
"validate_engine_mcp_l1_credential_reuse_slice",
),
mock.patch.object(
RUNNER,
"engine_backend_container_id",
return_value="backend",
),
mock.patch.object(
RUNNER,
"run_engine_backend_probe",
return_value=expected_live,
) as backend_probe,
):
result = RUNNER.accept_engine_mcp_l1_credential_reuse_runtime()
self.assertEqual(result["live"], expected_live)
probe_source = backend_probe.call_args.args[0][-1]
self.assertIn("engineAgentCandidateScope", probe_source)
self.assertIn("engineAgentCredentialMayReuseWithinL1", probe_source)
self.assertIn("untrusted.example", probe_source)
if __name__ == "__main__":
unittest.main(verbosity=2)

View File

@ -0,0 +1,342 @@
import hashlib
import importlib.machinery
import importlib.util
import io
import json
import os
from pathlib import Path
import subprocess
import tarfile
import tempfile
import unittest
from contextlib import redirect_stdout
from unittest import mock
SCRIPT_DIR = Path(__file__).resolve().parent
RUNNER_PATH = SCRIPT_DIR / "nodedc-deploy"
BUILDER_PATH = (
SCRIPT_DIR / "build-engine-mcp-normalized-identity-search-artifact.mjs"
)
ENGINE_ROOT = SCRIPT_DIR.parent.parent.parent / "NODEDC_ENGINE_INFRA"
PATCH_ID = "engine-mcp-normalized-identity-search-20991231-999"
def load_runner():
loader = importlib.machinery.SourceFileLoader(
"nodedc_engine_mcp_normalized_identity_search",
str(RUNNER_PATH),
)
spec = importlib.util.spec_from_loader(loader.name, loader)
module = importlib.util.module_from_spec(spec)
loader.exec_module(module)
return module
RUNNER = load_runner()
class EngineMcpNormalizedIdentitySearchTest(unittest.TestCase):
def require_current_target_source(self):
for relative_path, expected in (
RUNNER.ENGINE_MCP_NORMALIZED_IDENTITY_SEARCH_TARGET_SHA256.items()
):
path = ENGINE_ROOT / relative_path
if (
not path.is_file()
or hashlib.sha256(path.read_bytes()).hexdigest() != expected
):
self.skipTest("normalized identity search source has advanced")
def build(self, artifact_dir, engine_root=ENGINE_ROOT):
environment = os.environ.copy()
environment["NODEDC_ENGINE_SOURCE_ROOT"] = str(engine_root)
environment["NODEDC_DEPLOY_ARTIFACT_DIR"] = str(artifact_dir)
completed = subprocess.run(
["node", str(BUILDER_PATH), PATCH_ID],
check=True,
capture_output=True,
text=True,
env=environment,
)
return json.loads(completed.stdout)
def test_builder_emits_exact_deterministic_backend_only_successor(self):
self.require_current_target_source()
with tempfile.TemporaryDirectory(
prefix="nodedc-engine-mcp-normalized-identity-search-"
) as directory:
root = Path(directory)
first = self.build(root / "first")
second = self.build(root / "second")
first_artifact = Path(first["artifact"])
second_artifact = Path(second["artifact"])
self.assertEqual(first_artifact.read_bytes(), second_artifact.read_bytes())
self.assertEqual(first["services"], ["nodedc-backend"])
self.assertEqual(first["mcpVersion"], "0.11.0")
self.assertEqual(first["tool"], "engine_find_normalized_subjects")
self.assertEqual(first["commandSurface"], "absent")
extract = root / "extract"
with tarfile.open(first_artifact, "r:gz") as archive:
archive.extractall(extract, filter="data")
entries = tuple(
(extract / "files.txt").read_text(encoding="utf-8").splitlines()
)
payload = extract / "payload"
self.assertEqual(
entries,
RUNNER.ENGINE_MCP_NORMALIZED_IDENTITY_SEARCH_ARTIFACT_ENTRIES,
)
self.assertTrue(
RUNNER.is_engine_mcp_normalized_identity_search_slice(
"engine",
entries,
)
)
self.assertEqual(
RUNNER.component_services("engine", entries),
("nodedc-backend",),
)
RUNNER.validate_engine_mcp_normalized_identity_search_slice(
payload,
entries,
)
(root / "load").mkdir()
manifest, loaded_entries, _ = RUNNER.load_artifact(
first_artifact,
root / "load",
)
self.assertEqual(manifest["component"], "engine")
self.assertEqual(tuple(loaded_entries), entries)
def test_builder_rejects_source_drift(self):
self.require_current_target_source()
with tempfile.TemporaryDirectory(
prefix="nodedc-engine-mcp-normalized-identity-drift-"
) as directory:
root = Path(directory)
engine_copy = root / "engine"
for relative_path in (
RUNNER.ENGINE_MCP_NORMALIZED_IDENTITY_SEARCH_ARTIFACT_ENTRIES
):
source = ENGINE_ROOT / relative_path
destination = engine_copy / relative_path
destination.parent.mkdir(parents=True, exist_ok=True)
destination.write_bytes(source.read_bytes())
gateway = engine_copy / RUNNER.ENGINE_NODE_INTELLIGENCE_GATEWAY_REL
gateway.write_text(
gateway.read_text(encoding="utf-8") + "\n// drift\n",
encoding="utf-8",
)
with self.assertRaises(subprocess.CalledProcessError):
self.build(root / "artifact", engine_copy)
def test_descriptor_and_activation_are_canonically_attested(self):
self.require_current_target_source()
descriptor = json.loads(
(
ENGINE_ROOT
/ RUNNER.ENGINE_MCP_NORMALIZED_IDENTITY_SEARCH_DESCRIPTOR_REL
).read_text(encoding="utf-8")
)
activation = RUNNER.read_engine_node_intelligence_descriptor(
ENGINE_ROOT / RUNNER.ENGINE_NODE_INTELLIGENCE_DESCRIPTOR_REL
)
self.assertEqual(descriptor["mcpVersion"], "0.11.0")
self.assertFalse(descriptor["normalizedFactSearch"]["commandSurface"])
self.assertEqual(
activation["source"]["gatewaySha256"],
RUNNER.ENGINE_MCP_NORMALIZED_IDENTITY_SEARCH_TARGET_SHA256[
RUNNER.ENGINE_NODE_INTELLIGENCE_GATEWAY_REL
],
)
self.assertEqual(
activation["predecessor"]["gatewaySha256"],
RUNNER.ENGINE_NODE_INTELLIGENCE_PREDECESSOR_GATEWAY_SHA256,
)
def test_preflight_requires_exact_foundation_and_absent_descriptor(self):
with tempfile.TemporaryDirectory(
prefix="nodedc-engine-mcp-normalized-identity-preflight-"
) as directory:
root = Path(directory)
hashes = {}
for mapping in (
RUNNER.ENGINE_MCP_NORMALIZED_IDENTITY_SEARCH_PREDECESSOR_SHA256,
RUNNER.ENGINE_MCP_NORMALIZED_IDENTITY_SEARCH_FOUNDATION_SHA256,
):
for relative_path, expected in mapping.items():
path = root / relative_path
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text("installed\n", encoding="utf-8")
hashes[path] = expected
with (
mock.patch.object(RUNNER, "component_root", return_value=root),
mock.patch.object(
RUNNER,
"sha256_file",
side_effect=lambda path: hashes[Path(path)],
),
mock.patch.object(
RUNNER,
"preflight_engine_credential_backend_runtime",
return_value={"mode": "verified-derived-retry"},
),
):
result = (
RUNNER
.preflight_engine_mcp_normalized_identity_search_predecessor()
)
self.assertEqual(
result["mode"],
"classified-aspects-v1-to-normalized-identity-search-v1",
)
self.assertEqual(
result["foundation_sha256"],
RUNNER.ENGINE_MCP_NORMALIZED_IDENTITY_SEARCH_FOUNDATION_SHA256,
)
def test_plan_renders_closed_read_only_identity_boundary(self):
self.require_current_target_source()
with tempfile.TemporaryDirectory(
prefix="nodedc-engine-mcp-normalized-identity-plan-"
) as directory:
root = Path(directory)
artifact = Path(self.build(root / "artifacts")["artifact"])
live_root = root / "live"
live_root.mkdir()
preflight = {
"mode": "classified-aspects-v1-to-normalized-identity-search-v1",
"predecessor_sha256": dict(
RUNNER
.ENGINE_MCP_NORMALIZED_IDENTITY_SEARCH_PREDECESSOR_SHA256
),
"foundation_sha256": dict(
RUNNER
.ENGINE_MCP_NORMALIZED_IDENTITY_SEARCH_FOUNDATION_SHA256
),
"target_sha256": dict(
RUNNER.ENGINE_MCP_NORMALIZED_IDENTITY_SEARCH_TARGET_SHA256
),
"new_paths": tuple(
RUNNER.ENGINE_MCP_NORMALIZED_IDENTITY_SEARCH_NEW_PATHS
),
"backend_mode": "verified-derived-retry",
}
output = io.StringIO()
with (
mock.patch.object(RUNNER, "validate_artifact_location"),
mock.patch.object(RUNNER, "ensure_layout"),
mock.patch.object(RUNNER, "TMP_DIR", root),
mock.patch.object(RUNNER, "component_root", return_value=live_root),
mock.patch.object(
RUNNER,
"component_compose_root",
return_value=live_root,
),
mock.patch.object(
RUNNER,
"preflight_engine_mcp_normalized_identity_search_predecessor",
return_value=preflight,
),
mock.patch.object(
RUNNER,
"preflight_engine_credential_backend_runtime",
return_value={"mode": "verified-derived-retry"},
),
mock.patch.object(RUNNER, "state_has_sha", return_value=False),
mock.patch.object(RUNNER, "state_has_patch_id", return_value=False),
redirect_stdout(output),
):
RUNNER.plan_artifact(artifact)
plan = output.getvalue()
self.assertIn("services=nodedc-backend", plan)
self.assertIn("engine_mcp_version=0.11.0", plan)
self.assertIn(
"engine_mcp_tool=engine_find_normalized_subjects",
plan,
)
self.assertIn("engine_mcp_command_surface_included=no", plan)
self.assertIn(
"engine_data_product_added=fleet.units.identity.current.v1",
plan,
)
self.assertIn("l2_graph=untouched", plan)
self.assertIn("state=new", plan)
def test_healthchecks_dispatch_identity_search_acceptance(self):
entries = RUNNER.ENGINE_MCP_NORMALIZED_IDENTITY_SEARCH_ARTIFACT_ENTRIES
with tempfile.TemporaryDirectory(
prefix="nodedc-engine-mcp-normalized-identity-health-"
) as directory:
root = Path(directory)
all_hashes = {
**RUNNER.ENGINE_MCP_NORMALIZED_IDENTITY_SEARCH_TARGET_SHA256,
**RUNNER.ENGINE_MCP_NORMALIZED_IDENTITY_SEARCH_FOUNDATION_SHA256,
}
for relative_path in all_hashes:
path = root / relative_path
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text("target\n", encoding="utf-8")
def target_hash(path):
relative_path = Path(path).relative_to(root).as_posix()
return all_hashes[relative_path]
with (
mock.patch.object(RUNNER, "component_root", return_value=root),
mock.patch.object(RUNNER, "sha256_file", side_effect=target_hash),
mock.patch.object(RUNNER, "healthcheck_compose_service"),
mock.patch.object(RUNNER, "component_healthchecks", return_value=()),
mock.patch.object(
RUNNER,
"preflight_engine_credential_backend_runtime",
return_value={"mode": "verified-derived-retry"},
),
mock.patch.object(
RUNNER,
"accept_engine_mcp_normalized_identity_search_runtime",
return_value={"live": "accepted"},
) as acceptance,
mock.patch.object(RUNNER, "healthcheck_container"),
):
RUNNER.run_healthchecks(
"engine",
entries,
("nodedc-backend",),
)
acceptance.assert_called_once_with()
def test_live_acceptance_probes_read_only_normalized_boundary(self):
expected_live = (
"engine-mcp-normalized-identity-search:"
"0.11.0:canonical-facts:full-admin-identifiers:read-only:v1"
)
with (
mock.patch.object(RUNNER, "component_root", return_value=ENGINE_ROOT),
mock.patch.object(
RUNNER,
"validate_engine_mcp_normalized_identity_search_slice",
),
mock.patch.object(
RUNNER,
"engine_backend_container_id",
return_value="backend",
),
mock.patch.object(
RUNNER,
"run_engine_backend_probe",
return_value=expected_live,
) as backend_probe,
):
result = RUNNER.accept_engine_mcp_normalized_identity_search_runtime()
self.assertEqual(result["live"], expected_live)
probe_source = backend_probe.call_args.args[0][-1]
self.assertIn("engine_find_normalized_subjects", probe_source)
self.assertIn("commandSurfaceIncluded: false", probe_source)
self.assertIn("classification!=='read'", probe_source)
if __name__ == "__main__":
unittest.main(verbosity=2)

Some files were not shown because too many files have changed in this diff Show More