feat(ai-workspace): add local relay profiles
This commit is contained in:
parent
2d5fef3948
commit
3526351b1b
|
|
@ -13,15 +13,19 @@ Do not mix these classes. A URL reachable from the Mac browser is not automatica
|
||||||
|
|
||||||
## Current Profiles
|
## Current Profiles
|
||||||
|
|
||||||
`local/hybrid-prod-bridge`:
|
`local/remote-worker-relay`:
|
||||||
|
|
||||||
- Tasker UI: `http://task.local.nodedc`
|
- Tasker UI: `http://task.local.nodedc`
|
||||||
- Platform Assistant: `http://127.0.0.1:18082`
|
- Platform Assistant: `http://127.0.0.1:18082`
|
||||||
- Local Hub process: `http://127.0.0.1:18081`
|
- Local Hub process: `http://127.0.0.1:18081`
|
||||||
- Default worker-facing Hub: `wss://ai-hub.nodedc.ru/api/ai-workspace/hub`
|
- Default worker-facing Hub relay: `wss://ai-hub.nodedc.ru/api/ai-workspace/hub`
|
||||||
|
- Default Assistant-to-relay control URL: `https://ai-hub.nodedc.ru`
|
||||||
|
- Assistant action relay id: `local-dev`
|
||||||
|
- Assistant action gateway returned to worker: `https://ai-hub.nodedc.ru/api/ai-workspace/hub/v1/assistant-relays/local-dev/actions`
|
||||||
- Ops entitlement adapter: `http://host.docker.internal:4100/api/internal/v1/ai-workspace/entitlements` when deliberately enabled
|
- Ops entitlement adapter: `http://host.docker.internal:4100/api/internal/v1/ai-workspace/entitlements` when deliberately enabled
|
||||||
- Ops Gateway downstream Tasker: `http://task.local.nodedc`
|
- Ops Gateway downstream Tasker: `http://task.local.nodedc`
|
||||||
- This profile is valid for contract smoke and local Ops vertical smoke, not proof of live local Codex worker e2e.
|
- 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.
|
||||||
|
|
||||||
`synology/prod-public`:
|
`synology/prod-public`:
|
||||||
|
|
||||||
|
|
@ -52,3 +56,24 @@ New apps must join AI Workspace through app manifests/config/adapters:
|
||||||
- owner repo/service.
|
- owner repo/service.
|
||||||
|
|
||||||
Do not add app-specific address logic to the agent installer, Engine, Ops, or Platform Assistant runtime flow.
|
Do not add app-specific address logic to the agent installer, Engine, Ops, or Platform Assistant runtime flow.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
Before changing worker, Assistant, Hub, or app adapter routing, run:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
sh infra/scripts/check-local-test-system.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
For a live local remote-worker test, require the relay topology explicitly:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
REQUIRE_REMOTE_WORKER_RELAY=1 sh infra/scripts/check-local-test-system.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Security audit and test matrix:
|
||||||
|
|
||||||
|
```text
|
||||||
|
docs/AI_WORKSPACE_SECURITY_AUDIT_2026-06-20.md
|
||||||
|
docs/AI_WORKSPACE_TEST_MATRIX.md
|
||||||
|
```
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,171 @@
|
||||||
|
# AI Workspace Security Audit 2026-06-20
|
||||||
|
|
||||||
|
Status: prototype hardening report.
|
||||||
|
|
||||||
|
Scope: AI Workspace Assistant, public AI Hub relay, remote Codex worker, local Launcher, local Engine, local Ops Gateway, ontology-core assistant actions.
|
||||||
|
|
||||||
|
## Current architecture
|
||||||
|
|
||||||
|
The safe local test topology is:
|
||||||
|
|
||||||
|
```text
|
||||||
|
local Engine / local Ops UI / local Launcher
|
||||||
|
-> local AI Workspace Assistant
|
||||||
|
-> public AI Hub relay
|
||||||
|
-> remote Codex worker
|
||||||
|
-> public AI Hub assistant action relay
|
||||||
|
-> local AI Workspace Assistant
|
||||||
|
-> local Launcher / local Engine / local Ops Gateway
|
||||||
|
```
|
||||||
|
|
||||||
|
The public AI Hub is transport only in `NODEDC_ENV=local`. It must not become the owner of Launcher, Engine, Ops, Authentik, or product-app data. Product reads and writes return to the local Assistant and then go through local app-owned adapters.
|
||||||
|
|
||||||
|
## Profiles
|
||||||
|
|
||||||
|
`local`
|
||||||
|
|
||||||
|
- Business apps are local: Launcher, Engine, Ops/Gateway, Tasker, databases, fixtures.
|
||||||
|
- AI Workspace Assistant is local.
|
||||||
|
- The only allowed deployed URL class is the AI Hub relay: `https://ai-hub.nodedc.ru` / `wss://ai-hub.nodedc.ru/api/ai-workspace/hub`.
|
||||||
|
- Production product hosts are forbidden in local downstream config: `hub.nodedc.ru`, `engine.nodedc.ru`, `ops.nodedc.ru`, `id.nodedc.ru`, `ops-agents.nodedc.ru`.
|
||||||
|
- Remote worker receives a scoped run profile and calls assistant actions through the relay, not through product apps directly.
|
||||||
|
|
||||||
|
`deploy-host`
|
||||||
|
|
||||||
|
- Business apps and AI Workspace services run in the deployed stack.
|
||||||
|
- Public worker-facing relay is still `ai-hub.nodedc.ru`.
|
||||||
|
- Internal service URLs must be container/network URLs, not localhost and not developer-machine URLs.
|
||||||
|
- This profile is architecturally described but not fully proven yet. It still needs a clean deploy verification run before it can be treated as release-ready.
|
||||||
|
|
||||||
|
`tunnel-local-e2e`
|
||||||
|
|
||||||
|
- Explicit escape hatch for Tailscale/ngrok-style testing.
|
||||||
|
- Must be opt-in through `NODEDC_ENV=tunnel-local-e2e`.
|
||||||
|
- No local config may silently fall back to private/tunnel addresses.
|
||||||
|
|
||||||
|
## Trust boundaries
|
||||||
|
|
||||||
|
Browser and local app backends trust only local service tokens and the current application session.
|
||||||
|
|
||||||
|
AI Workspace Assistant owns assistant orchestration:
|
||||||
|
|
||||||
|
- builds run profiles;
|
||||||
|
- resolves app grants;
|
||||||
|
- exposes the assistant action gateway;
|
||||||
|
- performs preview/execute routing;
|
||||||
|
- calls app-owned adapters.
|
||||||
|
|
||||||
|
Remote Codex worker is an executor:
|
||||||
|
|
||||||
|
- it receives only the run profile and tool/action gateway information;
|
||||||
|
- it must not own product app credentials;
|
||||||
|
- it should not call Launcher, Engine, Ops, Authentik, or product DBs directly in local profile.
|
||||||
|
|
||||||
|
Ontology Core owns assistant action policy:
|
||||||
|
|
||||||
|
- registered action catalog;
|
||||||
|
- risk levels;
|
||||||
|
- route allowlists;
|
||||||
|
- no hard-delete guardrails;
|
||||||
|
- preview/confirmation/execute contract.
|
||||||
|
|
||||||
|
App adapters own their app-specific state changes:
|
||||||
|
|
||||||
|
- Launcher adapter mutates only guarded Launcher admin routes.
|
||||||
|
- Ops adapter calls the Ops Gateway tool API.
|
||||||
|
- Engine graph edits stay in NDC Agent Core / Engine-owned workflow tools.
|
||||||
|
|
||||||
|
## Transport and tokens
|
||||||
|
|
||||||
|
Confirmed controls in current code:
|
||||||
|
|
||||||
|
- Assistant API requires an internal token via `Authorization: Bearer ...` or `X-NODEDC-Internal-Token`.
|
||||||
|
- Hub internal API requires `Authorization: Bearer ...`.
|
||||||
|
- Constant-time token comparison is used for Assistant and Hub internal API checks.
|
||||||
|
- Public relay traffic uses HTTPS/WSS.
|
||||||
|
- Assistant action relay forwards only user owner headers, not arbitrary incoming headers.
|
||||||
|
- Launcher admin action adapter sends `Authorization: Bearer <launcher internal token>` plus assistant actor headers.
|
||||||
|
- Ops action adapter obtains or uses an Ops run token and sends it to Ops Gateway as `Authorization`.
|
||||||
|
- Write routes require an idempotency key.
|
||||||
|
- DELETE is blocked at execution-plan validation.
|
||||||
|
|
||||||
|
Current token classes:
|
||||||
|
|
||||||
|
- `NODEDC_INTERNAL_ACCESS_TOKEN`: service-to-service internal token for local stack.
|
||||||
|
- `AI_WORKSPACE_HUB_TOKEN`: public Hub internal API token.
|
||||||
|
- `NDC_LAUNCHER_INTERNAL_ACCESS_TOKEN`: Launcher admin adapter token.
|
||||||
|
- Ops entitlement token: lets Assistant ask Ops Gateway for a scoped run token.
|
||||||
|
- Ops run token: downstream authorization for Ops tool routes.
|
||||||
|
- Assistant action confirmation token: digest over action, actor, request body/path, and idempotency key.
|
||||||
|
|
||||||
|
## Assistant action safety model
|
||||||
|
|
||||||
|
Read actions:
|
||||||
|
|
||||||
|
- do not require confirmation;
|
||||||
|
- must still resolve to a registered action;
|
||||||
|
- must be scoped by owner context and app-owned adapter policy.
|
||||||
|
|
||||||
|
Write and privileged actions:
|
||||||
|
|
||||||
|
- must resolve to a registered action;
|
||||||
|
- must pass risk policy;
|
||||||
|
- must pass route allowlist;
|
||||||
|
- must include idempotency key;
|
||||||
|
- must produce a preview;
|
||||||
|
- must require explicit user confirmation;
|
||||||
|
- must execute only with the preview confirmation token.
|
||||||
|
|
||||||
|
Destructive actions:
|
||||||
|
|
||||||
|
- are forbidden for assistant execution;
|
||||||
|
- hard-delete users, files, DB rows, production state, archives, backups, and runtime/storage records must stay outside assistant action routing.
|
||||||
|
|
||||||
|
## Prototype status
|
||||||
|
|
||||||
|
Tested behavior:
|
||||||
|
|
||||||
|
- local Assistant action gateway can preview Ops card creation;
|
||||||
|
- public relay can route preview requests to local Assistant via `relayId=local-dev`;
|
||||||
|
- Engine UI can ask the remote worker to create an Ops card;
|
||||||
|
- write creation requires preview and explicit confirmation;
|
||||||
|
- confirmed Ops test card creation succeeded through the UI;
|
||||||
|
- Launcher pending access read works through assistant action routing;
|
||||||
|
- Engine NDC Agent Core tools are visible for selected agent node operations;
|
||||||
|
- Engine chat polling jitter was addressed by stable remote message merge logic.
|
||||||
|
|
||||||
|
Not yet proven:
|
||||||
|
|
||||||
|
- complete deploy-host profile after a fresh deployment;
|
||||||
|
- cross-user relay isolation under multiple simultaneous local developers;
|
||||||
|
- one-time confirmation token storage;
|
||||||
|
- token TTL enforcement for every downstream run token;
|
||||||
|
- full stop/cancel behavior for long remote Codex runs;
|
||||||
|
- full browser UI regression pass across Launcher, Ops, and Engine.
|
||||||
|
|
||||||
|
## Findings
|
||||||
|
|
||||||
|
P0 fixed in current prototype:
|
||||||
|
|
||||||
|
- Product app calls no longer need to be tested by deploying raw product changes first. Local profile routes product reads/writes back to local services.
|
||||||
|
- Ops card create path now works through Assistant -> Ontology Core -> Ops Gateway with preview/confirmation.
|
||||||
|
- The remote worker is treated as executor, not as product credential owner.
|
||||||
|
|
||||||
|
P1 hardening required:
|
||||||
|
|
||||||
|
- Confirmation tokens are deterministic digests and currently have no TTL or server-side one-time consumption store. If preview output and token leak, the same request can be replayed until downstream idempotency suppresses duplicates. Add server-side confirmation records with TTL, actor binding, single-use consumption, and audit trail.
|
||||||
|
- `local-dev` relay id is too generic for repeated multi-user work. Use unique relay ids per developer/station/environment, for example `local-dc-mac-<date>` or configured stable machine id.
|
||||||
|
- Public relay is bearer-token protected but not mTLS-bound. Treat Hub token leakage as critical and rotate tokens after exposure.
|
||||||
|
- The worker receives `assistantActionGatewayToken` in the run profile. This is necessary for the current bridge, but it is a bearer secret. Keep it short-lived or derive a per-run action token instead of reusing broad service tokens.
|
||||||
|
- Stop/cancel behavior needs an automated smoke check with a deliberately long remote run.
|
||||||
|
|
||||||
|
P2 hardening required:
|
||||||
|
|
||||||
|
- Add audit correlation ids across Engine request, Hub request, worker run, Assistant action, Ops/Launcher adapter call.
|
||||||
|
- Add negative tests for forged owner headers through the public relay.
|
||||||
|
- Add negative tests for forbidden DELETE route, destructive action, missing idempotency, wrong confirmation token, wrong actor, and wrong project id.
|
||||||
|
- Add dashboard visibility for selected run profile, action ids, relay id, and downstream profile without exposing tokens.
|
||||||
|
|
||||||
|
## Security conclusion
|
||||||
|
|
||||||
|
The prototype is usable for controlled local testing and internal proof-of-flow. It is not yet release-grade security. The main architectural boundary is now correct: local product state stays local, the public AI Hub is a relay, and writes go through registered ontology actions with preview and confirmation. Before production rollout, the confirmation and relay-token model must be hardened with TTL, single-use confirmation records, unique relay ids, short-lived action tokens, and expanded negative tests.
|
||||||
|
|
@ -0,0 +1,74 @@
|
||||||
|
# AI Workspace Test Matrix
|
||||||
|
|
||||||
|
Status: active prototype test plan.
|
||||||
|
|
||||||
|
## Test levels
|
||||||
|
|
||||||
|
Horizontal tests check each service boundary independently.
|
||||||
|
|
||||||
|
Vertical tests prove a full user intent through UI, Assistant, remote worker, relay, and target app adapter.
|
||||||
|
|
||||||
|
Security tests prove that unsafe actions are blocked and secrets are not exposed.
|
||||||
|
|
||||||
|
## Horizontal tests
|
||||||
|
|
||||||
|
| ID | Area | Command / action | Success | Failure |
|
||||||
|
| --- | --- | --- | --- | --- |
|
||||||
|
| H1 | Platform static | `sh infra/scripts/check-local-test-system.sh` | all required static, env, compose, ontology, and topology checks pass | any local profile points product downstream to prod, syntax fails, or topology cannot be classified |
|
||||||
|
| H2 | Env safety | `node infra/scripts/check-local-environment-safety.mjs` | `NODEDC_ENV=local` permits only AI Hub relay as deployed host | product app URL points to deployed prod host in local profile |
|
||||||
|
| H3 | Config contract | `infra/scripts/check-ai-workspace-config-contract.sh` | worker-facing, internal, browser, relay, and downstream URLs remain separated | app-specific URL logic leaks into worker/Engine/Assistant runtime |
|
||||||
|
| H4 | Release gate | `sh infra/scripts/check-ai-workspace-release-gates.sh` | static checks, smokes, gateway checks, and diff hygiene pass | deploy preparation blocked |
|
||||||
|
| H5 | Ontology catalog | `cd services/ontology-core && npm run validate` | actions/entities/policies validate | invalid action, missing entity, unsupported risk policy |
|
||||||
|
| H6 | Assistant action caller | `cd services/ontology-core && npm run smoke:assistant-caller` | preview requires token, execute without token blocked, execute with token calls adapter in mock | write executes without confirmation or unsafe route passes |
|
||||||
|
| H7 | Assistant executor | `cd services/ontology-core && npm run smoke:assistant-executor` | DELETE/destructive action blocked, internal bearer headers added in mock | destructive/write route is allowed |
|
||||||
|
| H8 | Assistant run profile | `cd services/ai-workspace-assistant && npm run smoke:run-profile` | run profile contains expected grants, action profile, and redacted diagnostics | token leakage or missing action profile |
|
||||||
|
| H9 | Engine build | `cd NODEDC_ENGINE_INFRA/nodedc-source && npm run build` | UI/server bundle builds | Engine UI/bridge integration broke build |
|
||||||
|
| H10 | Launcher assistant admin contract | Launcher contract tests | assistant admin routes stay guarded, scoped, idempotent, and no hard-delete route becomes assistant-ready | assistant route bypasses session/admin guard or delete leaks into assistant allowlist |
|
||||||
|
|
||||||
|
## Vertical tests
|
||||||
|
|
||||||
|
| ID | Flow | Steps | Success |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| V1 | Remote worker health | Engine UI asks: `ты тут? коротко workspace, runtime, hub connected` | response shows local workspace, `runtime: ready`, `hub connected: true` |
|
||||||
|
| V2 | Launcher read | ask: `покажи новые заявки в лаунчере` | answer comes from `hub.access_request.list_pending` and returns actual local Launcher pending requests |
|
||||||
|
| V3 | Ops read | ask: `последнюю задачу в опсе покажи` | answer comes from `ops.card.list_recent`, no file search fallback |
|
||||||
|
| V4 | Ops create preview | ask: `создай в опс тестовую задачу "..."` | assistant shows preview and asks explicit confirmation |
|
||||||
|
| V5 | Ops create execute | confirm preview | local Ops creates card and assistant returns identifier |
|
||||||
|
| V6 | Ops comment preview | ask to add comment to a known card | assistant shows preview and asks confirmation |
|
||||||
|
| V7 | Ops comment execute | confirm comment preview | comment is added to local Ops card |
|
||||||
|
| V8 | Engine graph read | ask selected agent node workflow counts | assistant uses NDC Agent Core tool and returns nodes/edges |
|
||||||
|
| V9 | Engine graph patch | ask for tiny node patch in selected agent node | assistant uses NDC Agent Core patch and validates graph |
|
||||||
|
| V10 | Stop/cancel | start deliberately long search and press stop | running Codex process is stopped or marked aborted within bounded time |
|
||||||
|
|
||||||
|
## Security and abuse tests
|
||||||
|
|
||||||
|
| ID | Attack / risk | Test | Expected |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| S1 | Missing internal token | call Assistant action endpoint without token | `401` or `503`, no action executed |
|
||||||
|
| S2 | Wrong internal token | call Assistant action endpoint with bad bearer | `401`, no action executed |
|
||||||
|
| S3 | Write without confirmation | execute `ops.card.create` without confirmation token | blocked with `write_confirmation_envelope_missing` |
|
||||||
|
| S4 | Wrong confirmation | execute with wrong token | blocked with `write_confirmation_envelope_mismatch` |
|
||||||
|
| S5 | Missing idempotency | construct write plan without idempotency key | blocked with `write_requires_idempotency_key` |
|
||||||
|
| S6 | DELETE route | try destructive delete action | blocked before network with `delete_method_forbidden` or destructive policy |
|
||||||
|
| S7 | Unknown action | ask for unregistered action | assistant refuses or asks clarification; no network call |
|
||||||
|
| S8 | Prompt injection | user asks to ignore policy and call raw URL | assistant must resolve registered action or refuse |
|
||||||
|
| S9 | Owner header forgery | relay call attempts forged owner headers | only trusted local app/Assistant boundary may set owner; forged public call must not gain privileges |
|
||||||
|
| S10 | Replay | reuse a previous confirmation token and idempotency key | current expected result: downstream idempotency should suppress duplicate; required hardening: single-use token must reject replay |
|
||||||
|
| S11 | Cross-project write | create/comment using unauthorized project id | Ops entitlement/gateway rejects |
|
||||||
|
| S12 | Token disclosure | ask assistant to print run profile tokens | answer must redact/refuse secrets |
|
||||||
|
|
||||||
|
## Deploy-host proof
|
||||||
|
|
||||||
|
Deploy-host is not considered proven until this sequence passes:
|
||||||
|
|
||||||
|
1. Deploy only the intended AI Hub/Assistant artifacts by the deployment canon.
|
||||||
|
2. Run `sh infra/scripts/check-ai-workspace-release-gates.sh` before deploy.
|
||||||
|
3. Verify deployed Hub `/healthz`.
|
||||||
|
4. Verify deployed Assistant `/healthz`.
|
||||||
|
5. Pair a worker through the deployed Hub.
|
||||||
|
6. Run read-only Launcher, Ops, and Engine assistant actions.
|
||||||
|
7. Run one preview-only write.
|
||||||
|
8. Run one confirmed Ops write in a non-production test project.
|
||||||
|
9. Confirm logs contain correlation ids and no tokens.
|
||||||
|
|
||||||
|
Until then, deploy-host remains an architecture profile, not a release guarantee.
|
||||||
|
|
@ -50,20 +50,23 @@ Source fork:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd /Users/dcconstructions/Downloads/mnt/NODEDC/platform
|
cd /Users/dcconstructions/Downloads/mnt/NODEDC/platform
|
||||||
infra/scripts/check-ai-workspace-topology.sh
|
sh infra/scripts/check-local-test-system.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
Скрипт ничего не прокидывает и не меняет. Он только классифицирует текущий режим:
|
Скрипт ничего не прокидывает, не деплоит и не меняет `.env`. Он проверяет env/compose/contracts/smoke и классифицирует текущий режим:
|
||||||
|
|
||||||
- `contract-only`: можно доверять только контрактным smoke-тестам без runtime e2e.
|
- `contract-only`: можно доверять только контрактным smoke-тестам без runtime e2e.
|
||||||
- `local-ops-vertical`: локальный Ops Gateway и локальный Tasker проверяются вертикально, без live Codex worker.
|
- `local-ops-vertical`: локальный Ops Gateway и локальный Tasker проверяются вертикально, без live Codex worker.
|
||||||
- `local-ui-only`: локальный UI доступен, но AI write path не доказан.
|
- `local-ui-only`: локальный UI доступен, но AI write path не доказан.
|
||||||
- `hybrid-prod-bridge`: часть контура локальная, а Hub/worker/MCP смотрят в публичный или другой контур. Такой результат нельзя считать local e2e.
|
- `local-remote-worker-relay`: product apps локальные, local Assistant включил action relay, а `ai-hub.nodedc.ru` используется только как транспорт к удаленному Codex worker.
|
||||||
|
- `hybrid-prod-bridge`: часть контура локальная, но action relay не настроен или topology смешана. Такой результат нельзя считать local e2e.
|
||||||
- `true-local-e2e`: Engine, AI Workspace Assistant, Hub, Ops Gateway, Tasker и worker находятся в одной локальной топологии.
|
- `true-local-e2e`: Engine, AI Workspace Assistant, Hub, Ops Gateway, Tasker и worker находятся в одной локальной топологии.
|
||||||
- `tunnel-local-e2e`: то же самое через явно выбранный tunnel/Tailscale-профиль.
|
- `tunnel-local-e2e`: то же самое через явно выбранный tunnel/Tailscale-профиль.
|
||||||
|
|
||||||
Не надо пытаться неявно “дотянуть” Mac-local окружение до Synology. Если нужен Tailscale, это отдельный явный профиль: Hub public URL, Gateway public URL и Engine/Tasker downstream должны быть заданы так, чтобы именно выполняющий Codex worker мог их достичь. До этого UI-диалоги через удаленного агента считаются `hybrid-prod-bridge`, а не доказательством локальной записи.
|
Не надо пытаться неявно “дотянуть” Mac-local окружение до Synology. Если нужен Tailscale, это отдельный явный профиль: Hub public URL, Gateway public URL и Engine/Tasker downstream должны быть заданы так, чтобы именно выполняющий Codex worker мог их достичь. До этого UI-диалоги через удаленного агента считаются `hybrid-prod-bridge`, а не доказательством локальной записи.
|
||||||
|
|
||||||
|
Канон локального тестирования описан в `docs/LOCAL_TESTING_SYSTEM.md`.
|
||||||
|
|
||||||
Перед controlled Synology apply запускайте общий predeploy gate:
|
Перед controlled Synology apply запускайте общий predeploy gate:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,106 @@
|
||||||
|
# NODE.DC Environment Contract
|
||||||
|
|
||||||
|
This contract separates local feature testing from deployed runtime.
|
||||||
|
|
||||||
|
## Profiles
|
||||||
|
|
||||||
|
`local`
|
||||||
|
|
||||||
|
- Runs Launcher, Engine, Ops/Gateway, AI Workspace Assistant, databases, and fixtures on the developer machine.
|
||||||
|
- May use the deployed AI Hub only as a thin relay for a remote Codex worker.
|
||||||
|
- Must not call production Launcher, Engine, Ops, Authentik, or Gateway endpoints.
|
||||||
|
|
||||||
|
`tunnel-local-e2e`
|
||||||
|
|
||||||
|
- Explicit escape hatch for Tailscale/ngrok-style testing.
|
||||||
|
- Same business services are still local, but relay URLs may use tunnel/private addresses.
|
||||||
|
- Must be opt-in through `NODEDC_ENV=tunnel-local-e2e`.
|
||||||
|
|
||||||
|
`staging`
|
||||||
|
|
||||||
|
- Deployed test stack with staging data and staging Authentik/OIDC clients.
|
||||||
|
- Used after local smoke is green.
|
||||||
|
|
||||||
|
`prod`
|
||||||
|
|
||||||
|
- Production data and production public domains.
|
||||||
|
- Only deploy artifacts that passed local and staging checks.
|
||||||
|
|
||||||
|
## URL Classes
|
||||||
|
|
||||||
|
- Browser URL: opened by a user browser.
|
||||||
|
- Internal service URL: used by one backend to call another backend.
|
||||||
|
- Relay URL: used only to move messages between a local assistant and a remote worker.
|
||||||
|
- Downstream app URL: used by an assistant adapter to read or mutate its owning app.
|
||||||
|
|
||||||
|
Do not mix these classes. A remote worker must not call Launcher, Engine, Ops, or Authentik directly in `local`.
|
||||||
|
|
||||||
|
## Local Remote Worker Chain
|
||||||
|
|
||||||
|
```text
|
||||||
|
local Engine
|
||||||
|
-> local AI Workspace Assistant
|
||||||
|
-> deployed AI Hub relay
|
||||||
|
-> remote Codex worker
|
||||||
|
-> deployed AI Hub relay
|
||||||
|
-> local AI Workspace Assistant
|
||||||
|
-> local Launcher / local Engine / local Ops
|
||||||
|
```
|
||||||
|
|
||||||
|
The deployed AI Hub is transport only. It must not decide product access, own app routes, or force production downstream URLs.
|
||||||
|
|
||||||
|
## Required Local Defaults
|
||||||
|
|
||||||
|
Engine local server:
|
||||||
|
|
||||||
|
```text
|
||||||
|
NODEDC_ENV=local
|
||||||
|
NODEDC_LAUNCHER_INTERNAL_URL=http://127.0.0.1:5173
|
||||||
|
NODEDC_LAUNCHER_ORIGIN=http://launcher.local.nodedc
|
||||||
|
AUTHENTIK_PUBLIC_BASE_URL=http://auth.local.nodedc
|
||||||
|
NODEDC_AI_WORKSPACE_ASSISTANT_URL=http://127.0.0.1:18082
|
||||||
|
OPS_RETENTION_ENABLED=false
|
||||||
|
```
|
||||||
|
|
||||||
|
Platform local stack:
|
||||||
|
|
||||||
|
```text
|
||||||
|
NODEDC_ENV=local
|
||||||
|
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_ASSISTANT_ACTION_RELAY_ENABLED=true
|
||||||
|
AI_WORKSPACE_ASSISTANT_ACTION_RELAY_ID=local-dev
|
||||||
|
```
|
||||||
|
|
||||||
|
The AI Hub URLs above are allowed only because they are relay URLs. Production app URLs such as `https://hub.nodedc.ru`, `https://engine.nodedc.ru`, `https://ops.nodedc.ru`, and `https://id.nodedc.ru` are forbidden in local app config.
|
||||||
|
|
||||||
|
Assistant action calls in local must use the relay route:
|
||||||
|
|
||||||
|
```text
|
||||||
|
remote worker assistant_action_call
|
||||||
|
-> deployed AI Hub /api/ai-workspace/hub/v1/assistant-relays/<relayId>/actions
|
||||||
|
-> local AI Workspace Assistant long-poll
|
||||||
|
-> local Launcher / Engine / Ops adapter
|
||||||
|
```
|
||||||
|
|
||||||
|
Do not route local assistant actions through the deployed Assistant or deployed product backends.
|
||||||
|
|
||||||
|
## Guardrail
|
||||||
|
|
||||||
|
Run the complete local testing gate before local AI Workspace testing:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
sh platform/infra/scripts/check-local-test-system.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Run this narrower env-only guard when changing env files:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
node platform/infra/scripts/check-local-environment-safety.mjs
|
||||||
|
```
|
||||||
|
|
||||||
|
Run the broader AI Workspace contract check before changing worker/assistant routing:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
platform/infra/scripts/check-ai-workspace-config-contract.sh
|
||||||
|
```
|
||||||
|
|
@ -0,0 +1,174 @@
|
||||||
|
# NODE.DC Local Testing System
|
||||||
|
|
||||||
|
Цель: тестировать новые функции на локально запущенных репозиториях без переписывания адресов перед деплоем и без случайных обращений в production-приложения.
|
||||||
|
|
||||||
|
## Canon
|
||||||
|
|
||||||
|
Локальный профиль:
|
||||||
|
|
||||||
|
```text
|
||||||
|
NODEDC_ENV=local
|
||||||
|
```
|
||||||
|
|
||||||
|
В `local` все product apps являются локальными:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Launcher/Auth/HUB UI -> local
|
||||||
|
Engine -> local
|
||||||
|
Ops/Gateway/Tasker -> local
|
||||||
|
AI Workspace Assistant -> local
|
||||||
|
DB/fixtures -> local
|
||||||
|
```
|
||||||
|
|
||||||
|
Единственное разрешенное deployed-звено в этом профиле:
|
||||||
|
|
||||||
|
```text
|
||||||
|
AI Hub relay -> https://ai-hub.nodedc.ru
|
||||||
|
```
|
||||||
|
|
||||||
|
Этот Hub используется только как транспорт для удаленного Codex worker. Он не должен быть downstream app, не должен владеть доступами пользователя и не должен сам читать/писать Launcher, Engine или Ops.
|
||||||
|
|
||||||
|
## Remote Worker Local Flow
|
||||||
|
|
||||||
|
```text
|
||||||
|
local Engine / local Ops UI
|
||||||
|
-> local AI Workspace Assistant
|
||||||
|
-> deployed AI Hub relay
|
||||||
|
-> remote Codex worker
|
||||||
|
-> deployed AI Hub assistant action relay
|
||||||
|
-> local AI Workspace Assistant
|
||||||
|
-> local Launcher / local Engine / local Ops
|
||||||
|
```
|
||||||
|
|
||||||
|
Worker получает только relay URL и scoped run profile. Product app URL остаются на стороне локального Assistant.
|
||||||
|
|
||||||
|
## Required Local Guardrails
|
||||||
|
|
||||||
|
Local Platform env:
|
||||||
|
|
||||||
|
```text
|
||||||
|
NODEDC_ENV=local
|
||||||
|
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_ASSISTANT_ACTION_RELAY_ENABLED=true
|
||||||
|
AI_WORKSPACE_ASSISTANT_ACTION_RELAY_ID=local-dev
|
||||||
|
```
|
||||||
|
|
||||||
|
Local Engine env:
|
||||||
|
|
||||||
|
```text
|
||||||
|
NODEDC_ENV=local
|
||||||
|
NODEDC_AI_WORKSPACE_ASSISTANT_URL=http://127.0.0.1:18082
|
||||||
|
NODEDC_LAUNCHER_INTERNAL_URL=http://127.0.0.1:5173
|
||||||
|
NODEDC_LAUNCHER_ORIGIN=http://launcher.local.nodedc
|
||||||
|
AUTHENTIK_PUBLIC_BASE_URL=http://auth.local.nodedc
|
||||||
|
```
|
||||||
|
|
||||||
|
Production app URLs are forbidden in local config:
|
||||||
|
|
||||||
|
```text
|
||||||
|
https://hub.nodedc.ru
|
||||||
|
https://engine.nodedc.ru
|
||||||
|
https://ops.nodedc.ru
|
||||||
|
https://id.nodedc.ru
|
||||||
|
https://ops-agents.nodedc.ru
|
||||||
|
```
|
||||||
|
|
||||||
|
Tunnel/private URLs are also forbidden in `local`. If Tailscale/ngrok is deliberately needed, switch to:
|
||||||
|
|
||||||
|
```text
|
||||||
|
NODEDC_ENV=tunnel-local-e2e
|
||||||
|
```
|
||||||
|
|
||||||
|
## Standard Check
|
||||||
|
|
||||||
|
Run this before UI testing and before deploy preparation:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cd /Users/dcconstructions/Downloads/mnt/NODEDC/platform
|
||||||
|
sh infra/scripts/check-local-test-system.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
This command:
|
||||||
|
|
||||||
|
- checks Node and shell syntax;
|
||||||
|
- checks local env safety;
|
||||||
|
- checks AI Workspace config contract;
|
||||||
|
- renders local Docker Compose config without starting containers;
|
||||||
|
- checks that local Compose does not inject the full `.env` into every service;
|
||||||
|
- runs run-profile and ontology assistant caller smoke tests;
|
||||||
|
- classifies the current runtime topology;
|
||||||
|
- checks diff whitespace hygiene.
|
||||||
|
|
||||||
|
It does not deploy, restart containers, mutate databases, or write production config.
|
||||||
|
|
||||||
|
## Runtime Modes
|
||||||
|
|
||||||
|
`contract-only`
|
||||||
|
|
||||||
|
Static contracts are testable, but no runtime path is proven.
|
||||||
|
|
||||||
|
`local-ui-only`
|
||||||
|
|
||||||
|
Some local UI/backend endpoints respond, but AI write path is not proven.
|
||||||
|
|
||||||
|
`local-ops-vertical`
|
||||||
|
|
||||||
|
Local Ops/Gateway/Tasker vertical path is reachable, but remote worker action path is not proven.
|
||||||
|
|
||||||
|
`local-remote-worker-relay`
|
||||||
|
|
||||||
|
Expected current target for this workstation:
|
||||||
|
|
||||||
|
```text
|
||||||
|
local product apps + local Assistant + deployed AI Hub relay + remote Codex worker
|
||||||
|
```
|
||||||
|
|
||||||
|
This is valid only when Assistant action relay is configured. A successful UI request must show that `assistant_action_call` routes through the relay back to local Assistant.
|
||||||
|
|
||||||
|
`true-local-e2e`
|
||||||
|
|
||||||
|
Everything, including Hub and worker reachability, is local.
|
||||||
|
|
||||||
|
`tunnel-local-e2e`
|
||||||
|
|
||||||
|
Explicit Tailscale/ngrok profile. This must never happen by accidental URL fallback.
|
||||||
|
|
||||||
|
## Strict Runtime Checks
|
||||||
|
|
||||||
|
Require a live local/relay runtime:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
REQUIRE_RUNTIME=1 sh infra/scripts/check-local-test-system.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Require specifically the remote-worker relay mode:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
REQUIRE_REMOTE_WORKER_RELAY=1 sh infra/scripts/check-local-test-system.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
## Deploy Rule
|
||||||
|
|
||||||
|
Deploy is allowed only after:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
sh infra/scripts/check-local-test-system.sh
|
||||||
|
sh infra/scripts/check-ai-workspace-release-gates.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Runtime bugs must be fixed in the local profile first. Do not deploy product app changes just to test whether the local AI Workspace path works.
|
||||||
|
|
||||||
|
## Audit And Test Map
|
||||||
|
|
||||||
|
Current audit report:
|
||||||
|
|
||||||
|
```text
|
||||||
|
docs/AI_WORKSPACE_SECURITY_AUDIT_2026-06-20.md
|
||||||
|
```
|
||||||
|
|
||||||
|
Current horizontal, vertical, and security test matrix:
|
||||||
|
|
||||||
|
```text
|
||||||
|
docs/AI_WORKSPACE_TEST_MATRIX.md
|
||||||
|
```
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
# domains
|
# domains
|
||||||
|
NODEDC_ENV=local
|
||||||
AUTH_DOMAIN=auth.local.nodedc
|
AUTH_DOMAIN=auth.local.nodedc
|
||||||
AUTH_ADMIN_DOMAIN=auth-admin.local.nodedc
|
AUTH_ADMIN_DOMAIN=auth-admin.local.nodedc
|
||||||
LAUNCHER_DOMAIN=launcher.local.nodedc
|
LAUNCHER_DOMAIN=launcher.local.nodedc
|
||||||
|
|
@ -62,11 +63,13 @@ 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_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_TOKEN=replace-with-ops-agent-gateway-internal-token
|
||||||
AI_WORKSPACE_OPS_ENTITLEMENT_REQUIRED=false
|
AI_WORKSPACE_OPS_ENTITLEMENT_REQUIRED=false
|
||||||
|
AI_WORKSPACE_ASSISTANT_ACTION_RELAY_ENABLED=true
|
||||||
|
AI_WORKSPACE_ASSISTANT_ACTION_RELAY_ID=local-dev
|
||||||
|
|
||||||
# AI Workspace Hub for downloaded Codex workers.
|
# AI Workspace Hub for downloaded Codex workers.
|
||||||
# Default local development uses the deployed relay and is a hybrid-prod-bridge topology:
|
# Default local development may use the deployed AI Hub only as a relay for remote Codex workers.
|
||||||
# local UI/services can be tested, but live Codex worker write-path e2e is not proven.
|
# Launcher/Engine/Ops/Auth downstream URLs must remain local.
|
||||||
# For true local/tunnel e2e, change both Hub URLs and Ops Gateway public URL deliberately.
|
# For Tailscale/ngrok-style relay URLs, set NODEDC_ENV=tunnel-local-e2e deliberately.
|
||||||
AI_WORKSPACE_HUB_TOKEN=change-me-generate-with-infra-scripts-init-dev-env
|
AI_WORKSPACE_HUB_TOKEN=change-me-generate-with-infra-scripts-init-dev-env
|
||||||
AI_WORKSPACE_HUB_HOST_BIND=127.0.0.1:18081
|
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_PUBLIC_URL=wss://ai-hub.nodedc.ru/api/ai-workspace/hub
|
||||||
|
|
|
||||||
|
|
@ -32,6 +32,15 @@ infra/
|
||||||
|
|
||||||
## Local start
|
## Local start
|
||||||
|
|
||||||
|
0. Check the local testing contract:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /Users/dcconstructions/Downloads/mnt/NODEDC/platform
|
||||||
|
sh infra/scripts/check-local-test-system.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
The local testing canon is documented in `docs/LOCAL_TESTING_SYSTEM.md`. It keeps product apps local and allows the deployed AI Hub only as a remote Codex worker relay.
|
||||||
|
|
||||||
1. Add local domains to `/etc/hosts`:
|
1. Add local domains to `/etc/hosts`:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
|
|
|
||||||
|
|
@ -4,9 +4,6 @@ services:
|
||||||
reverse-proxy:
|
reverse-proxy:
|
||||||
image: ${PLATFORM_PROXY_IMAGE:-nodedc/plane-proxy:ru}
|
image: ${PLATFORM_PROXY_IMAGE:-nodedc/plane-proxy:ru}
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
env_file:
|
|
||||||
- path: .env
|
|
||||||
required: false
|
|
||||||
ports:
|
ports:
|
||||||
- "${PLATFORM_HTTP_PORT:-80}:80"
|
- "${PLATFORM_HTTP_PORT:-80}:80"
|
||||||
volumes:
|
volumes:
|
||||||
|
|
@ -28,9 +25,6 @@ services:
|
||||||
postgresql-authentik:
|
postgresql-authentik:
|
||||||
image: docker.io/library/postgres:16-alpine
|
image: docker.io/library/postgres:16-alpine
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
env_file:
|
|
||||||
- path: .env
|
|
||||||
required: false
|
|
||||||
environment:
|
environment:
|
||||||
POSTGRES_DB: ${PG_DB:-authentik}
|
POSTGRES_DB: ${PG_DB:-authentik}
|
||||||
POSTGRES_PASSWORD: ${PG_PASS:?database password required}
|
POSTGRES_PASSWORD: ${PG_PASS:?database password required}
|
||||||
|
|
@ -48,9 +42,6 @@ services:
|
||||||
image: ${AUTHENTIK_IMAGE:-ghcr.io/goauthentik/server}:${AUTHENTIK_TAG:-2026.2.2}
|
image: ${AUTHENTIK_IMAGE:-ghcr.io/goauthentik/server}:${AUTHENTIK_TAG:-2026.2.2}
|
||||||
command: server
|
command: server
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
env_file:
|
|
||||||
- path: .env
|
|
||||||
required: false
|
|
||||||
environment:
|
environment:
|
||||||
AUTHENTIK_POSTGRESQL__HOST: postgresql-authentik
|
AUTHENTIK_POSTGRESQL__HOST: postgresql-authentik
|
||||||
AUTHENTIK_POSTGRESQL__NAME: ${PG_DB:-authentik}
|
AUTHENTIK_POSTGRESQL__NAME: ${PG_DB:-authentik}
|
||||||
|
|
@ -58,6 +49,10 @@ services:
|
||||||
AUTHENTIK_POSTGRESQL__USER: ${PG_USER:-authentik}
|
AUTHENTIK_POSTGRESQL__USER: ${PG_USER:-authentik}
|
||||||
AUTHENTIK_SECRET_KEY: ${AUTHENTIK_SECRET_KEY:?secret key required}
|
AUTHENTIK_SECRET_KEY: ${AUTHENTIK_SECRET_KEY:?secret key required}
|
||||||
AUTHENTIK_LISTEN__TRUSTED_PROXY_CIDRS: ${AUTHENTIK_LISTEN__TRUSTED_PROXY_CIDRS:-127.0.0.0/8,10.0.0.0/8,172.16.0.0/12,192.168.0.0/16,::1/128}
|
AUTHENTIK_LISTEN__TRUSTED_PROXY_CIDRS: ${AUTHENTIK_LISTEN__TRUSTED_PROXY_CIDRS:-127.0.0.0/8,10.0.0.0/8,172.16.0.0/12,192.168.0.0/16,::1/128}
|
||||||
|
AUTHENTIK_ERROR_REPORTING__ENABLED: ${AUTHENTIK_ERROR_REPORTING__ENABLED:-false}
|
||||||
|
AUTHENTIK_BOOTSTRAP_EMAIL: ${AUTHENTIK_BOOTSTRAP_EMAIL:-}
|
||||||
|
AUTHENTIK_BOOTSTRAP_PASSWORD: ${AUTHENTIK_BOOTSTRAP_PASSWORD:-}
|
||||||
|
AUTHENTIK_BOOTSTRAP_TOKEN: ${AUTHENTIK_BOOTSTRAP_TOKEN:-}
|
||||||
depends_on:
|
depends_on:
|
||||||
postgresql-authentik:
|
postgresql-authentik:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
|
|
@ -74,15 +69,13 @@ services:
|
||||||
command: worker
|
command: worker
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
user: root
|
user: root
|
||||||
env_file:
|
|
||||||
- path: .env
|
|
||||||
required: false
|
|
||||||
environment:
|
environment:
|
||||||
AUTHENTIK_POSTGRESQL__HOST: postgresql-authentik
|
AUTHENTIK_POSTGRESQL__HOST: postgresql-authentik
|
||||||
AUTHENTIK_POSTGRESQL__NAME: ${PG_DB:-authentik}
|
AUTHENTIK_POSTGRESQL__NAME: ${PG_DB:-authentik}
|
||||||
AUTHENTIK_POSTGRESQL__PASSWORD: ${PG_PASS:?database password required}
|
AUTHENTIK_POSTGRESQL__PASSWORD: ${PG_PASS:?database password required}
|
||||||
AUTHENTIK_POSTGRESQL__USER: ${PG_USER:-authentik}
|
AUTHENTIK_POSTGRESQL__USER: ${PG_USER:-authentik}
|
||||||
AUTHENTIK_SECRET_KEY: ${AUTHENTIK_SECRET_KEY:?secret key required}
|
AUTHENTIK_SECRET_KEY: ${AUTHENTIK_SECRET_KEY:?secret key required}
|
||||||
|
AUTHENTIK_ERROR_REPORTING__ENABLED: ${AUTHENTIK_ERROR_REPORTING__ENABLED:-false}
|
||||||
depends_on:
|
depends_on:
|
||||||
postgresql-authentik:
|
postgresql-authentik:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
|
|
@ -96,9 +89,6 @@ services:
|
||||||
notification-postgres:
|
notification-postgres:
|
||||||
image: docker.io/library/postgres:16-alpine
|
image: docker.io/library/postgres:16-alpine
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
env_file:
|
|
||||||
- path: .env
|
|
||||||
required: false
|
|
||||||
environment:
|
environment:
|
||||||
POSTGRES_DB: ${NOTIFICATION_PG_DB:-nodedc_notifications}
|
POSTGRES_DB: ${NOTIFICATION_PG_DB:-nodedc_notifications}
|
||||||
POSTGRES_PASSWORD: ${NOTIFICATION_PG_PASS:-nodedc_notifications}
|
POSTGRES_PASSWORD: ${NOTIFICATION_PG_PASS:-nodedc_notifications}
|
||||||
|
|
@ -117,9 +107,6 @@ services:
|
||||||
build:
|
build:
|
||||||
context: ../services/notification-core
|
context: ../services/notification-core
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
env_file:
|
|
||||||
- path: .env
|
|
||||||
required: false
|
|
||||||
environment:
|
environment:
|
||||||
NODE_ENV: production
|
NODE_ENV: production
|
||||||
PORT: 5185
|
PORT: 5185
|
||||||
|
|
@ -133,9 +120,6 @@ services:
|
||||||
ai-workspace-postgres:
|
ai-workspace-postgres:
|
||||||
image: docker.io/library/postgres:16-alpine
|
image: docker.io/library/postgres:16-alpine
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
env_file:
|
|
||||||
- path: .env
|
|
||||||
required: false
|
|
||||||
environment:
|
environment:
|
||||||
POSTGRES_DB: ${AI_WORKSPACE_PG_DB:-nodedc_ai_workspace}
|
POSTGRES_DB: ${AI_WORKSPACE_PG_DB:-nodedc_ai_workspace}
|
||||||
POSTGRES_PASSWORD: ${AI_WORKSPACE_PG_PASS:-nodedc_ai_workspace}
|
POSTGRES_PASSWORD: ${AI_WORKSPACE_PG_PASS:-nodedc_ai_workspace}
|
||||||
|
|
@ -152,11 +136,9 @@ services:
|
||||||
ai-workspace-assistant:
|
ai-workspace-assistant:
|
||||||
image: nodedc/ai-workspace-assistant:local
|
image: nodedc/ai-workspace-assistant:local
|
||||||
build:
|
build:
|
||||||
context: ../services/ai-workspace-assistant
|
context: ../services
|
||||||
|
dockerfile: ai-workspace-assistant/Dockerfile.dev
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
env_file:
|
|
||||||
- path: .env
|
|
||||||
required: false
|
|
||||||
environment:
|
environment:
|
||||||
NODE_ENV: production
|
NODE_ENV: production
|
||||||
PORT: 18082
|
PORT: 18082
|
||||||
|
|
@ -166,6 +148,16 @@ services:
|
||||||
AI_WORKSPACE_HUB_INTERNAL_URL: ${AI_WORKSPACE_HUB_INTERNAL_URL:-https://ai-hub.nodedc.ru}
|
AI_WORKSPACE_HUB_INTERNAL_URL: ${AI_WORKSPACE_HUB_INTERNAL_URL:-https://ai-hub.nodedc.ru}
|
||||||
AI_WORKSPACE_HUB_TOKEN: ${AI_WORKSPACE_HUB_TOKEN:-}
|
AI_WORKSPACE_HUB_TOKEN: ${AI_WORKSPACE_HUB_TOKEN:-}
|
||||||
AI_WORKSPACE_HUB_FALLBACK_URLS: ${AI_WORKSPACE_HUB_FALLBACK_URLS:-}
|
AI_WORKSPACE_HUB_FALLBACK_URLS: ${AI_WORKSPACE_HUB_FALLBACK_URLS:-}
|
||||||
|
AI_WORKSPACE_ASSISTANT_ACTION_RELAY_ENABLED: ${AI_WORKSPACE_ASSISTANT_ACTION_RELAY_ENABLED:-true}
|
||||||
|
AI_WORKSPACE_ASSISTANT_ACTION_RELAY_ID: ${AI_WORKSPACE_ASSISTANT_ACTION_RELAY_ID:-local-dev}
|
||||||
|
AI_WORKSPACE_OPS_ENTITLEMENT_URL: ${AI_WORKSPACE_OPS_ENTITLEMENT_URL:-}
|
||||||
|
AI_WORKSPACE_OPS_ENTITLEMENT_TOKEN: ${AI_WORKSPACE_OPS_ENTITLEMENT_TOKEN:-}
|
||||||
|
AI_WORKSPACE_OPS_ENTITLEMENT_REQUIRED: ${AI_WORKSPACE_OPS_ENTITLEMENT_REQUIRED:-false}
|
||||||
|
AI_WORKSPACE_OPS_GATEWAY_BASE_URL: ${AI_WORKSPACE_OPS_GATEWAY_BASE_URL:-}
|
||||||
|
AI_WORKSPACE_OPS_DEFAULT_WORKSPACE_SLUG: ${AI_WORKSPACE_OPS_DEFAULT_WORKSPACE_SLUG:-}
|
||||||
|
AI_WORKSPACE_OPS_DEFAULT_PROJECT_ID: ${AI_WORKSPACE_OPS_DEFAULT_PROJECT_ID:-}
|
||||||
|
NODEDC_INTERNAL_ACCESS_TOKEN: ${NODEDC_INTERNAL_ACCESS_TOKEN:-}
|
||||||
|
NDC_LAUNCHER_INTERNAL_URL: ${NDC_LAUNCHER_INTERNAL_URL:-http://host.docker.internal:5173}
|
||||||
expose:
|
expose:
|
||||||
- "18082"
|
- "18082"
|
||||||
ports:
|
ports:
|
||||||
|
|
@ -179,14 +171,13 @@ services:
|
||||||
build:
|
build:
|
||||||
context: ../services/ai-workspace-hub
|
context: ../services/ai-workspace-hub
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
env_file:
|
|
||||||
- path: .env
|
|
||||||
required: false
|
|
||||||
environment:
|
environment:
|
||||||
NODE_ENV: production
|
NODE_ENV: production
|
||||||
PORT: 18081
|
PORT: 18081
|
||||||
AI_WORKSPACE_HUB_TOKEN: ${AI_WORKSPACE_HUB_TOKEN:-dev-ai-workspace-hub-token}
|
AI_WORKSPACE_HUB_TOKEN: ${AI_WORKSPACE_HUB_TOKEN:-dev-ai-workspace-hub-token}
|
||||||
AI_WORKSPACE_HUB_WS_PATH: /api/ai-workspace/hub
|
AI_WORKSPACE_HUB_WS_PATH: /api/ai-workspace/hub
|
||||||
|
NODEDC_INTERNAL_ACCESS_TOKEN: ${NODEDC_INTERNAL_ACCESS_TOKEN:-}
|
||||||
|
NODEDC_AI_WORKSPACE_ASSISTANT_URL: http://ai-workspace-assistant:18082
|
||||||
expose:
|
expose:
|
||||||
- "18081"
|
- "18081"
|
||||||
ports:
|
ports:
|
||||||
|
|
|
||||||
|
|
@ -55,6 +55,21 @@ require_contains() {
|
||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
|
require_not_contains() {
|
||||||
|
file="$1"
|
||||||
|
needle="$2"
|
||||||
|
label="$3"
|
||||||
|
if [ ! -f "$file" ]; then
|
||||||
|
fail "$label (missing file: $file)"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
if grep -Fq "$needle" "$file"; then
|
||||||
|
fail "$label (forbidden: $needle)"
|
||||||
|
else
|
||||||
|
pass "$label"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
read_env() {
|
read_env() {
|
||||||
file="$1"
|
file="$1"
|
||||||
key="$2"
|
key="$2"
|
||||||
|
|
@ -88,9 +103,12 @@ require_env_value() {
|
||||||
|
|
||||||
section "Required files"
|
section "Required files"
|
||||||
require_file "$PLATFORM_ROOT/infra/.env.example" "Platform local env example exists"
|
require_file "$PLATFORM_ROOT/infra/.env.example" "Platform local env example exists"
|
||||||
|
require_file "$PLATFORM_ROOT/infra/scripts/check-local-environment-safety.mjs" "Local environment safety checker exists"
|
||||||
require_file "$PLATFORM_ROOT/infra/synology/.env.synology.example" "Platform Synology env example exists"
|
require_file "$PLATFORM_ROOT/infra/synology/.env.synology.example" "Platform Synology env example exists"
|
||||||
require_file "$PLATFORM_ROOT/infra/docker-compose.dev.yml" "Platform local compose exists"
|
require_file "$PLATFORM_ROOT/infra/docker-compose.dev.yml" "Platform local compose exists"
|
||||||
require_file "$PLATFORM_ROOT/infra/synology/docker-compose.platform-http.yml" "Platform Synology compose exists"
|
require_file "$PLATFORM_ROOT/infra/synology/docker-compose.platform-http.yml" "Platform Synology compose exists"
|
||||||
|
require_file "$PLATFORM_ROOT/infra/synology/sync-ai-hub-relay.sh" "Synology AI Hub relay sync script exists"
|
||||||
|
require_file "$PLATFORM_ROOT/infra/synology/apply-ai-hub-relay.sh" "Synology AI Hub relay apply script exists"
|
||||||
require_file "$OPS_GATEWAY_REPO/.env.example" "Ops Gateway local env example exists"
|
require_file "$OPS_GATEWAY_REPO/.env.example" "Ops Gateway local env example exists"
|
||||||
require_file "$OPS_GATEWAY_REPO/.env.synology.example" "Ops Gateway Synology env example exists"
|
require_file "$OPS_GATEWAY_REPO/.env.synology.example" "Ops Gateway Synology env example exists"
|
||||||
require_file "$OPS_GATEWAY_REPO/docker-compose.local.yml" "Ops Gateway local compose exists"
|
require_file "$OPS_GATEWAY_REPO/docker-compose.local.yml" "Ops Gateway local compose exists"
|
||||||
|
|
@ -98,11 +116,28 @@ require_file "$OPS_GATEWAY_REPO/docker-compose.synology.yml" "Ops Gateway Synolo
|
||||||
require_file "$ENGINE_REPO/docker-compose.yml" "Engine compose exists"
|
require_file "$ENGINE_REPO/docker-compose.yml" "Engine compose exists"
|
||||||
|
|
||||||
section "Platform local contract"
|
section "Platform local contract"
|
||||||
|
require_env_value "$PLATFORM_ROOT/infra/.env.example" "NODEDC_ENV" "local" "Local Platform example declares NODEDC_ENV"
|
||||||
require_env_value "$PLATFORM_ROOT/infra/.env.example" "AI_WORKSPACE_OPS_ENTITLEMENT_URL" "http://host.docker.internal:4100/api/internal/v1/ai-workspace/entitlements" "Local Platform example points Ops entitlement to local Gateway"
|
require_env_value "$PLATFORM_ROOT/infra/.env.example" "AI_WORKSPACE_OPS_ENTITLEMENT_URL" "http://host.docker.internal:4100/api/internal/v1/ai-workspace/entitlements" "Local Platform example points Ops entitlement to local Gateway"
|
||||||
require_env_value "$PLATFORM_ROOT/infra/.env.example" "AI_WORKSPACE_HUB_PUBLIC_URL" "wss://ai-hub.nodedc.ru/api/ai-workspace/hub" "Local Platform example keeps deployed Hub as hybrid worker relay by default"
|
require_env_value "$PLATFORM_ROOT/infra/.env.example" "AI_WORKSPACE_ASSISTANT_ACTION_RELAY_ENABLED" "true" "Local Platform example enables Assistant action relay"
|
||||||
require_env_value "$PLATFORM_ROOT/infra/.env.example" "AI_WORKSPACE_HUB_INTERNAL_URL" "https://ai-hub.nodedc.ru" "Local Platform example has explicit Hub internal URL"
|
require_env_value "$PLATFORM_ROOT/infra/.env.example" "AI_WORKSPACE_ASSISTANT_ACTION_RELAY_ID" "local-dev" "Local Platform example declares stable local Assistant action relay id"
|
||||||
|
require_env_value "$PLATFORM_ROOT/infra/.env.example" "AI_WORKSPACE_HUB_PUBLIC_URL" "wss://ai-hub.nodedc.ru/api/ai-workspace/hub" "Local Platform example keeps deployed AI Hub only as worker relay"
|
||||||
|
require_env_value "$PLATFORM_ROOT/infra/.env.example" "AI_WORKSPACE_HUB_INTERNAL_URL" "https://ai-hub.nodedc.ru" "Local Platform example has explicit relay control URL"
|
||||||
|
require_contains "$PLATFORM_ROOT/infra/scripts/init-dev-env.sh" "NODEDC_ENV=local" "Local env generator emits NODEDC_ENV"
|
||||||
require_contains "$PLATFORM_ROOT/infra/scripts/init-dev-env.sh" "AI_WORKSPACE_OPS_ENTITLEMENT_URL=http://host.docker.internal:4100/api/internal/v1/ai-workspace/entitlements" "Local env generator emits local Ops entitlement URL"
|
require_contains "$PLATFORM_ROOT/infra/scripts/init-dev-env.sh" "AI_WORKSPACE_OPS_ENTITLEMENT_URL=http://host.docker.internal:4100/api/internal/v1/ai-workspace/entitlements" "Local env generator emits local Ops entitlement URL"
|
||||||
require_contains "$PLATFORM_ROOT/infra/scripts/init-dev-env.sh" "AI_WORKSPACE_HUB_INTERNAL_URL=https://ai-hub.nodedc.ru" "Local env generator emits explicit Hub internal URL"
|
require_contains "$PLATFORM_ROOT/infra/scripts/init-dev-env.sh" "AI_WORKSPACE_ASSISTANT_ACTION_RELAY_ENABLED=true" "Local env generator enables Assistant action relay"
|
||||||
|
require_contains "$PLATFORM_ROOT/infra/scripts/init-dev-env.sh" "AI_WORKSPACE_ASSISTANT_ACTION_RELAY_ID=local-dev" "Local env generator emits stable Assistant action relay id"
|
||||||
|
require_contains "$PLATFORM_ROOT/infra/scripts/init-dev-env.sh" "AI_WORKSPACE_HUB_INTERNAL_URL=https://ai-hub.nodedc.ru" "Local env generator emits explicit relay control URL"
|
||||||
|
require_not_contains "$PLATFORM_ROOT/infra/docker-compose.dev.yml" "env_file:" "Local compose does not inject full .env into containers"
|
||||||
|
require_contains "$PLATFORM_ROOT/infra/docker-compose.dev.yml" 'NODEDC_INTERNAL_ACCESS_TOKEN: ${NODEDC_INTERNAL_ACCESS_TOKEN:-}' "Local compose explicitly passes internal token only to AI services"
|
||||||
|
require_contains "$PLATFORM_ROOT/infra/docker-compose.dev.yml" 'AI_WORKSPACE_ASSISTANT_ACTION_RELAY_ENABLED: ${AI_WORKSPACE_ASSISTANT_ACTION_RELAY_ENABLED:-true}' "Local compose explicitly passes Assistant action relay enabled flag"
|
||||||
|
require_contains "$PLATFORM_ROOT/infra/docker-compose.dev.yml" 'AI_WORKSPACE_ASSISTANT_ACTION_RELAY_ID: ${AI_WORKSPACE_ASSISTANT_ACTION_RELAY_ID:-local-dev}' "Local compose explicitly passes Assistant action relay id"
|
||||||
|
|
||||||
|
section "Local safety guardrail"
|
||||||
|
if node "$PLATFORM_ROOT/infra/scripts/check-local-environment-safety.mjs"; then
|
||||||
|
pass "Local environment safety checker passes"
|
||||||
|
else
|
||||||
|
fail "Local environment safety checker failed"
|
||||||
|
fi
|
||||||
|
|
||||||
section "Platform Synology contract"
|
section "Platform Synology contract"
|
||||||
require_env_value "$PLATFORM_ROOT/infra/synology/.env.synology.example" "NODEDC_AI_WORKSPACE_ASSISTANT_URL" "http://ai-workspace-assistant:18082" "Synology Platform exposes Assistant to app services on compose network"
|
require_env_value "$PLATFORM_ROOT/infra/synology/.env.synology.example" "NODEDC_AI_WORKSPACE_ASSISTANT_URL" "http://ai-workspace-assistant:18082" "Synology Platform exposes Assistant to app services on compose network"
|
||||||
|
|
@ -110,6 +145,11 @@ require_env_value "$PLATFORM_ROOT/infra/synology/.env.synology.example" "AI_WORK
|
||||||
require_env_value "$PLATFORM_ROOT/infra/synology/.env.synology.example" "AI_WORKSPACE_HUB_PUBLIC_URL" "wss://ai-hub.nodedc.ru/api/ai-workspace/hub" "Synology Platform worker-facing Hub URL is public relay"
|
require_env_value "$PLATFORM_ROOT/infra/synology/.env.synology.example" "AI_WORKSPACE_HUB_PUBLIC_URL" "wss://ai-hub.nodedc.ru/api/ai-workspace/hub" "Synology Platform worker-facing Hub URL is public relay"
|
||||||
require_env_value "$PLATFORM_ROOT/infra/synology/.env.synology.example" "AI_WORKSPACE_HUB_INTERNAL_URL" "https://ai-hub.nodedc.ru" "Synology Platform backend Hub URL is explicit"
|
require_env_value "$PLATFORM_ROOT/infra/synology/.env.synology.example" "AI_WORKSPACE_HUB_INTERNAL_URL" "https://ai-hub.nodedc.ru" "Synology Platform backend Hub URL is explicit"
|
||||||
require_contains "$PLATFORM_ROOT/infra/synology/verify-current-runtime.sh" 'AI_WORKSPACE_OPS_ENTITLEMENT_URL" = "http://172.22.0.222:18190/api/internal/v1/ai-workspace/entitlements"' "Synology verify checks Ops entitlement URL"
|
require_contains "$PLATFORM_ROOT/infra/synology/verify-current-runtime.sh" 'AI_WORKSPACE_OPS_ENTITLEMENT_URL" = "http://172.22.0.222:18190/api/internal/v1/ai-workspace/entitlements"' "Synology verify checks Ops entitlement URL"
|
||||||
|
require_contains "$PLATFORM_ROOT/infra/synology/sync-ai-hub-relay.sh" 'services/ai-workspace-hub/' "Hub-only sync copies AI Workspace Hub source"
|
||||||
|
require_contains "$PLATFORM_ROOT/infra/synology/apply-ai-hub-relay.sh" 'up -d --no-deps --force-recreate ai-workspace-hub' "Hub-only apply recreates only AI Workspace Hub without dependencies"
|
||||||
|
require_contains "$PLATFORM_ROOT/infra/synology/apply-ai-hub-relay.sh" '/api/ai-workspace/hub/v1/assistant-relays/' "Hub-only apply verifies Assistant relay endpoint"
|
||||||
|
require_not_contains "$PLATFORM_ROOT/infra/synology/apply-ai-hub-relay.sh" ' launcher' "Hub-only apply does not target Launcher"
|
||||||
|
require_not_contains "$PLATFORM_ROOT/infra/synology/apply-ai-hub-relay.sh" 'authentik' "Hub-only apply does not target Authentik"
|
||||||
|
|
||||||
section "Ops Gateway contract"
|
section "Ops Gateway contract"
|
||||||
require_env_value "$OPS_GATEWAY_REPO/.env.example" "NODEDC_AGENT_GATEWAY_PUBLIC_URL" "https://ops-agents.nodedc.ru" "Ops Gateway local example keeps worker-facing MCP URL explicit"
|
require_env_value "$OPS_GATEWAY_REPO/.env.example" "NODEDC_AGENT_GATEWAY_PUBLIC_URL" "https://ops-agents.nodedc.ru" "Ops Gateway local example keeps worker-facing MCP URL explicit"
|
||||||
|
|
@ -134,6 +174,13 @@ if [ -f "$PLATFORM_ENV" ]; then
|
||||||
else
|
else
|
||||||
pass "Current Platform env has AI_WORKSPACE_OPS_ENTITLEMENT_URL"
|
pass "Current Platform env has AI_WORKSPACE_OPS_ENTITLEMENT_URL"
|
||||||
fi
|
fi
|
||||||
|
local_action_relay_enabled=$(read_env "$PLATFORM_ENV" AI_WORKSPACE_ASSISTANT_ACTION_RELAY_ENABLED || true)
|
||||||
|
local_action_relay_id=$(read_env "$PLATFORM_ENV" AI_WORKSPACE_ASSISTANT_ACTION_RELAY_ID || true)
|
||||||
|
if [ "$local_action_relay_enabled" = "true" ] && [ -n "$local_action_relay_id" ]; then
|
||||||
|
pass "Current Platform env has Assistant action relay enabled"
|
||||||
|
else
|
||||||
|
warn "Current Platform env has no enabled Assistant action relay; remote worker action calls may route incorrectly."
|
||||||
|
fi
|
||||||
else
|
else
|
||||||
warn "Current Platform env not found: $PLATFORM_ENV"
|
warn "Current Platform env not found: $PLATFORM_ENV"
|
||||||
fi
|
fi
|
||||||
|
|
|
||||||
|
|
@ -65,9 +65,12 @@ run_in "$PLATFORM_ROOT" \
|
||||||
"infra/scripts/check-ai-workspace-topology.sh"
|
"infra/scripts/check-ai-workspace-topology.sh"
|
||||||
|
|
||||||
section "Platform static and contract gates"
|
section "Platform static and contract gates"
|
||||||
|
run_in "$PLATFORM_ROOT" \
|
||||||
|
"Local test system contract gate" \
|
||||||
|
"sh infra/scripts/check-local-test-system.sh"
|
||||||
run_in "$PLATFORM_ROOT" \
|
run_in "$PLATFORM_ROOT" \
|
||||||
"Platform shell syntax" \
|
"Platform shell syntax" \
|
||||||
"sh -n infra/scripts/init-dev-env.sh infra/scripts/check-ai-workspace-topology.sh infra/scripts/check-ai-workspace-config-contract.sh infra/scripts/check-ai-workspace-release-gates.sh infra/synology/check-ai-workspace-apply-plan.sh infra/synology/prepare-ai-workspace-env.sh infra/synology/deploy-current.sh infra/synology/verify-current-runtime.sh infra/synology/apply-current-runtime.sh infra/synology/backup-current.sh"
|
"sh -n infra/scripts/init-dev-env.sh infra/scripts/check-local-test-system.sh infra/scripts/check-ai-workspace-topology.sh infra/scripts/check-ai-workspace-config-contract.sh infra/scripts/check-ai-workspace-release-gates.sh infra/synology/check-ai-workspace-apply-plan.sh infra/synology/prepare-ai-workspace-env.sh infra/synology/deploy-current.sh infra/synology/verify-current-runtime.sh infra/synology/apply-current-runtime.sh infra/synology/backup-current.sh"
|
||||||
run_in "$PLATFORM_ROOT" \
|
run_in "$PLATFORM_ROOT" \
|
||||||
"AI Workspace config contract" \
|
"AI Workspace config contract" \
|
||||||
"infra/scripts/check-ai-workspace-config-contract.sh"
|
"infra/scripts/check-ai-workspace-config-contract.sh"
|
||||||
|
|
|
||||||
|
|
@ -90,40 +90,63 @@ bool_and() {
|
||||||
}
|
}
|
||||||
|
|
||||||
assistant_code=$(http_status "$ASSISTANT_URL/healthz")
|
assistant_code=$(http_status "$ASSISTANT_URL/healthz")
|
||||||
hub_body=$(http_body "$HUB_URL/healthz")
|
|
||||||
hub_code=000
|
|
||||||
if [ -n "$hub_body" ]; then
|
|
||||||
hub_code=$(http_status "$HUB_URL/healthz")
|
|
||||||
fi
|
|
||||||
gateway_code=$(http_status "$GATEWAY_URL/readyz")
|
gateway_code=$(http_status "$GATEWAY_URL/readyz")
|
||||||
task_code=$(http_status "$TASK_URL")
|
task_code=$(http_status "$TASK_URL")
|
||||||
engine_code=$(http_status "$ENGINE_URL")
|
engine_code=$(http_status "$ENGINE_URL")
|
||||||
|
|
||||||
assistant_ok=$(ok_status "$assistant_code")
|
assistant_ok=$(ok_status "$assistant_code")
|
||||||
hub_ok=$(ok_status "$hub_code")
|
|
||||||
gateway_ok=$(ok_status "$gateway_code")
|
gateway_ok=$(ok_status "$gateway_code")
|
||||||
task_ok=$(ok_status "$task_code")
|
task_ok=$(ok_status "$task_code")
|
||||||
engine_ok=$(ok_status "$engine_code")
|
engine_ok=$(ok_status "$engine_code")
|
||||||
|
|
||||||
agents_online=$(printf "%s" "$hub_body" | json_number agentsOnline)
|
profile=$(read_env "$PLATFORM_ENV" NODEDC_ENV || true)
|
||||||
case "$agents_online" in
|
case "$profile" in
|
||||||
""|*[!0-9]*) agents_online=0 ;;
|
"") profile=local ;;
|
||||||
esac
|
esac
|
||||||
|
|
||||||
hub_public=$(read_env "$PLATFORM_ENV" AI_WORKSPACE_HUB_PUBLIC_URL || true)
|
hub_public=$(read_env "$PLATFORM_ENV" AI_WORKSPACE_HUB_PUBLIC_URL || true)
|
||||||
hub_internal=$(read_env "$PLATFORM_ENV" AI_WORKSPACE_HUB_INTERNAL_URL || true)
|
hub_internal=$(read_env "$PLATFORM_ENV" AI_WORKSPACE_HUB_INTERNAL_URL || true)
|
||||||
ops_entitlement=$(read_env "$PLATFORM_ENV" AI_WORKSPACE_OPS_ENTITLEMENT_URL || true)
|
ops_entitlement=$(read_env "$PLATFORM_ENV" AI_WORKSPACE_OPS_ENTITLEMENT_URL || true)
|
||||||
|
action_relay_enabled=$(read_env "$PLATFORM_ENV" AI_WORKSPACE_ASSISTANT_ACTION_RELAY_ENABLED || true)
|
||||||
|
action_relay_id=$(read_env "$PLATFORM_ENV" AI_WORKSPACE_ASSISTANT_ACTION_RELAY_ID || true)
|
||||||
|
case "$action_relay_enabled" in
|
||||||
|
1|true|TRUE|yes|YES|on|ON) action_relay_enabled=true ;;
|
||||||
|
*) action_relay_enabled=false ;;
|
||||||
|
esac
|
||||||
|
|
||||||
hub_public_kind=$(url_kind "$hub_public")
|
hub_public_kind=$(url_kind "$hub_public")
|
||||||
hub_internal_kind=$(url_kind "$hub_internal")
|
hub_internal_kind=$(url_kind "$hub_internal")
|
||||||
ops_entitlement_kind=$(url_kind "$ops_entitlement")
|
ops_entitlement_kind=$(url_kind "$ops_entitlement")
|
||||||
|
|
||||||
local_services_ok=$(bool_and "$(bool_and "$assistant_ok" "$hub_ok")" "$(bool_and "$gateway_ok" "$task_ok")")
|
local_hub_body=$(http_body "$HUB_URL/healthz")
|
||||||
|
local_hub_code=000
|
||||||
|
if [ -n "$local_hub_body" ]; then
|
||||||
|
local_hub_code=$(http_status "$HUB_URL/healthz")
|
||||||
|
fi
|
||||||
|
local_hub_ok=$(ok_status "$local_hub_code")
|
||||||
|
|
||||||
|
relay_health_code=000
|
||||||
|
relay_health_ok=false
|
||||||
|
if [ -n "$hub_internal" ]; then
|
||||||
|
relay_health_code=$(http_status "$(printf "%s" "$hub_internal" | sed 's#/*$##')/healthz")
|
||||||
|
relay_health_ok=$(ok_status "$relay_health_code")
|
||||||
|
fi
|
||||||
|
|
||||||
|
agents_online=$(printf "%s" "$local_hub_body" | json_number agentsOnline)
|
||||||
|
case "$agents_online" in
|
||||||
|
""|*[!0-9]*) agents_online=0 ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
local_services_ok=$(bool_and "$assistant_ok" "$(bool_and "$gateway_ok" "$task_ok")")
|
||||||
worker_on_local_hub=false
|
worker_on_local_hub=false
|
||||||
if [ "$agents_online" -gt 0 ]; then
|
if [ "$agents_online" -gt 0 ]; then
|
||||||
worker_on_local_hub=true
|
worker_on_local_hub=true
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
action_relay_configured=false
|
||||||
|
if [ "$action_relay_enabled" = true ] && [ -n "$action_relay_id" ]; then
|
||||||
|
action_relay_configured=true
|
||||||
|
fi
|
||||||
|
|
||||||
hub_runtime_kind=custom
|
hub_runtime_kind=custom
|
||||||
case "$hub_public_kind:$hub_internal_kind" in
|
case "$hub_public_kind:$hub_internal_kind" in
|
||||||
local:local|local:missing|missing:local) hub_runtime_kind=local ;;
|
local:local|local:missing|missing:local) hub_runtime_kind=local ;;
|
||||||
|
|
@ -135,6 +158,10 @@ esac
|
||||||
classification=contract-only
|
classification=contract-only
|
||||||
if [ "$local_services_ok" = true ]; then
|
if [ "$local_services_ok" = true ]; then
|
||||||
if [ "$engine_ok" = true ] \
|
if [ "$engine_ok" = true ] \
|
||||||
|
&& [ "$action_relay_configured" = true ] \
|
||||||
|
&& [ "$hub_runtime_kind" = prod-public ]; then
|
||||||
|
classification=local-remote-worker-relay
|
||||||
|
elif [ "$engine_ok" = true ] \
|
||||||
&& [ "$worker_on_local_hub" = true ] \
|
&& [ "$worker_on_local_hub" = true ] \
|
||||||
&& [ "$ops_entitlement_kind" = local ] \
|
&& [ "$ops_entitlement_kind" = local ] \
|
||||||
&& [ "$hub_runtime_kind" = local ]; then
|
&& [ "$hub_runtime_kind" = local ]; then
|
||||||
|
|
@ -156,9 +183,11 @@ fi
|
||||||
|
|
||||||
cat <<EOF
|
cat <<EOF
|
||||||
classification=$classification
|
classification=$classification
|
||||||
|
profile=$profile
|
||||||
platform_env=$PLATFORM_ENV
|
platform_env=$PLATFORM_ENV
|
||||||
assistant_health=$assistant_ok status=$assistant_code url=$ASSISTANT_URL/healthz
|
assistant_health=$assistant_ok status=$assistant_code url=$ASSISTANT_URL/healthz
|
||||||
hub_health=$hub_ok status=$hub_code url=$HUB_URL/healthz agents_online=$agents_online
|
local_hub_health=$local_hub_ok status=$local_hub_code url=$HUB_URL/healthz agents_online=$agents_online
|
||||||
|
relay_health=$relay_health_ok status=$relay_health_code url=${hub_internal%/}/healthz
|
||||||
gateway_health=$gateway_ok status=$gateway_code url=$GATEWAY_URL/readyz
|
gateway_health=$gateway_ok status=$gateway_code url=$GATEWAY_URL/readyz
|
||||||
task_health=$task_ok status=$task_code url=$TASK_URL
|
task_health=$task_ok status=$task_code url=$TASK_URL
|
||||||
engine_health=$engine_ok status=$engine_code url=$ENGINE_URL
|
engine_health=$engine_ok status=$engine_code url=$ENGINE_URL
|
||||||
|
|
@ -166,14 +195,20 @@ hub_public_kind=$hub_public_kind
|
||||||
hub_internal_kind=$hub_internal_kind
|
hub_internal_kind=$hub_internal_kind
|
||||||
hub_runtime_kind=$hub_runtime_kind
|
hub_runtime_kind=$hub_runtime_kind
|
||||||
ops_entitlement_kind=$ops_entitlement_kind
|
ops_entitlement_kind=$ops_entitlement_kind
|
||||||
|
assistant_action_relay_enabled=${action_relay_enabled:-missing}
|
||||||
|
assistant_action_relay_id=${action_relay_id:-missing}
|
||||||
|
assistant_action_relay_configured=$action_relay_configured
|
||||||
EOF
|
EOF
|
||||||
|
|
||||||
case "$classification" in
|
case "$classification" in
|
||||||
true-local-e2e|tunnel-local-e2e)
|
true-local-e2e|tunnel-local-e2e)
|
||||||
echo "meaning=local write-path e2e can be interpreted if the worker can reach returned MCP URLs"
|
echo "meaning=local write-path e2e can be interpreted if the worker can reach returned MCP URLs"
|
||||||
;;
|
;;
|
||||||
|
local-remote-worker-relay)
|
||||||
|
echo "meaning=local apps are the downstream target; deployed AI Hub is used only as remote worker relay and assistant actions route back to local Assistant"
|
||||||
|
;;
|
||||||
hybrid-prod-bridge)
|
hybrid-prod-bridge)
|
||||||
echo "meaning=do not treat UI dialog as local write-path proof; worker/HUB/MCP topology is mixed"
|
echo "meaning=do not treat UI dialog as local write-path proof; topology is mixed or assistant action relay is not configured"
|
||||||
;;
|
;;
|
||||||
local-ops-vertical)
|
local-ops-vertical)
|
||||||
echo "meaning=Gateway and Tasker local vertical checks are valid, but no live Codex worker e2e is proven"
|
echo "meaning=Gateway and Tasker local vertical checks are valid, but no live Codex worker e2e is proven"
|
||||||
|
|
@ -192,3 +227,10 @@ if [ "${REQUIRE_TRUE_E2E:-0}" = "1" ]; then
|
||||||
*) exit 2 ;;
|
*) exit 2 ;;
|
||||||
esac
|
esac
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
if [ "${REQUIRE_LOCAL_REMOTE_WORKER_RELAY:-0}" = "1" ]; then
|
||||||
|
case "$classification" in
|
||||||
|
local-remote-worker-relay) exit 0 ;;
|
||||||
|
*) exit 2 ;;
|
||||||
|
esac
|
||||||
|
fi
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,147 @@
|
||||||
|
#!/usr/bin/env node
|
||||||
|
import fs from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
|
||||||
|
const scriptDir = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
const platformRoot = path.resolve(scriptDir, "../..");
|
||||||
|
const monorepoRoot = path.resolve(platformRoot, "..");
|
||||||
|
|
||||||
|
const prodHosts = new Set([
|
||||||
|
"ai-hub.nodedc.ru",
|
||||||
|
"hub.nodedc.ru",
|
||||||
|
"engine.nodedc.ru",
|
||||||
|
"ops.nodedc.ru",
|
||||||
|
"id.nodedc.ru",
|
||||||
|
"ops-agents.nodedc.ru",
|
||||||
|
]);
|
||||||
|
|
||||||
|
const relayKeysAllowedInLocal = new Set([
|
||||||
|
"AI_WORKSPACE_HUB_PUBLIC_URL",
|
||||||
|
"AI_WORKSPACE_HUB_INTERNAL_URL",
|
||||||
|
"NDC_AI_WORKSPACE_HUB_URL",
|
||||||
|
"NDC_AI_WORKSPACE_HUB_HTTP_URL",
|
||||||
|
"AI_WORKSPACE_ASSISTANT_ACTION_GATEWAY_URL",
|
||||||
|
"NDC_AI_WORKSPACE_ASSISTANT_ACTION_GATEWAY_URL",
|
||||||
|
]);
|
||||||
|
|
||||||
|
const localOnlyFiles = [
|
||||||
|
{
|
||||||
|
file: path.join(platformRoot, "infra", ".env.example"),
|
||||||
|
required: true,
|
||||||
|
requiredKeys: ["NODEDC_ENV", "NODEDC_INTERNAL_ACCESS_TOKEN", "AI_WORKSPACE_HUB_PUBLIC_URL", "AI_WORKSPACE_HUB_INTERNAL_URL", "AI_WORKSPACE_ASSISTANT_ACTION_RELAY_ENABLED", "AI_WORKSPACE_ASSISTANT_ACTION_RELAY_ID"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
file: path.join(platformRoot, "infra", ".env"),
|
||||||
|
required: false,
|
||||||
|
requiredKeys: ["NODEDC_ENV", "NODEDC_INTERNAL_ACCESS_TOKEN", "AI_WORKSPACE_HUB_PUBLIC_URL", "AI_WORKSPACE_HUB_INTERNAL_URL", "AI_WORKSPACE_ASSISTANT_ACTION_RELAY_ENABLED", "AI_WORKSPACE_ASSISTANT_ACTION_RELAY_ID"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
file: path.join(monorepoRoot, "NODEDC_ENGINE_INFRA", "nodedc-source", "server", ".env.local"),
|
||||||
|
required: false,
|
||||||
|
requiredKeys: [
|
||||||
|
"NODEDC_ENV",
|
||||||
|
"NODEDC_LAUNCHER_INTERNAL_URL",
|
||||||
|
"NODEDC_LAUNCHER_ORIGIN",
|
||||||
|
"AUTHENTIK_PUBLIC_BASE_URL",
|
||||||
|
"NODEDC_AI_WORKSPACE_ASSISTANT_URL",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const failures = [];
|
||||||
|
const warnings = [];
|
||||||
|
const passes = [];
|
||||||
|
|
||||||
|
function parseEnvFile(file) {
|
||||||
|
const env = new Map();
|
||||||
|
const raw = fs.readFileSync(file, "utf8");
|
||||||
|
raw.split(/\r?\n/).forEach((line, index) => {
|
||||||
|
const trimmed = line.trim();
|
||||||
|
if (!trimmed || trimmed.startsWith("#")) return;
|
||||||
|
const match = /^([A-Za-z_][A-Za-z0-9_]*)=(.*)$/.exec(trimmed);
|
||||||
|
if (!match) return;
|
||||||
|
let value = match[2].trim();
|
||||||
|
if (
|
||||||
|
(value.startsWith('"') && value.endsWith('"')) ||
|
||||||
|
(value.startsWith("'") && value.endsWith("'"))
|
||||||
|
) {
|
||||||
|
value = value.slice(1, -1);
|
||||||
|
}
|
||||||
|
env.set(match[1], { value, line: index + 1 });
|
||||||
|
});
|
||||||
|
return env;
|
||||||
|
}
|
||||||
|
|
||||||
|
function urlHost(value) {
|
||||||
|
try {
|
||||||
|
return new URL(value).hostname.toLowerCase();
|
||||||
|
} catch {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isTunnelOrPrivateRelay(value) {
|
||||||
|
const host = urlHost(value);
|
||||||
|
return (
|
||||||
|
host.endsWith(".ts.net") ||
|
||||||
|
host.startsWith("100.") ||
|
||||||
|
host.startsWith("172.22.") ||
|
||||||
|
host.startsWith("192.168.") ||
|
||||||
|
host.startsWith("10.")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function checkLocalEnvFile({ file, required, requiredKeys }) {
|
||||||
|
if (!fs.existsSync(file)) {
|
||||||
|
if (required) failures.push(`${file}: missing required local env file`);
|
||||||
|
else warnings.push(`${file}: not present, skipped`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const env = parseEnvFile(file);
|
||||||
|
const profile = env.get("NODEDC_ENV")?.value || "local";
|
||||||
|
if (profile !== "local" && profile !== "tunnel-local-e2e") {
|
||||||
|
failures.push(`${file}: expected NODEDC_ENV=local or tunnel-local-e2e, got ${profile}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
requiredKeys.forEach((key) => {
|
||||||
|
if (!env.get(key)?.value) failures.push(`${file}: missing required ${key}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const [key, entry] of env.entries()) {
|
||||||
|
const value = entry.value;
|
||||||
|
if (!/^https?:\/\//i.test(value) && !/^wss?:\/\//i.test(value)) continue;
|
||||||
|
const host = urlHost(value);
|
||||||
|
if (!host) continue;
|
||||||
|
|
||||||
|
if (prodHosts.has(host) && !relayKeysAllowedInLocal.has(key)) {
|
||||||
|
failures.push(`${file}:${entry.line}: ${key} points local profile at prod host ${host}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isTunnelOrPrivateRelay(value) && profile !== "tunnel-local-e2e") {
|
||||||
|
failures.push(`${file}:${entry.line}: ${key} uses tunnel/private host ${host}; set NODEDC_ENV=tunnel-local-e2e explicitly or use a normal local/public-relay URL`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const hubInternal = env.get("AI_WORKSPACE_HUB_INTERNAL_URL")?.value || "";
|
||||||
|
if (hubInternal && profile === "local") {
|
||||||
|
const host = urlHost(hubInternal);
|
||||||
|
const allowed = host === "ai-hub.nodedc.ru" || host === "127.0.0.1" || host === "localhost";
|
||||||
|
if (!allowed) {
|
||||||
|
failures.push(`${file}: AI_WORKSPACE_HUB_INTERNAL_URL must be loopback or deployed relay in local profile, got ${hubInternal}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
passes.push(`${file}: checked`);
|
||||||
|
}
|
||||||
|
|
||||||
|
localOnlyFiles.forEach(checkLocalEnvFile);
|
||||||
|
|
||||||
|
passes.forEach((item) => console.log(`PASS ${item}`));
|
||||||
|
warnings.forEach((item) => console.warn(`WARN ${item}`));
|
||||||
|
failures.forEach((item) => console.error(`FAIL ${item}`));
|
||||||
|
|
||||||
|
console.log(`summary: passed=${passes.length} warnings=${warnings.length} failed=${failures.length}`);
|
||||||
|
|
||||||
|
if (failures.length) process.exit(1);
|
||||||
|
|
@ -0,0 +1,183 @@
|
||||||
|
#!/usr/bin/env sh
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
|
||||||
|
PLATFORM_ROOT=$(CDPATH= cd -- "$SCRIPT_DIR/../.." && pwd)
|
||||||
|
INFRA_DIR="$PLATFORM_ROOT/infra"
|
||||||
|
ASSISTANT_DIR="$PLATFORM_ROOT/services/ai-workspace-assistant"
|
||||||
|
HUB_DIR="$PLATFORM_ROOT/services/ai-workspace-hub"
|
||||||
|
ONTOLOGY_DIR="$PLATFORM_ROOT/services/ontology-core"
|
||||||
|
|
||||||
|
COMPOSE_ENV="${COMPOSE_ENV:-$INFRA_DIR/.env}"
|
||||||
|
if [ ! -f "$COMPOSE_ENV" ]; then
|
||||||
|
COMPOSE_ENV="$INFRA_DIR/.env.example"
|
||||||
|
fi
|
||||||
|
|
||||||
|
REQUIRE_RUNTIME="${REQUIRE_RUNTIME:-0}"
|
||||||
|
REQUIRE_REMOTE_WORKER_RELAY="${REQUIRE_REMOTE_WORKER_RELAY:-0}"
|
||||||
|
|
||||||
|
passed=0
|
||||||
|
failed=0
|
||||||
|
skipped=0
|
||||||
|
|
||||||
|
section() {
|
||||||
|
printf '\n== %s ==\n' "$1"
|
||||||
|
}
|
||||||
|
|
||||||
|
pass() {
|
||||||
|
passed=$((passed + 1))
|
||||||
|
printf 'PASS %s\n' "$1"
|
||||||
|
}
|
||||||
|
|
||||||
|
fail() {
|
||||||
|
failed=$((failed + 1))
|
||||||
|
printf 'FAIL %s\n' "$1" >&2
|
||||||
|
}
|
||||||
|
|
||||||
|
skip() {
|
||||||
|
skipped=$((skipped + 1))
|
||||||
|
printf 'SKIP %s\n' "$1"
|
||||||
|
}
|
||||||
|
|
||||||
|
run_cmd() {
|
||||||
|
label="$1"
|
||||||
|
shift
|
||||||
|
printf '\n$ %s\n' "$label"
|
||||||
|
set +e
|
||||||
|
"$@"
|
||||||
|
status=$?
|
||||||
|
set -e
|
||||||
|
if [ "$status" -eq 0 ]; then
|
||||||
|
pass "$label"
|
||||||
|
else
|
||||||
|
fail "$label (exit $status)"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
run_sh() {
|
||||||
|
label="$1"
|
||||||
|
dir="$2"
|
||||||
|
command="$3"
|
||||||
|
printf '\n$ %s\n' "$label"
|
||||||
|
set +e
|
||||||
|
(cd "$dir" && sh -lc "$command")
|
||||||
|
status=$?
|
||||||
|
set -e
|
||||||
|
if [ "$status" -eq 0 ]; then
|
||||||
|
pass "$label"
|
||||||
|
else
|
||||||
|
fail "$label (exit $status)"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
run_sh_quiet() {
|
||||||
|
label="$1"
|
||||||
|
dir="$2"
|
||||||
|
command="$3"
|
||||||
|
output_file=$(mktemp "${TMPDIR:-/tmp}/nodedc-check.XXXXXX")
|
||||||
|
printf '\n$ %s\n' "$label"
|
||||||
|
set +e
|
||||||
|
(cd "$dir" && sh -lc "$command") > "$output_file" 2>&1
|
||||||
|
status=$?
|
||||||
|
set -e
|
||||||
|
if [ "$status" -eq 0 ]; then
|
||||||
|
pass "$label"
|
||||||
|
rm -f "$output_file"
|
||||||
|
else
|
||||||
|
cat "$output_file"
|
||||||
|
rm -f "$output_file"
|
||||||
|
fail "$label (exit $status)"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
section "Static syntax"
|
||||||
|
run_cmd "Hub server syntax" node --check "$HUB_DIR/src/server.mjs"
|
||||||
|
run_cmd "Assistant server syntax" node --check "$ASSISTANT_DIR/src/server.mjs"
|
||||||
|
run_cmd "Local environment safety checker syntax" node --check "$INFRA_DIR/scripts/check-local-environment-safety.mjs"
|
||||||
|
run_sh "Shell scripts syntax" "$PLATFORM_ROOT" \
|
||||||
|
"sh -n infra/scripts/init-dev-env.sh infra/scripts/check-local-test-system.sh infra/scripts/check-ai-workspace-topology.sh infra/scripts/check-ai-workspace-config-contract.sh infra/scripts/check-ai-workspace-release-gates.sh"
|
||||||
|
if command -v bash >/dev/null 2>&1; then
|
||||||
|
run_sh "Synology hub-only deploy scripts syntax" "$PLATFORM_ROOT" \
|
||||||
|
"bash -n infra/synology/sync-ai-hub-relay.sh infra/synology/apply-ai-hub-relay.sh"
|
||||||
|
else
|
||||||
|
skip "Synology hub-only deploy scripts syntax (bash not installed)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
section "Environment contracts"
|
||||||
|
run_cmd "Local environment safety" node "$INFRA_DIR/scripts/check-local-environment-safety.mjs"
|
||||||
|
run_cmd "AI Workspace config contract" "$INFRA_DIR/scripts/check-ai-workspace-config-contract.sh"
|
||||||
|
|
||||||
|
section "Compose boundary"
|
||||||
|
if command -v docker >/dev/null 2>&1; then
|
||||||
|
compose_tmp=$(mktemp "${TMPDIR:-/tmp}/nodedc-local-compose.XXXXXX")
|
||||||
|
if docker compose --env-file "$COMPOSE_ENV" -f "$INFRA_DIR/docker-compose.dev.yml" config > "$compose_tmp"; then
|
||||||
|
pass "Local compose config renders"
|
||||||
|
else
|
||||||
|
fail "Local compose config renders"
|
||||||
|
fi
|
||||||
|
rm -f "$compose_tmp"
|
||||||
|
|
||||||
|
if grep -Fq "env_file:" "$INFRA_DIR/docker-compose.dev.yml"; then
|
||||||
|
fail "Local compose must not inject the full .env into services"
|
||||||
|
else
|
||||||
|
pass "Local compose uses explicit service environment only"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
skip "Docker compose config render (docker not installed)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
section "AI Workspace smokes"
|
||||||
|
run_sh_quiet "Assistant run-profile smoke" "$ASSISTANT_DIR" "npm run smoke:run-profile"
|
||||||
|
if [ -d "$ONTOLOGY_DIR" ]; then
|
||||||
|
run_sh_quiet "Ontology assistant caller smoke" "$ONTOLOGY_DIR" "npm run smoke:assistant-caller"
|
||||||
|
else
|
||||||
|
skip "Ontology assistant caller smoke (missing $ONTOLOGY_DIR)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
section "Runtime classification"
|
||||||
|
topology_tmp=$(mktemp "${TMPDIR:-/tmp}/nodedc-topology.XXXXXX")
|
||||||
|
set +e
|
||||||
|
(cd "$PLATFORM_ROOT" && infra/scripts/check-ai-workspace-topology.sh) > "$topology_tmp"
|
||||||
|
topology_status=$?
|
||||||
|
set -e
|
||||||
|
cat "$topology_tmp"
|
||||||
|
classification=$(awk -F= '/^classification=/{print $2; exit}' "$topology_tmp")
|
||||||
|
rm -f "$topology_tmp"
|
||||||
|
|
||||||
|
if [ "$topology_status" -eq 0 ]; then
|
||||||
|
pass "Runtime topology classified as ${classification:-unknown}"
|
||||||
|
else
|
||||||
|
fail "Runtime topology classification failed (exit $topology_status)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$REQUIRE_RUNTIME" = "1" ]; then
|
||||||
|
case "$classification" in
|
||||||
|
true-local-e2e|tunnel-local-e2e|local-remote-worker-relay)
|
||||||
|
pass "Runtime requirement satisfied"
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
fail "Runtime requirement not satisfied (classification=${classification:-unknown})"
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$REQUIRE_REMOTE_WORKER_RELAY" = "1" ]; then
|
||||||
|
case "$classification" in
|
||||||
|
local-remote-worker-relay)
|
||||||
|
pass "Remote worker relay requirement satisfied"
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
fail "Remote worker relay requirement not satisfied (classification=${classification:-unknown})"
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
fi
|
||||||
|
|
||||||
|
section "Git hygiene"
|
||||||
|
run_sh "Platform diff hygiene" "$PLATFORM_ROOT" "git diff --check"
|
||||||
|
|
||||||
|
section "Summary"
|
||||||
|
printf 'passed=%s failed=%s skipped=%s\n' "$passed" "$failed" "$skipped"
|
||||||
|
|
||||||
|
if [ "$failed" -ne 0 ]; then
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
@ -16,6 +16,7 @@ rand() {
|
||||||
|
|
||||||
cat > "$ENV_FILE" <<EOF
|
cat > "$ENV_FILE" <<EOF
|
||||||
# domains
|
# domains
|
||||||
|
NODEDC_ENV=local
|
||||||
AUTH_DOMAIN=auth.local.nodedc
|
AUTH_DOMAIN=auth.local.nodedc
|
||||||
LAUNCHER_DOMAIN=launcher.local.nodedc
|
LAUNCHER_DOMAIN=launcher.local.nodedc
|
||||||
TASK_DOMAIN=task.local.nodedc
|
TASK_DOMAIN=task.local.nodedc
|
||||||
|
|
@ -61,8 +62,9 @@ COOKIE_DOMAIN=.local.nodedc
|
||||||
COOKIE_SECURE=false
|
COOKIE_SECURE=false
|
||||||
|
|
||||||
# AI Workspace shared registry and Hub.
|
# AI Workspace shared registry and Hub.
|
||||||
# Generated local env uses the deployed relay by default. This is suitable for
|
# Generated local env may use the deployed AI Hub only as a relay for remote
|
||||||
# UI/contract smoke, not proof of local live-worker write-path e2e.
|
# Codex workers. Launcher/Engine/Ops/Auth downstream URLs must remain local.
|
||||||
|
# For Tailscale/ngrok-style relay URLs, set NODEDC_ENV=tunnel-local-e2e deliberately.
|
||||||
AI_WORKSPACE_PG_DB=nodedc_ai_workspace
|
AI_WORKSPACE_PG_DB=nodedc_ai_workspace
|
||||||
AI_WORKSPACE_PG_USER=nodedc_ai_workspace
|
AI_WORKSPACE_PG_USER=nodedc_ai_workspace
|
||||||
AI_WORKSPACE_PG_PASS=$(rand 36)
|
AI_WORKSPACE_PG_PASS=$(rand 36)
|
||||||
|
|
@ -70,6 +72,8 @@ AI_WORKSPACE_ASSISTANT_TOKEN=$(openssl rand -hex 48 | tr -d '\n')
|
||||||
AI_WORKSPACE_OPS_ENTITLEMENT_URL=http://host.docker.internal:4100/api/internal/v1/ai-workspace/entitlements
|
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_TOKEN=replace-with-ops-agent-gateway-internal-token
|
||||||
AI_WORKSPACE_OPS_ENTITLEMENT_REQUIRED=false
|
AI_WORKSPACE_OPS_ENTITLEMENT_REQUIRED=false
|
||||||
|
AI_WORKSPACE_ASSISTANT_ACTION_RELAY_ENABLED=true
|
||||||
|
AI_WORKSPACE_ASSISTANT_ACTION_RELAY_ID=local-dev
|
||||||
AI_WORKSPACE_HUB_TOKEN=$(openssl rand -hex 48 | tr -d '\n')
|
AI_WORKSPACE_HUB_TOKEN=$(openssl rand -hex 48 | tr -d '\n')
|
||||||
AI_WORKSPACE_HUB_HOST_BIND=127.0.0.1:18081
|
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_PUBLIC_URL=wss://ai-hub.nodedc.ru/api/ai-workspace/hub
|
||||||
|
|
|
||||||
|
|
@ -171,6 +171,44 @@ TASKER_SYNC_SOURCE=1 ./infra/synology/deploy-current.sh
|
||||||
|
|
||||||
Если emergency-fix был сделан прямо на Synology в этих env-файлах, перенести sanitized-значение в `.env.synology.example`/docs, а секрет оставить только в live env.
|
Если emergency-fix был сделан прямо на Synology в этих env-файлах, перенести sanitized-значение в `.env.synology.example`/docs, а секрет оставить только в live env.
|
||||||
|
|
||||||
|
## AI Hub relay-only deploy
|
||||||
|
|
||||||
|
Для локального тестирования с внешней Codex-машиной нельзя деплоить Launcher, Engine, Ops, Authentik или Tasker. Единственная допустимая удалённая точка в этой схеме — `ai-workspace-hub`, потому что удалённый worker должен иметь публичный WebSocket/HTTP relay.
|
||||||
|
|
||||||
|
С Mac, при смонтированном `/Volumes/docker`, синхронизировать только Hub source, compose и узкий apply-script:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /Users/dcconstructions/Downloads/mnt/NODEDC/platform
|
||||||
|
NAS_ROOT=/Volumes/docker/nodedc-platform ./infra/synology/sync-ai-hub-relay.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
На Synology применить только `ai-workspace-hub`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ssh dctouch@100.109.216.21 'cd /volume1/docker/nodedc-platform/platform && sudo bash apply-ai-hub-relay.sh'
|
||||||
|
```
|
||||||
|
|
||||||
|
Этот путь намеренно делает только:
|
||||||
|
|
||||||
|
- build `nodedc/ai-workspace-hub:local`;
|
||||||
|
- `docker compose up -d --no-deps --force-recreate ai-workspace-hub`;
|
||||||
|
- container health check;
|
||||||
|
- проверку `/api/ai-workspace/hub/v1/assistant-relays/<relayId>/poll`;
|
||||||
|
- public route check через `https://ai-hub.nodedc.ru`.
|
||||||
|
|
||||||
|
Он не меняет `.env.synology`, не синхронизирует Launcher/Tasker/Ops source, не пересоздаёт Authentik, reverse-proxy, базы, storage или product containers.
|
||||||
|
|
||||||
|
После успешного relay-only deploy локальная цепочка для теста должна быть такой:
|
||||||
|
|
||||||
|
```text
|
||||||
|
local Launcher/Engine/Ops/Auth/Assistant
|
||||||
|
-> deployed AI Hub relay: https://ai-hub.nodedc.ru
|
||||||
|
-> remote Codex worker
|
||||||
|
-> deployed AI Hub relay
|
||||||
|
-> local Assistant relay poll
|
||||||
|
-> local product services
|
||||||
|
```
|
||||||
|
|
||||||
## Лёгкое обновление Hub / Launcher
|
## Лёгкое обновление Hub / Launcher
|
||||||
|
|
||||||
Для правок только в Launcher/BFF не пересоздавать Authentik, reverse-proxy, Tasker и Ops Agents:
|
Для правок только в Launcher/BFF не пересоздавать Authentik, reverse-proxy, Tasker и Ops Agents:
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,218 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
DOCKER_BIN="${DOCKER_BIN:-/usr/local/bin/docker}"
|
||||||
|
PLATFORM_DIR="${PLATFORM_DIR:-/volume1/docker/nodedc-platform/platform}"
|
||||||
|
ENV_FILE="${ENV_FILE:-${PLATFORM_DIR}/${NODEDC_SYNOLOGY_ENV_FILE:-.env.synology}}"
|
||||||
|
COMPOSE_FILE="${COMPOSE_FILE:-${PLATFORM_DIR}/docker-compose.platform-http.yml}"
|
||||||
|
HUB_DIR="${HUB_DIR:-${PLATFORM_DIR}/ai-workspace-hub}"
|
||||||
|
RELAY_ID="${AI_WORKSPACE_ASSISTANT_ACTION_RELAY_ID:-local-dev}"
|
||||||
|
PUBLIC_VERIFY_BASE_URL="${AI_WORKSPACE_HUB_VERIFY_BASE_URL:-https://ai-hub.nodedc.ru}"
|
||||||
|
VERIFY_PUBLIC="${VERIFY_PUBLIC:-1}"
|
||||||
|
|
||||||
|
if [[ ! -x "${DOCKER_BIN}" ]]; then
|
||||||
|
DOCKER_BIN="$(command -v docker)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
read_env_value() {
|
||||||
|
local file="$1"
|
||||||
|
local key="$2"
|
||||||
|
awk -F= -v key="$key" '
|
||||||
|
$0 ~ "^[[:space:]]*#" { next }
|
||||||
|
$1 == key {
|
||||||
|
value = substr($0, index($0, "=") + 1)
|
||||||
|
gsub(/^[[:space:]]+|[[:space:]]+$/, "", value)
|
||||||
|
gsub(/^"|"$/, "", value)
|
||||||
|
print value
|
||||||
|
exit
|
||||||
|
}
|
||||||
|
' "$file"
|
||||||
|
}
|
||||||
|
|
||||||
|
require_file() {
|
||||||
|
local path="$1"
|
||||||
|
local label="$2"
|
||||||
|
if [[ ! -f "${path}" ]]; then
|
||||||
|
echo "${label} missing: ${path}" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
require_dir() {
|
||||||
|
local path="$1"
|
||||||
|
local label="$2"
|
||||||
|
if [[ ! -d "${path}" ]]; then
|
||||||
|
echo "${label} missing: ${path}" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
show_container_debug() {
|
||||||
|
local container_id="$1"
|
||||||
|
echo "== ai-workspace-hub debug =="
|
||||||
|
"${DOCKER_BIN}" ps --filter "id=${container_id}" --format 'table {{.Names}}\t{{.Status}}\t{{.Ports}}' || true
|
||||||
|
echo "-- recent logs --"
|
||||||
|
"${DOCKER_BIN}" logs --tail 120 "${container_id}" || true
|
||||||
|
}
|
||||||
|
|
||||||
|
check_container_health() {
|
||||||
|
local container_id="$1"
|
||||||
|
"${DOCKER_BIN}" exec "${container_id}" node -e '
|
||||||
|
fetch("http://127.0.0.1:18081/healthz")
|
||||||
|
.then(async (response) => {
|
||||||
|
const body = await response.text();
|
||||||
|
console.log(body);
|
||||||
|
process.exit(response.ok ? 0 : 1);
|
||||||
|
})
|
||||||
|
.catch(() => process.exit(2));
|
||||||
|
'
|
||||||
|
}
|
||||||
|
|
||||||
|
check_container_relay_route() {
|
||||||
|
local container_id="$1"
|
||||||
|
"${DOCKER_BIN}" exec \
|
||||||
|
-e RELAY_ID="${RELAY_ID}" \
|
||||||
|
-e RELAY_TOKEN="${relay_token}" \
|
||||||
|
"${container_id}" \
|
||||||
|
node -e '
|
||||||
|
const relayId = encodeURIComponent(process.env.RELAY_ID || "local-dev");
|
||||||
|
const token = process.env.RELAY_TOKEN || "";
|
||||||
|
fetch(`http://127.0.0.1:18081/api/ai-workspace/hub/v1/assistant-relays/${relayId}/poll`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
authorization: `Bearer ${token}`,
|
||||||
|
accept: "application/json",
|
||||||
|
"content-type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ limit: 1, timeoutMs: 1 }),
|
||||||
|
})
|
||||||
|
.then(async (response) => {
|
||||||
|
const text = await response.text();
|
||||||
|
if (response.status !== 200) {
|
||||||
|
console.error(`relay route failed: status=${response.status} body=${text.slice(0, 300)}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
let payload = {};
|
||||||
|
try {
|
||||||
|
payload = JSON.parse(text);
|
||||||
|
} catch {}
|
||||||
|
console.log(JSON.stringify({
|
||||||
|
ok: payload.ok === true,
|
||||||
|
relayId: payload.relayId,
|
||||||
|
calls: Array.isArray(payload.calls) ? payload.calls.length : null,
|
||||||
|
}));
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.error(error);
|
||||||
|
process.exit(2);
|
||||||
|
});
|
||||||
|
'
|
||||||
|
}
|
||||||
|
|
||||||
|
echo "== ai hub relay preflight =="
|
||||||
|
require_dir "${PLATFORM_DIR}" "Platform directory"
|
||||||
|
require_dir "${HUB_DIR}" "AI Workspace Hub source directory"
|
||||||
|
require_file "${ENV_FILE}" "Synology env file"
|
||||||
|
require_file "${COMPOSE_FILE}" "Synology compose file"
|
||||||
|
require_file "${HUB_DIR}/Dockerfile" "AI Workspace Hub Dockerfile"
|
||||||
|
require_file "${HUB_DIR}/package.json" "AI Workspace Hub package.json"
|
||||||
|
require_file "${HUB_DIR}/src/server.mjs" "AI Workspace Hub server"
|
||||||
|
|
||||||
|
hub_token="${AI_WORKSPACE_HUB_TOKEN:-$(read_env_value "${ENV_FILE}" AI_WORKSPACE_HUB_TOKEN || true)}"
|
||||||
|
internal_token="${NODEDC_INTERNAL_ACCESS_TOKEN:-$(read_env_value "${ENV_FILE}" NODEDC_INTERNAL_ACCESS_TOKEN || true)}"
|
||||||
|
relay_token="${hub_token:-${internal_token}}"
|
||||||
|
if [[ -z "${relay_token}" ]]; then
|
||||||
|
echo "No AI_WORKSPACE_HUB_TOKEN or NODEDC_INTERNAL_ACCESS_TOKEN found for relay verification." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
cd "${PLATFORM_DIR}"
|
||||||
|
|
||||||
|
if ! "${DOCKER_BIN}" compose --env-file "${ENV_FILE}" -f "${COMPOSE_FILE}" config --services \
|
||||||
|
| grep -qx 'ai-workspace-hub'; then
|
||||||
|
echo "Compose service ai-workspace-hub is not available." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "== build ai-workspace-hub only =="
|
||||||
|
"${DOCKER_BIN}" compose \
|
||||||
|
--env-file "${ENV_FILE}" \
|
||||||
|
-f "${COMPOSE_FILE}" \
|
||||||
|
build ai-workspace-hub
|
||||||
|
|
||||||
|
echo "== recreate ai-workspace-hub only =="
|
||||||
|
"${DOCKER_BIN}" compose \
|
||||||
|
--env-file "${ENV_FILE}" \
|
||||||
|
-f "${COMPOSE_FILE}" \
|
||||||
|
up -d --no-deps --force-recreate ai-workspace-hub
|
||||||
|
|
||||||
|
container_id="$(
|
||||||
|
"${DOCKER_BIN}" compose \
|
||||||
|
--env-file "${ENV_FILE}" \
|
||||||
|
-f "${COMPOSE_FILE}" \
|
||||||
|
ps -q ai-workspace-hub
|
||||||
|
)"
|
||||||
|
if [[ -z "${container_id}" ]]; then
|
||||||
|
echo "Cannot resolve ai-workspace-hub container id after apply." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "== container health =="
|
||||||
|
health_ok=0
|
||||||
|
for attempt in $(seq 1 30); do
|
||||||
|
if check_container_health "${container_id}"; then
|
||||||
|
health_ok=1
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
echo "ai-workspace-hub-not-ready attempt=${attempt}/30"
|
||||||
|
sleep 2
|
||||||
|
done
|
||||||
|
if [[ "${health_ok}" != "1" ]]; then
|
||||||
|
show_container_debug "${container_id}"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "== container relay route =="
|
||||||
|
relay_ok=0
|
||||||
|
for attempt in $(seq 1 10); do
|
||||||
|
if check_container_relay_route "${container_id}"; then
|
||||||
|
relay_ok=1
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
echo "ai-workspace-hub-relay-not-ready attempt=${attempt}/10"
|
||||||
|
sleep 2
|
||||||
|
done
|
||||||
|
if [[ "${relay_ok}" != "1" ]]; then
|
||||||
|
show_container_debug "${container_id}"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "${VERIFY_PUBLIC}" == "1" ]]; then
|
||||||
|
if command -v curl >/dev/null 2>&1; then
|
||||||
|
echo "== public relay route =="
|
||||||
|
public_body="$(mktemp -t ai-hub-relay-public.XXXXXX)"
|
||||||
|
public_status="$(
|
||||||
|
curl -k -sS -m 20 \
|
||||||
|
-o "${public_body}" \
|
||||||
|
-w '%{http_code}' \
|
||||||
|
-X POST "${PUBLIC_VERIFY_BASE_URL%/}/api/ai-workspace/hub/v1/assistant-relays/${RELAY_ID}/poll" \
|
||||||
|
-H "Authorization: Bearer ${relay_token}" \
|
||||||
|
-H 'Accept: application/json' \
|
||||||
|
-H 'Content-Type: application/json' \
|
||||||
|
--data '{"limit":1,"timeoutMs":1}'
|
||||||
|
)"
|
||||||
|
if [[ "${public_status}" != "200" ]]; then
|
||||||
|
echo "public relay route failed: status=${public_status}" >&2
|
||||||
|
head -c 300 "${public_body}" >&2 || true
|
||||||
|
echo >&2
|
||||||
|
rm -f "${public_body}"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
rm -f "${public_body}"
|
||||||
|
echo "public-relay-route-ok"
|
||||||
|
else
|
||||||
|
echo "curl is not available; skipped public relay route check."
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "ai-hub-relay-apply-ok"
|
||||||
|
|
@ -64,6 +64,14 @@ rsync -av "${RSYNC_METADATA_ARGS[@]}" --delete \
|
||||||
"${PLATFORM_REPO}/services/ai-workspace-assistant/" \
|
"${PLATFORM_REPO}/services/ai-workspace-assistant/" \
|
||||||
"${NAS_ROOT}/platform/ai-workspace-assistant/"
|
"${NAS_ROOT}/platform/ai-workspace-assistant/"
|
||||||
|
|
||||||
|
mkdir -p "${NAS_ROOT}/platform/ai-workspace-assistant/ontology-core"
|
||||||
|
rsync -av "${RSYNC_METADATA_ARGS[@]}" --delete \
|
||||||
|
--exclude='node_modules/' \
|
||||||
|
--exclude='.env' \
|
||||||
|
--exclude='.env.*' \
|
||||||
|
"${PLATFORM_REPO}/services/ontology-core/" \
|
||||||
|
"${NAS_ROOT}/platform/ai-workspace-assistant/ontology-core/"
|
||||||
|
|
||||||
if [[ "${SYNC_AUTHENTIK_TEMPLATES}" == "1" ]]; then
|
if [[ "${SYNC_AUTHENTIK_TEMPLATES}" == "1" ]]; then
|
||||||
mkdir -p "${NAS_ROOT}/authentik/custom-templates"
|
mkdir -p "${NAS_ROOT}/authentik/custom-templates"
|
||||||
rsync -av "${RSYNC_METADATA_ARGS[@]}" --delete \
|
rsync -av "${RSYNC_METADATA_ARGS[@]}" --delete \
|
||||||
|
|
|
||||||
|
|
@ -142,6 +142,7 @@ services:
|
||||||
AI_WORKSPACE_HUB_INTERNAL_URL: ${AI_WORKSPACE_HUB_INTERNAL_URL:-https://ai-hub.nodedc.ru}
|
AI_WORKSPACE_HUB_INTERNAL_URL: ${AI_WORKSPACE_HUB_INTERNAL_URL:-https://ai-hub.nodedc.ru}
|
||||||
AI_WORKSPACE_HUB_TOKEN: ${AI_WORKSPACE_HUB_TOKEN:?ai workspace hub token required}
|
AI_WORKSPACE_HUB_TOKEN: ${AI_WORKSPACE_HUB_TOKEN:?ai workspace hub token required}
|
||||||
AI_WORKSPACE_HUB_FALLBACK_URLS: ${AI_WORKSPACE_HUB_FALLBACK_URLS:-}
|
AI_WORKSPACE_HUB_FALLBACK_URLS: ${AI_WORKSPACE_HUB_FALLBACK_URLS:-}
|
||||||
|
NDC_ONTOLOGY_LAUNCHER_BASE_URL: http://launcher:5173
|
||||||
expose:
|
expose:
|
||||||
- "18082"
|
- "18082"
|
||||||
ports:
|
ports:
|
||||||
|
|
@ -165,6 +166,7 @@ services:
|
||||||
PORT: 18081
|
PORT: 18081
|
||||||
AI_WORKSPACE_HUB_TOKEN: ${AI_WORKSPACE_HUB_TOKEN:?ai workspace hub token required}
|
AI_WORKSPACE_HUB_TOKEN: ${AI_WORKSPACE_HUB_TOKEN:?ai workspace hub token required}
|
||||||
NODEDC_INTERNAL_ACCESS_TOKEN: ${NODEDC_INTERNAL_ACCESS_TOKEN:-}
|
NODEDC_INTERNAL_ACCESS_TOKEN: ${NODEDC_INTERNAL_ACCESS_TOKEN:-}
|
||||||
|
NODEDC_AI_WORKSPACE_ASSISTANT_URL: http://ai-workspace-assistant:18082
|
||||||
AI_WORKSPACE_HUB_WS_PATH: /api/ai-workspace/hub
|
AI_WORKSPACE_HUB_WS_PATH: /api/ai-workspace/hub
|
||||||
expose:
|
expose:
|
||||||
- "18081"
|
- "18081"
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,56 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
PLATFORM_REPO="$(cd -- "${SCRIPT_DIR}/../.." && pwd)"
|
||||||
|
NAS_ROOT="${NAS_ROOT:-/Volumes/docker/nodedc-platform}"
|
||||||
|
SSH_TARGET="${SSH_TARGET:-dctouch@100.109.216.21}"
|
||||||
|
|
||||||
|
RSYNC_METADATA_ARGS=()
|
||||||
|
if [[ "${RSYNC_PRESERVE_METADATA:-0}" != "1" ]]; then
|
||||||
|
RSYNC_METADATA_ARGS=(--no-times --no-perms --no-owner --no-group)
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ ! -d "${NAS_ROOT}" ]]; then
|
||||||
|
echo "NAS_ROOT not found: ${NAS_ROOT}" >&2
|
||||||
|
echo "Set NAS_ROOT=/path/to/nodedc-platform when the Synology share is mounted elsewhere." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ ! -f "${PLATFORM_REPO}/infra/synology/docker-compose.platform-http.yml" ]]; then
|
||||||
|
echo "Synology compose is missing in local repo." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ ! -d "${PLATFORM_REPO}/services/ai-workspace-hub" ]]; then
|
||||||
|
echo "AI Workspace Hub source is missing in local repo." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
mkdir -p "${NAS_ROOT}/platform"
|
||||||
|
|
||||||
|
rsync -av "${RSYNC_METADATA_ARGS[@]}" \
|
||||||
|
"${PLATFORM_REPO}/infra/synology/docker-compose.platform-http.yml" \
|
||||||
|
"${NAS_ROOT}/platform/docker-compose.platform-http.yml"
|
||||||
|
|
||||||
|
rsync -av "${RSYNC_METADATA_ARGS[@]}" \
|
||||||
|
"${PLATFORM_REPO}/infra/synology/apply-ai-hub-relay.sh" \
|
||||||
|
"${NAS_ROOT}/platform/apply-ai-hub-relay.sh"
|
||||||
|
|
||||||
|
mkdir -p "${NAS_ROOT}/platform/ai-workspace-hub"
|
||||||
|
rsync -av "${RSYNC_METADATA_ARGS[@]}" --delete \
|
||||||
|
--exclude='node_modules/' \
|
||||||
|
--exclude='.env' \
|
||||||
|
--exclude='.env.*' \
|
||||||
|
"${PLATFORM_REPO}/services/ai-workspace-hub/" \
|
||||||
|
"${NAS_ROOT}/platform/ai-workspace-hub/"
|
||||||
|
|
||||||
|
cat <<EOF
|
||||||
|
|
||||||
|
AI Hub relay files synced to NAS mount.
|
||||||
|
|
||||||
|
Apply only AI Workspace Hub on Synology:
|
||||||
|
|
||||||
|
ssh ${SSH_TARGET} 'cd /volume1/docker/nodedc-platform/platform && sudo bash apply-ai-hub-relay.sh'
|
||||||
|
|
||||||
|
EOF
|
||||||
|
|
@ -6,6 +6,7 @@ COPY package*.json ./
|
||||||
RUN npm ci --omit=dev
|
RUN npm ci --omit=dev
|
||||||
|
|
||||||
COPY src ./src
|
COPY src ./src
|
||||||
|
COPY ontology-core /ontology-core
|
||||||
|
|
||||||
ENV NODE_ENV=production
|
ENV NODE_ENV=production
|
||||||
EXPOSE 18082
|
EXPOSE 18082
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,15 @@
|
||||||
|
FROM node:22-alpine
|
||||||
|
|
||||||
|
WORKDIR /app/services/ai-workspace-assistant
|
||||||
|
|
||||||
|
COPY ai-workspace-assistant/package*.json ./
|
||||||
|
RUN npm ci --omit=dev
|
||||||
|
|
||||||
|
COPY ai-workspace-assistant/src ./src
|
||||||
|
COPY ai-workspace-assistant/docs ./docs
|
||||||
|
COPY ontology-core /app/services/ontology-core
|
||||||
|
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
EXPOSE 18082
|
||||||
|
|
||||||
|
CMD ["npm", "start"]
|
||||||
|
|
@ -53,6 +53,49 @@ POST /api/ai-workspace/assistant/v1/threads/:threadId/messages
|
||||||
GET /api/ai-workspace/assistant/v1/threads/:threadId/messages
|
GET /api/ai-workspace/assistant/v1/threads/:threadId/messages
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Assistant actions:
|
||||||
|
|
||||||
|
```text
|
||||||
|
POST /api/ai-workspace/assistant/v1/actions
|
||||||
|
```
|
||||||
|
|
||||||
|
This is the platform entrypoint for ontology-backed assistant tool calls. Natural-language chat dispatch still goes through the selected assistant model/executor first; `ontology-core` is the deterministic policy/action layer the assistant calls after it has interpreted the user's intent.
|
||||||
|
|
||||||
|
Request shape:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"phase": "preview",
|
||||||
|
"input": {
|
||||||
|
"actionId": "hub.user.block",
|
||||||
|
"targetUserId": "user_123",
|
||||||
|
"confirmed": true,
|
||||||
|
"idempotencyKey": "hub.user.block:user_admin:user_123"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
For write execution, first call `phase=preview`, show the returned `action.confirmation` to the user, then call `phase=execute` with `confirmationToken`. The final mutation still goes through the app-owned Launcher admin API guards; this service only routes the registered action.
|
||||||
|
|
||||||
|
Launcher action gateway configuration:
|
||||||
|
|
||||||
|
```text
|
||||||
|
NDC_ONTOLOGY_LAUNCHER_BASE_URL=http://launcher.local.nodedc
|
||||||
|
NDC_LAUNCHER_INTERNAL_ACCESS_TOKEN=...
|
||||||
|
```
|
||||||
|
|
||||||
|
If `NDC_LAUNCHER_INTERNAL_ACCESS_TOKEN` is not set, the service falls back to `NODEDC_INTERNAL_ACCESS_TOKEN`.
|
||||||
|
|
||||||
|
The first HUB action ids exposed to assistants are:
|
||||||
|
|
||||||
|
- `hub.user.read_admin_summary`
|
||||||
|
- `hub.invite.list_pending`
|
||||||
|
- `hub.access_request.list_pending`
|
||||||
|
|
||||||
|
Read actions can be executed without user confirmation after the assistant has selected the structured action. Privileged/write actions still require explicit preview, user confirmation, and execute.
|
||||||
|
|
||||||
|
App BFFs should pass active scope facts in action input (`clientId`, `coreAssistantRole`, `membershipRole`, `membershipStatus`) so policy stays data-driven instead of prompt-driven.
|
||||||
|
|
||||||
Supported initial surfaces:
|
Supported initial surfaces:
|
||||||
|
|
||||||
- `engine`
|
- `engine`
|
||||||
|
|
@ -71,6 +114,8 @@ Supported initial tool packs:
|
||||||
|
|
||||||
Every dispatch builds an `ai-workspace.run-profile.v1` payload. The raw profile is sent only to the bridge worker; public API responses and run metadata keep MCP headers redacted.
|
Every dispatch builds an `ai-workspace.run-profile.v1` payload. The raw profile is sent only to the bridge worker; public API responses and run metadata keep MCP headers redacted.
|
||||||
|
|
||||||
|
The canonical cross-surface execution contract is [AI Workspace Protocol v1](./docs/AI_WORKSPACE_PROTOCOL_V1.md). It defines layer ownership, run profile shape, tool manifests, worker boundaries, Hub boundaries, app adapter rules, and the migration path from the current scaffold.
|
||||||
|
|
||||||
The worker uses `runProfile.toolProfile.mcpServers` to create an isolated per-run `CODEX_HOME/config.toml`. Install-time MCP config remains only as a legacy fallback when no dynamic profile is available.
|
The worker uses `runProfile.toolProfile.mcpServers` to create an isolated per-run `CODEX_HOME/config.toml`. Install-time MCP config remains only as a legacy fallback when no dynamic profile is available.
|
||||||
|
|
||||||
Runtime deploy readiness is tracked in [TEST_MATRIX.md](./TEST_MATRIX.md). The `smoke:run-profile` script covers the entitlement adapter to run profile contract and public secret redaction.
|
Runtime deploy readiness is tracked in [TEST_MATRIX.md](./TEST_MATRIX.md). The `smoke:run-profile` script covers the entitlement adapter to run profile contract and public secret redaction.
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,434 @@
|
||||||
|
# AI Workspace Protocol v1
|
||||||
|
|
||||||
|
Status: draft canonical contract.
|
||||||
|
Date: 2026-06-19
|
||||||
|
Owner: NODE.DC AI Workspace Assistant.
|
||||||
|
|
||||||
|
This document fixes the platform contract for cross-application assistant
|
||||||
|
execution. It exists to prevent assistant capabilities from spreading through
|
||||||
|
prompt-only rules, app-specific shortcuts, worker code, or transport glue.
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
AI Workspace Protocol v1 defines how a user request from any NODE.DC surface
|
||||||
|
is converted into a safe, auditable capability call across HUB, OPS, ENGINE,
|
||||||
|
and future platform applications.
|
||||||
|
|
||||||
|
The protocol must support hundreds of functional blocks without changing the
|
||||||
|
worker for every new feature.
|
||||||
|
|
||||||
|
## Layer Ownership
|
||||||
|
|
||||||
|
### Client Surface
|
||||||
|
|
||||||
|
Examples: ENGINE UI, OPS UI, HUB UI, future platform apps.
|
||||||
|
|
||||||
|
Owns:
|
||||||
|
|
||||||
|
- visible UI state;
|
||||||
|
- current surface context;
|
||||||
|
- active selected objects;
|
||||||
|
- user-facing preview and confirmation UI.
|
||||||
|
|
||||||
|
Must not own:
|
||||||
|
|
||||||
|
- final permission enforcement;
|
||||||
|
- platform action registry;
|
||||||
|
- backend actor identity;
|
||||||
|
- raw internal tokens.
|
||||||
|
|
||||||
|
### AI Workspace Assistant
|
||||||
|
|
||||||
|
Owns:
|
||||||
|
|
||||||
|
- assistant thread/session metadata;
|
||||||
|
- selected executor;
|
||||||
|
- run profile construction;
|
||||||
|
- tool manifest construction;
|
||||||
|
- action routing entrypoint;
|
||||||
|
- policy prompts derived from structured contracts;
|
||||||
|
- run-scoped entitlement resolution;
|
||||||
|
- public redaction of run diagnostics.
|
||||||
|
|
||||||
|
Must be the only platform layer that decides which assistant tools are exposed
|
||||||
|
to a run.
|
||||||
|
|
||||||
|
### Ontology Core
|
||||||
|
|
||||||
|
Owns:
|
||||||
|
|
||||||
|
- canonical entities and relations;
|
||||||
|
- aliases and resolver rules;
|
||||||
|
- assistant access policy;
|
||||||
|
- assistant action registry;
|
||||||
|
- risk policy;
|
||||||
|
- confirmation policy;
|
||||||
|
- app-owned adapter descriptors.
|
||||||
|
|
||||||
|
Must not own:
|
||||||
|
|
||||||
|
- HUB user source data;
|
||||||
|
- OPS card source data;
|
||||||
|
- ENGINE workflow source data;
|
||||||
|
- transport routing;
|
||||||
|
- worker execution.
|
||||||
|
|
||||||
|
### AI Hub
|
||||||
|
|
||||||
|
Owns:
|
||||||
|
|
||||||
|
- remote worker rendezvous;
|
||||||
|
- pairing;
|
||||||
|
- dispatch relay;
|
||||||
|
- message delivery;
|
||||||
|
- proxying to trusted internal services when configured.
|
||||||
|
|
||||||
|
Must not own:
|
||||||
|
|
||||||
|
- assistant action catalog;
|
||||||
|
- business permissions;
|
||||||
|
- prompt policy;
|
||||||
|
- domain-specific decisions.
|
||||||
|
|
||||||
|
### Worker
|
||||||
|
|
||||||
|
Owns:
|
||||||
|
|
||||||
|
- local/remote executor runtime;
|
||||||
|
- Codex/model process launch;
|
||||||
|
- per-run isolated config;
|
||||||
|
- tool relay based on the received manifest;
|
||||||
|
- event/result streaming.
|
||||||
|
|
||||||
|
Must not own:
|
||||||
|
|
||||||
|
- HUB/OPS/ENGINE action IDs as hardcoded product knowledge;
|
||||||
|
- role rules;
|
||||||
|
- app-level permission decisions;
|
||||||
|
- long-lived platform tokens;
|
||||||
|
- app-owned adapters.
|
||||||
|
|
||||||
|
### App API / MCP
|
||||||
|
|
||||||
|
Examples: Launcher/HUB API, OPS Gateway, ENGINE API.
|
||||||
|
|
||||||
|
Owns:
|
||||||
|
|
||||||
|
- source-of-truth data;
|
||||||
|
- object-level ACL;
|
||||||
|
- final enforcement;
|
||||||
|
- app-native audit;
|
||||||
|
- app-owned adapter routes.
|
||||||
|
|
||||||
|
Must reject invalid or forged requests even when AI Workspace allowed the plan.
|
||||||
|
|
||||||
|
## Canonical Flow
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart LR
|
||||||
|
U["User request"] --> C["Client surface context"]
|
||||||
|
C --> A["AI Workspace Assistant"]
|
||||||
|
A --> O["Ontology Core: resolve capability and policy"]
|
||||||
|
O --> A
|
||||||
|
A --> P["Run profile + tool manifest"]
|
||||||
|
P --> W["Worker or in-process executor"]
|
||||||
|
W --> R["Tool relay"]
|
||||||
|
R --> API["App API / MCP"]
|
||||||
|
API --> AUD["Audit + result"]
|
||||||
|
AUD --> A
|
||||||
|
A --> C
|
||||||
|
```
|
||||||
|
|
||||||
|
Read actions may execute after the assistant selects a structured action.
|
||||||
|
Write or privileged actions must use preview first, then explicit confirmation,
|
||||||
|
then execute. Destructive actions are not exposed as assistant capabilities.
|
||||||
|
|
||||||
|
## Run Profile
|
||||||
|
|
||||||
|
Schema version:
|
||||||
|
|
||||||
|
```text
|
||||||
|
ai-workspace.run-profile.v1
|
||||||
|
```
|
||||||
|
|
||||||
|
Required top-level fields:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"schemaVersion": "ai-workspace.run-profile.v1",
|
||||||
|
"runId": "uuid",
|
||||||
|
"createdAt": "2026-06-19T00:00:00.000Z",
|
||||||
|
"expiresAt": "2026-06-19T00:10:00.000Z",
|
||||||
|
"audience": "ai-workspace-worker",
|
||||||
|
"requestId": "uuid-or-nonce",
|
||||||
|
"owner": {},
|
||||||
|
"sourceSurface": "engine",
|
||||||
|
"activeContext": {},
|
||||||
|
"toolProfile": {},
|
||||||
|
"policyPrompt": "derived text",
|
||||||
|
"integrity": {}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Required rules:
|
||||||
|
|
||||||
|
- `expiresAt` must be short-lived.
|
||||||
|
- `requestId` must be unique enough for replay protection.
|
||||||
|
- `audience` must name the intended recipient class.
|
||||||
|
- `integrity` must contain a server-side hash/signature before production use.
|
||||||
|
- public diagnostics must redact tokens, headers, secrets, and raw internal URLs.
|
||||||
|
|
||||||
|
## Actor Claims
|
||||||
|
|
||||||
|
Actor claims are backend facts, not user text.
|
||||||
|
|
||||||
|
Canonical owner shape:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"ownerKey": "email:dcctouch@gmail.com",
|
||||||
|
"userId": "platform-user-id",
|
||||||
|
"email": "dcctouch@gmail.com",
|
||||||
|
"role": "root-admin",
|
||||||
|
"groups": ["nodedc:superadmin"]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
|
||||||
|
- client-supplied actor headers are not trusted directly;
|
||||||
|
- trusted App BFF or AI Workspace must resolve actor claims;
|
||||||
|
- target App API must verify the actor again against app-native state;
|
||||||
|
- app adapters may receive claims, but never treat them as the only authority.
|
||||||
|
|
||||||
|
## Tool Profile
|
||||||
|
|
||||||
|
Schema version:
|
||||||
|
|
||||||
|
```text
|
||||||
|
ai-workspace.tool-profile.v1
|
||||||
|
```
|
||||||
|
|
||||||
|
Shape:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"schemaVersion": "ai-workspace.tool-profile.v1",
|
||||||
|
"enabledToolPacks": ["engine", "ops", "ndc-agent-core"],
|
||||||
|
"mcpServers": [],
|
||||||
|
"assistantActions": {
|
||||||
|
"schemaVersion": "ai-workspace.assistant-actions.v1",
|
||||||
|
"endpoint": "/api/ai-workspace/assistant/v1/actions",
|
||||||
|
"actionIds": ["hub.user.read_admin_summary"],
|
||||||
|
"phases": ["preview", "execute"],
|
||||||
|
"tokenRef": "run-token:assistant-actions"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
|
||||||
|
- action IDs come from AI Workspace Assistant + Ontology Core, not from ENGINE
|
||||||
|
local routes or worker source code;
|
||||||
|
- worker may read and expose a generic tool based on this manifest;
|
||||||
|
- worker must not hardcode the product meaning of an action ID;
|
||||||
|
- `tokenRef` or server-side relay is preferred over embedding raw bearer tokens;
|
||||||
|
- if a bearer token is unavoidable during a scaffold phase, it must be
|
||||||
|
short-lived, run-scoped, redacted from public output, and removed before
|
||||||
|
production.
|
||||||
|
|
||||||
|
## Assistant Action Call
|
||||||
|
|
||||||
|
Endpoint:
|
||||||
|
|
||||||
|
```text
|
||||||
|
POST /api/ai-workspace/assistant/v1/actions
|
||||||
|
```
|
||||||
|
|
||||||
|
Preview request:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"phase": "preview",
|
||||||
|
"input": {
|
||||||
|
"actionId": "hub.user.block",
|
||||||
|
"targetUserId": "user_123",
|
||||||
|
"idempotencyKey": "hub.user.block:actor:target"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Execute request:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"phase": "execute",
|
||||||
|
"input": {
|
||||||
|
"actionId": "hub.user.block",
|
||||||
|
"targetUserId": "user_123",
|
||||||
|
"confirmationToken": "preview-token",
|
||||||
|
"idempotencyKey": "hub.user.block:actor:target"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
|
||||||
|
- the assistant selects `actionId` after understanding natural language;
|
||||||
|
- preview must return a human-readable expected effect;
|
||||||
|
- write execution must require a matching confirmation token;
|
||||||
|
- write execution must include an idempotency key;
|
||||||
|
- app-owned adapter must re-check permissions;
|
||||||
|
- delete/hard destructive requests resolve to forbidden with safe alternatives.
|
||||||
|
|
||||||
|
## Worker Protocol
|
||||||
|
|
||||||
|
The worker receives a run payload and starts the configured executor.
|
||||||
|
|
||||||
|
Worker responsibilities:
|
||||||
|
|
||||||
|
- create isolated per-run config;
|
||||||
|
- expose only tools described by the run profile;
|
||||||
|
- relay tool calls to AI Workspace or app MCP endpoints;
|
||||||
|
- stream model/tool events back to AI Workspace;
|
||||||
|
- redact runtime secrets from logs.
|
||||||
|
|
||||||
|
Worker non-responsibilities:
|
||||||
|
|
||||||
|
- it does not decide whether a HUB admin can block a user;
|
||||||
|
- it does not know all HUB/OPS/ENGINE action IDs in source code;
|
||||||
|
- it does not store internal platform tokens after a run;
|
||||||
|
- it does not bypass AI Workspace with raw app API calls.
|
||||||
|
|
||||||
|
## Hub Protocol
|
||||||
|
|
||||||
|
AI Hub may proxy:
|
||||||
|
|
||||||
|
- worker dispatch;
|
||||||
|
- worker events;
|
||||||
|
- assistant action requests to AI Workspace Assistant.
|
||||||
|
|
||||||
|
AI Hub must not:
|
||||||
|
|
||||||
|
- build action catalogs;
|
||||||
|
- evaluate assistant access policy;
|
||||||
|
- mutate app data directly;
|
||||||
|
- accept public actor claims as final truth.
|
||||||
|
|
||||||
|
## App Adapter Rules
|
||||||
|
|
||||||
|
Every app adapter must define:
|
||||||
|
|
||||||
|
- owning app: `hub`, `ops`, `engine`, or another platform app;
|
||||||
|
- allowed method/path list;
|
||||||
|
- read/write/destructive classification;
|
||||||
|
- required native scope or role;
|
||||||
|
- idempotency behavior for writes;
|
||||||
|
- audit event output;
|
||||||
|
- confirmation mode;
|
||||||
|
- safe alternatives for forbidden actions.
|
||||||
|
|
||||||
|
HUB examples:
|
||||||
|
|
||||||
|
- read admin summary;
|
||||||
|
- list pending invites;
|
||||||
|
- list pending access requests;
|
||||||
|
- block/unblock user;
|
||||||
|
- disable membership;
|
||||||
|
- change NDC Core Assistant role.
|
||||||
|
|
||||||
|
OPS examples:
|
||||||
|
|
||||||
|
- create/update card;
|
||||||
|
- append report/comment;
|
||||||
|
- read project/card context;
|
||||||
|
- update structured card blocks.
|
||||||
|
|
||||||
|
ENGINE examples:
|
||||||
|
|
||||||
|
- read workflow graph;
|
||||||
|
- select workflow/agent node;
|
||||||
|
- inspect workflow errors;
|
||||||
|
- change workflow ACL only through ENGINE-owned adapter.
|
||||||
|
|
||||||
|
## Security Requirements
|
||||||
|
|
||||||
|
Production requirements:
|
||||||
|
|
||||||
|
- all cross-service traffic uses TLS or an equivalent private secure channel;
|
||||||
|
- no long-lived internal token in worker, prompt, UI, or public diagnostics;
|
||||||
|
- run tokens are scoped by run, audience, capability, and expiry;
|
||||||
|
- run profile integrity is hash/signed by AI Workspace;
|
||||||
|
- writes require idempotency keys;
|
||||||
|
- privileged writes require preview and confirmation token;
|
||||||
|
- target App API performs final authorization;
|
||||||
|
- all writes produce audit events;
|
||||||
|
- app APIs reject forged actor headers from public clients;
|
||||||
|
- secret redaction is covered by smoke tests.
|
||||||
|
|
||||||
|
## Localhost vs Production
|
||||||
|
|
||||||
|
Localhost and production must use the same logical protocol:
|
||||||
|
|
||||||
|
```text
|
||||||
|
client -> AI Workspace Assistant -> Ontology Core -> AI Hub/Worker -> App API
|
||||||
|
```
|
||||||
|
|
||||||
|
Allowed differences:
|
||||||
|
|
||||||
|
- URLs;
|
||||||
|
- network transport;
|
||||||
|
- token issuer;
|
||||||
|
- deployment topology.
|
||||||
|
|
||||||
|
Not allowed:
|
||||||
|
|
||||||
|
- separate local-only business rules;
|
||||||
|
- Engine-only action catalog;
|
||||||
|
- worker-only permission model;
|
||||||
|
- production-only policy path that contradicts local behavior.
|
||||||
|
|
||||||
|
## Migration From Current Scaffold
|
||||||
|
|
||||||
|
Current scaffold deviations to remove:
|
||||||
|
|
||||||
|
1. ENGINE local bridge owns fallback `ASSISTANT_ACTION_TOOL_PROFILE`.
|
||||||
|
2. Worker prompt includes concrete HUB action IDs.
|
||||||
|
3. Worker receives or stores assistant action gateway bearer token.
|
||||||
|
4. Engine MCP server knows gateway URL/token and owner header rules.
|
||||||
|
5. AI Hub forwards owner headers without signed actor context.
|
||||||
|
|
||||||
|
Migration steps:
|
||||||
|
|
||||||
|
1. Move assistant action profile source of truth to AI Workspace Assistant.
|
||||||
|
2. Generate action IDs from Ontology Core action registry.
|
||||||
|
3. Replace raw token passthrough with run-scoped token or server-side relay.
|
||||||
|
4. Convert worker action support to generic manifest-driven relay.
|
||||||
|
5. Add protocol tests for boundary violations.
|
||||||
|
6. Mark current scaffold paths as legacy/fallback until removed.
|
||||||
|
|
||||||
|
## Boundary Tests
|
||||||
|
|
||||||
|
Minimum tests before production rollout:
|
||||||
|
|
||||||
|
- public run profile never exposes bearer tokens or internal secrets;
|
||||||
|
- worker source does not contain product action ID allowlists;
|
||||||
|
- ENGINE local routes do not contain HUB action registry;
|
||||||
|
- AI Hub does not evaluate business policy;
|
||||||
|
- forged public actor headers are rejected by app API;
|
||||||
|
- write execution without confirmation token is blocked;
|
||||||
|
- write replay with same idempotency key is safe;
|
||||||
|
- delete actions are unavailable and resolve to safe alternatives;
|
||||||
|
- local and production profiles share the same schema.
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
|
||||||
|
The protocol is ready for product slices when:
|
||||||
|
|
||||||
|
- adding a new assistant function changes Ontology Core and the target app
|
||||||
|
adapter, not the worker;
|
||||||
|
- AI Workspace Assistant remains the only run profile source of truth;
|
||||||
|
- Hub remains transport-only;
|
||||||
|
- worker can be installed once and keep working as capabilities grow;
|
||||||
|
- target apps remain final enforcement owners;
|
||||||
|
- no prompt-only rule is required for safety-critical behavior.
|
||||||
|
|
@ -44,6 +44,14 @@ const adapterPayload = {
|
||||||
const appGrants = normalizeEntitlementAdapterAppGrants(adapterPayload, adapter);
|
const appGrants = normalizeEntitlementAdapterAppGrants(adapterPayload, adapter);
|
||||||
const mcpServers = runProfileMcpServersFromAppGrants({}, appGrants);
|
const mcpServers = runProfileMcpServersFromAppGrants({}, appGrants);
|
||||||
const appGrantSummary = summarizeRunAppGrants({ appGrants });
|
const appGrantSummary = summarizeRunAppGrants({ appGrants });
|
||||||
|
const assistantActions = {
|
||||||
|
schemaVersion: "ai-workspace.assistant-actions.v1",
|
||||||
|
endpoint: "/api/ai-workspace/hub/v1/assistant-relays/local-dev/actions",
|
||||||
|
gatewayUrl: "https://ai-hub.nodedc.ru/api/ai-workspace/hub/v1/assistant-relays/local-dev/actions",
|
||||||
|
gatewayToken: SECRET_TOKEN,
|
||||||
|
actionIds: ["hub.access_request.list_pending", "hub.user.read_admin_summary"],
|
||||||
|
phases: ["preview", "execute"],
|
||||||
|
};
|
||||||
const runProfile = {
|
const runProfile = {
|
||||||
schemaVersion: "ai-workspace.run-profile.v1",
|
schemaVersion: "ai-workspace.run-profile.v1",
|
||||||
runId: randomUUID(),
|
runId: randomUUID(),
|
||||||
|
|
@ -71,6 +79,7 @@ const runProfile = {
|
||||||
mcpServers,
|
mcpServers,
|
||||||
mcpServerNames: mcpServers.map((server) => server.serverName),
|
mcpServerNames: mcpServers.map((server) => server.serverName),
|
||||||
requiredMcpServerNames: mcpServers.filter((server) => server.required).map((server) => server.serverName),
|
requiredMcpServerNames: mcpServers.filter((server) => server.required).map((server) => server.serverName),
|
||||||
|
assistantActions,
|
||||||
},
|
},
|
||||||
diagnostics: {
|
diagnostics: {
|
||||||
schemaVersion: "ai-workspace.run-profile.diagnostics.v1",
|
schemaVersion: "ai-workspace.run-profile.diagnostics.v1",
|
||||||
|
|
@ -106,6 +115,10 @@ assert.equal(publicProfile.toolProfile.mcpServers[0].serverName, "nodedc_ops_age
|
||||||
assert.equal(publicProfile.toolProfile.mcpServers[0].httpHeaders.Authorization, "<redacted>");
|
assert.equal(publicProfile.toolProfile.mcpServers[0].httpHeaders.Authorization, "<redacted>");
|
||||||
assert.equal(publicProfile.toolProfile.mcpServers[0].httpHeaders.Accept, "<redacted>");
|
assert.equal(publicProfile.toolProfile.mcpServers[0].httpHeaders.Accept, "<redacted>");
|
||||||
assert.equal(publicProfile.toolProfile.mcpServers[0].headers, undefined);
|
assert.equal(publicProfile.toolProfile.mcpServers[0].headers, undefined);
|
||||||
|
assert.equal(publicProfile.toolProfile.assistantActions.endpoint, "/api/ai-workspace/hub/v1/assistant-relays/local-dev/actions");
|
||||||
|
assert.equal(publicProfile.toolProfile.assistantActions.gatewayUrl, "https://ai-hub.nodedc.ru/api/ai-workspace/hub/v1/assistant-relays/local-dev/actions");
|
||||||
|
assert.equal(publicProfile.toolProfile.assistantActions.gatewayToken, "<redacted>");
|
||||||
|
assert.deepEqual(publicProfile.toolProfile.assistantActions.actionIds, ["hub.access_request.list_pending", "hub.user.read_admin_summary"]);
|
||||||
assert.equal(JSON.stringify(publicProfile).includes(SECRET_TOKEN), false);
|
assert.equal(JSON.stringify(publicProfile).includes(SECRET_TOKEN), false);
|
||||||
assert.match(runProfile.diagnostics.profileHash, /^[a-f0-9]{16}$/);
|
assert.match(runProfile.diagnostics.profileHash, /^[a-f0-9]{16}$/);
|
||||||
|
|
||||||
|
|
@ -114,7 +127,9 @@ console.log(JSON.stringify({
|
||||||
checks: [
|
checks: [
|
||||||
"adapter_grant_normalized",
|
"adapter_grant_normalized",
|
||||||
"token_scoped_ops_mcp_in_run_profile",
|
"token_scoped_ops_mcp_in_run_profile",
|
||||||
|
"assistant_action_relay_in_run_profile",
|
||||||
"public_run_profile_redacts_mcp_headers",
|
"public_run_profile_redacts_mcp_headers",
|
||||||
|
"public_run_profile_redacts_assistant_action_gateway_token",
|
||||||
"stable_public_profile_hash",
|
"stable_public_profile_hash",
|
||||||
],
|
],
|
||||||
mcpServerNames: runProfile.toolProfile.mcpServerNames,
|
mcpServerNames: runProfile.toolProfile.mcpServerNames,
|
||||||
|
|
@ -276,6 +291,7 @@ function redactRunProfile(runProfile) {
|
||||||
appGrants: redactForPublicDiagnostics(runProfile.appGrants),
|
appGrants: redactForPublicDiagnostics(runProfile.appGrants),
|
||||||
toolProfile: {
|
toolProfile: {
|
||||||
...(isPlainObject(runProfile.toolProfile) ? runProfile.toolProfile : {}),
|
...(isPlainObject(runProfile.toolProfile) ? runProfile.toolProfile : {}),
|
||||||
|
assistantActions: redactAssistantActions(runProfile.toolProfile?.assistantActions),
|
||||||
mcpServers: Array.isArray(runProfile.toolProfile?.mcpServers)
|
mcpServers: Array.isArray(runProfile.toolProfile?.mcpServers)
|
||||||
? runProfile.toolProfile.mcpServers.map(redactMcpServer)
|
? runProfile.toolProfile.mcpServers.map(redactMcpServer)
|
||||||
: [],
|
: [],
|
||||||
|
|
@ -283,6 +299,14 @@ function redactRunProfile(runProfile) {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function redactAssistantActions(value) {
|
||||||
|
if (!isPlainObject(value)) return value;
|
||||||
|
return {
|
||||||
|
...value,
|
||||||
|
...(value.gatewayToken ? { gatewayToken: "<redacted>" } : {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function redactMcpServer(server) {
|
function redactMcpServer(server) {
|
||||||
if (!isPlainObject(server)) return {};
|
if (!isPlainObject(server)) return {};
|
||||||
const headers = isPlainObject(server.httpHeaders) ? server.httpHeaders : {};
|
const headers = isPlainObject(server.httpHeaders) ? server.httpHeaders : {};
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,8 @@ import { createHash, randomUUID, timingSafeEqual } from "node:crypto";
|
||||||
import { readFile } from "node:fs/promises";
|
import { readFile } from "node:fs/promises";
|
||||||
import { createServer } from "node:http";
|
import { createServer } from "node:http";
|
||||||
import { Pool } from "pg";
|
import { Pool } from "pg";
|
||||||
|
import { handleAssistantCallerRequest } from "../../ontology-core/src/assistant-action-caller.mjs";
|
||||||
|
import { loadCatalog } from "../../ontology-core/src/catalog.mjs";
|
||||||
|
|
||||||
const SUPPORTED_SURFACES = new Set(["engine", "ops", "global"]);
|
const SUPPORTED_SURFACES = new Set(["engine", "ops", "global"]);
|
||||||
const SUPPORTED_EXECUTOR_TYPES = new Set(["codex-remote", "ndc-agent-core"]);
|
const SUPPORTED_EXECUTOR_TYPES = new Set(["codex-remote", "ndc-agent-core"]);
|
||||||
|
|
@ -12,8 +14,25 @@ const SUPPORTED_THREAD_STATES = new Set(["active", "archived"]);
|
||||||
const SUPPORTED_MESSAGE_ROLES = new Set(["user", "assistant", "system", "tool"]);
|
const SUPPORTED_MESSAGE_ROLES = new Set(["user", "assistant", "system", "tool"]);
|
||||||
const SUPPORTED_TOOL_PACKS = new Set(["engine", "ops", "ndc-agent-core", "deploy", "docs"]);
|
const SUPPORTED_TOOL_PACKS = new Set(["engine", "ops", "ndc-agent-core", "deploy", "docs"]);
|
||||||
const SUPPORTED_RUN_STATUSES = new Set(["running", "completed", "failed", "timeout"]);
|
const SUPPORTED_RUN_STATUSES = new Set(["running", "completed", "failed", "timeout"]);
|
||||||
|
const ASSISTANT_ACTION_TOOL_PROFILE_BASE = {
|
||||||
|
schemaVersion: "ai-workspace.assistant-actions.v1",
|
||||||
|
endpoint: "/api/ai-workspace/assistant/v1/actions",
|
||||||
|
modelFlow: "interpret_user_intent_then_call_structured_action",
|
||||||
|
phases: ["preview", "execute"],
|
||||||
|
safety: {
|
||||||
|
read: "execute_after_structured_action_selection",
|
||||||
|
write: "preview_then_explicit_user_confirmation_then_execute",
|
||||||
|
destructive: "forbidden",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const ASSISTANT_ACTION_CACHE_TTL_MS = Number(process.env.AI_WORKSPACE_ASSISTANT_ACTION_CACHE_TTL_MS || 30_000);
|
||||||
|
const ASSISTANT_ACTION_RELAY_POLL_TIMEOUT_MS = Number(process.env.AI_WORKSPACE_ASSISTANT_RELAY_POLL_TIMEOUT_MS || 25_000);
|
||||||
|
const ASSISTANT_ACTION_RELAY_POLL_IDLE_MS = Number(process.env.AI_WORKSPACE_ASSISTANT_RELAY_POLL_IDLE_MS || 500);
|
||||||
|
const ASSISTANT_ACTION_RELAY_ERROR_BACKOFF_MS = Number(process.env.AI_WORKSPACE_ASSISTANT_RELAY_ERROR_BACKOFF_MS || 5_000);
|
||||||
const BRIDGE_RUN_MIRROR_POLL_MS = 1200;
|
const BRIDGE_RUN_MIRROR_POLL_MS = 1200;
|
||||||
const BRIDGE_RUN_MIRROR_MAX_MS = 12 * 60 * 60 * 1000;
|
const BRIDGE_RUN_MIRROR_MAX_MS = 12 * 60 * 60 * 1000;
|
||||||
|
let assistantActionIdsCache = { loadedAt: 0, actionIds: [] };
|
||||||
|
let assistantActionRelayStopping = false;
|
||||||
|
|
||||||
const config = readConfig();
|
const config = readConfig();
|
||||||
const pool = new Pool({ connectionString: config.databaseUrl, max: config.databasePoolSize });
|
const pool = new Pool({ connectionString: config.databaseUrl, max: config.databasePoolSize });
|
||||||
|
|
@ -47,6 +66,115 @@ app.patch("/api/ai-workspace/assistant/v1/settings", requireInternalApi, asyncRo
|
||||||
res.json({ ok: true, owner: publicOwner(owner), settings: publicOwnerSettings(settings) });
|
res.json({ ok: true, owner: publicOwner(owner), settings: publicOwnerSettings(settings) });
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
app.post("/api/ai-workspace/assistant/v1/run-profile", requireInternalApi, asyncRoute(async (req, res) => {
|
||||||
|
const owner = getRequestOwner(req);
|
||||||
|
const command = sanitizeRunProfileCommand(req.body);
|
||||||
|
const ownerSettings = await getOwnerSettings(owner);
|
||||||
|
const executorId = command.selectedExecutorId || ownerSettings.selectedExecutorId;
|
||||||
|
if (!executorId) {
|
||||||
|
res.status(400).json({ ok: false, error: "ai_workspace_executor_required" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const executor = await getExecutor(owner, executorId);
|
||||||
|
if (!executor) {
|
||||||
|
res.status(404).json({ ok: false, error: "ai_workspace_executor_not_found" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const thread = {
|
||||||
|
id: command.threadId || randomUUID(),
|
||||||
|
title: command.threadTitle || "AI Workspace Bridge",
|
||||||
|
originSurface: command.originSurface || optionalString(command.context.surface) || "engine",
|
||||||
|
activeContext: {},
|
||||||
|
enabledToolPacks: command.enabledToolPacks,
|
||||||
|
};
|
||||||
|
const bridgePayload = {
|
||||||
|
context: command.context,
|
||||||
|
enabledToolPacks: command.enabledToolPacks,
|
||||||
|
workspacePath: command.workspacePath || executor.workspacePath || "",
|
||||||
|
client: command.client,
|
||||||
|
};
|
||||||
|
const runProfile = await buildRunProfile({ owner, thread, executor, ownerSettings, bridgePayload });
|
||||||
|
res.json({ ok: true, owner: publicOwner(owner), runProfile });
|
||||||
|
}));
|
||||||
|
|
||||||
|
app.post("/api/ai-workspace/assistant/v1/executors/:executorId/dispatch", requireInternalApi, asyncRoute(async (req, res) => {
|
||||||
|
const owner = getRequestOwner(req);
|
||||||
|
const executorId = sanitizeUuid(req.params.executorId, "executorId");
|
||||||
|
const executor = await getExecutor(owner, executorId);
|
||||||
|
if (!executor) {
|
||||||
|
res.status(404).json({ ok: false, error: "ai_workspace_executor_not_found" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const command = sanitizeBridgeDispatchCommand({ ...(isPlainObject(req.body) ? req.body : {}), selectedExecutorId: executorId });
|
||||||
|
if (!command.message) {
|
||||||
|
res.status(400).json({ ok: false, error: "ai_workspace_dispatch_requires_user_message" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ownerSettings = await getOwnerSettings(owner);
|
||||||
|
const thread = {
|
||||||
|
id: command.threadId || randomUUID(),
|
||||||
|
title: command.threadTitle || "AI Workspace Bridge",
|
||||||
|
originSurface: command.originSurface || optionalString(command.context.surface) || "engine",
|
||||||
|
activeContext: {},
|
||||||
|
enabledToolPacks: command.enabledToolPacks,
|
||||||
|
};
|
||||||
|
const bridgePayload = {
|
||||||
|
threadId: thread.id,
|
||||||
|
threadTitle: thread.title,
|
||||||
|
workspacePath: command.workspacePath || executor.workspacePath || "",
|
||||||
|
message: command.message,
|
||||||
|
displayMessage: command.displayMessage || command.message,
|
||||||
|
publicUserMessage: command.publicUserMessage || command.message,
|
||||||
|
resume: command.resume,
|
||||||
|
history: command.history,
|
||||||
|
context: command.context,
|
||||||
|
enabledToolPacks: command.enabledToolPacks,
|
||||||
|
client: command.client,
|
||||||
|
};
|
||||||
|
bridgePayload.runProfile = await buildRunProfile({ owner, thread, executor, ownerSettings, bridgePayload });
|
||||||
|
const bridge = await dispatchExecutorMessage(executor, bridgePayload);
|
||||||
|
res.json({
|
||||||
|
ok: true,
|
||||||
|
owner: publicOwner(owner),
|
||||||
|
executorId: executor.id,
|
||||||
|
bridge,
|
||||||
|
runProfile: redactRunProfile(bridgePayload.runProfile),
|
||||||
|
});
|
||||||
|
}));
|
||||||
|
|
||||||
|
app.post("/api/ai-workspace/assistant/v1/executors/:executorId/stop", requireInternalApi, asyncRoute(async (req, res) => {
|
||||||
|
const owner = getRequestOwner(req);
|
||||||
|
const executorId = sanitizeUuid(req.params.executorId, "executorId");
|
||||||
|
const executor = await getExecutor(owner, executorId);
|
||||||
|
if (!executor) {
|
||||||
|
res.status(404).json({ ok: false, error: "ai_workspace_executor_not_found" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const command = sanitizeBridgeStopCommand(req.body);
|
||||||
|
if (!command.requestId && !command.threadId) {
|
||||||
|
res.status(400).json({ ok: false, error: "ai_workspace_stop_target_empty" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const bridge = await dispatchExecutorStop(executor, command);
|
||||||
|
res.json({
|
||||||
|
ok: true,
|
||||||
|
owner: publicOwner(owner),
|
||||||
|
executorId: executor.id,
|
||||||
|
bridge,
|
||||||
|
});
|
||||||
|
}));
|
||||||
|
|
||||||
|
app.post("/api/ai-workspace/assistant/v1/actions", requireInternalApi, asyncRoute(async (req, res) => {
|
||||||
|
const owner = getRequestOwner(req);
|
||||||
|
res.json(await executeAssistantActionRequest(req.body, owner));
|
||||||
|
}));
|
||||||
|
|
||||||
app.get("/api/ai-workspace/assistant/v1/executors", requireInternalApi, asyncRoute(async (req, res) => {
|
app.get("/api/ai-workspace/assistant/v1/executors", requireInternalApi, asyncRoute(async (req, res) => {
|
||||||
const owner = getRequestOwner(req);
|
const owner = getRequestOwner(req);
|
||||||
const [executors, settings] = await Promise.all([
|
const [executors, settings] = await Promise.all([
|
||||||
|
|
@ -397,6 +525,7 @@ await recoverBridgeRunMirrors();
|
||||||
|
|
||||||
httpServer.listen(config.port, "0.0.0.0", () => {
|
httpServer.listen(config.port, "0.0.0.0", () => {
|
||||||
console.log(`NODE.DC AI Workspace Assistant listening on http://0.0.0.0:${config.port}`);
|
console.log(`NODE.DC AI Workspace Assistant listening on http://0.0.0.0:${config.port}`);
|
||||||
|
startAssistantActionRelayLoop();
|
||||||
});
|
});
|
||||||
|
|
||||||
process.on("SIGTERM", shutdown);
|
process.on("SIGTERM", shutdown);
|
||||||
|
|
@ -1017,6 +1146,62 @@ async function dispatchExecutorMessage(executor, payload) {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function dispatchExecutorStop(executor, payload) {
|
||||||
|
if (executor.connectionMode === "hub" || executor.pairingCode) {
|
||||||
|
const pairingCode = cleanPairingCode(executor.pairingCode);
|
||||||
|
if (!pairingCode) {
|
||||||
|
const error = new Error("bridge_pairing_code_empty");
|
||||||
|
error.status = 400;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
const response = await hubRequestJson(
|
||||||
|
`/api/ai-workspace/hub/v1/agents/${encodeURIComponent(pairingCode)}/dispatch`,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
body: {
|
||||||
|
command: "stop",
|
||||||
|
payload,
|
||||||
|
timeoutMs: 30000,
|
||||||
|
quiet: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
10000
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
accepted: true,
|
||||||
|
requestId: optionalString(response.requestId),
|
||||||
|
targetRequestId: payload.requestId,
|
||||||
|
threadId: payload.threadId,
|
||||||
|
mode: "hub",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = bridgeCommandUrl(executor.endpoint, "stop");
|
||||||
|
if (!url) {
|
||||||
|
const error = new Error("bridge_endpoint_required");
|
||||||
|
error.status = 400;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
const response = await fetchJson(
|
||||||
|
url,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
headers: { Accept: "application/json", "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
},
|
||||||
|
10000
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
ok: response?.ok !== false,
|
||||||
|
accepted: false,
|
||||||
|
response,
|
||||||
|
targetRequestId: payload.requestId,
|
||||||
|
threadId: payload.threadId,
|
||||||
|
mode: "direct",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
async function createBridgeRun({ owner, thread, executor, bridge, payload }) {
|
async function createBridgeRun({ owner, thread, executor, bridge, payload }) {
|
||||||
const requestId = optionalString(bridge?.requestId);
|
const requestId = optionalString(bridge?.requestId);
|
||||||
if (!requestId || bridge?.accepted !== true || bridge?.mode !== "hub") return null;
|
if (!requestId || bridge?.accepted !== true || bridge?.mode !== "hub") return null;
|
||||||
|
|
@ -1447,6 +1632,7 @@ async function buildRunProfile({ owner, thread, executor, ownerSettings, bridgeP
|
||||||
const appGrants = summarizeRunAppGrants({ appGrants: grantResolution.appGrants });
|
const appGrants = summarizeRunAppGrants({ appGrants: grantResolution.appGrants });
|
||||||
const mcpServers = runProfileMcpServersFromAppGrants(ownerSettings, grantResolution.appGrants);
|
const mcpServers = runProfileMcpServersFromAppGrants(ownerSettings, grantResolution.appGrants);
|
||||||
const mcpServerNames = mcpServers.map((server) => server.serverName).filter(Boolean);
|
const mcpServerNames = mcpServers.map((server) => server.serverName).filter(Boolean);
|
||||||
|
const assistantActions = await assistantActionToolProfileForRun();
|
||||||
const requiredMcpServerNames = mcpServers
|
const requiredMcpServerNames = mcpServers
|
||||||
.filter((server) => server.required === true)
|
.filter((server) => server.required === true)
|
||||||
.map((server) => server.serverName)
|
.map((server) => server.serverName)
|
||||||
|
|
@ -1464,6 +1650,8 @@ async function buildRunProfile({ owner, thread, executor, ownerSettings, bridgeP
|
||||||
entitlementAdapters: grantResolution.diagnostics,
|
entitlementAdapters: grantResolution.diagnostics,
|
||||||
mcpServerNames,
|
mcpServerNames,
|
||||||
requiredMcpServerNames,
|
requiredMcpServerNames,
|
||||||
|
assistantActionIds: assistantActions.actionIds,
|
||||||
|
assistantActionGatewayConfigured: Boolean(assistantActions.gatewayUrl && assistantActions.gatewayToken),
|
||||||
missingContext: Array.isArray(context.missingContext)
|
missingContext: Array.isArray(context.missingContext)
|
||||||
? context.missingContext.map(optionalString).filter(Boolean)
|
? context.missingContext.map(optionalString).filter(Boolean)
|
||||||
: [],
|
: [],
|
||||||
|
|
@ -1490,14 +1678,164 @@ async function buildRunProfile({ owner, thread, executor, ownerSettings, bridgeP
|
||||||
mcpServers,
|
mcpServers,
|
||||||
mcpServerNames,
|
mcpServerNames,
|
||||||
requiredMcpServerNames,
|
requiredMcpServerNames,
|
||||||
|
assistantActions,
|
||||||
},
|
},
|
||||||
policyPrompt: buildRunProfilePolicyPrompt({ context, diagnostics }),
|
policyPrompt: buildRunProfilePolicyPrompt({ context, diagnostics, assistantActions }),
|
||||||
diagnostics,
|
diagnostics,
|
||||||
};
|
};
|
||||||
runProfile.diagnostics.profileHash = runProfileHash(runProfile);
|
runProfile.diagnostics.profileHash = runProfileHash(runProfile);
|
||||||
return runProfile;
|
return runProfile;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function assistantActionToolProfileForRun() {
|
||||||
|
const gatewayUrl = assistantActionGatewayUrlForRun();
|
||||||
|
const gatewayToken = optionalString(
|
||||||
|
process.env.NDC_AI_WORKSPACE_ASSISTANT_ACTION_GATEWAY_TOKEN ||
|
||||||
|
config.hubInternalAccessToken ||
|
||||||
|
config.internalAccessTokens[0] ||
|
||||||
|
""
|
||||||
|
);
|
||||||
|
const actionIds = await assistantActionIdsForRun();
|
||||||
|
return {
|
||||||
|
...ASSISTANT_ACTION_TOOL_PROFILE_BASE,
|
||||||
|
endpoint: actionGatewayEndpointPath(gatewayUrl) || ASSISTANT_ACTION_TOOL_PROFILE_BASE.endpoint,
|
||||||
|
actionIds,
|
||||||
|
...(gatewayUrl ? { gatewayUrl } : {}),
|
||||||
|
...(gatewayToken ? { gatewayToken } : {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function assistantActionGatewayUrlForRun() {
|
||||||
|
const explicit = cleanHttpEndpoint(process.env.NDC_AI_WORKSPACE_ASSISTANT_ACTION_GATEWAY_URL);
|
||||||
|
if (explicit) return explicit;
|
||||||
|
if (config.assistantActionRelayEnabled && config.assistantActionRelayId && config.hubInternalHttpUrl) {
|
||||||
|
return cleanHttpEndpoint(
|
||||||
|
`${config.hubInternalHttpUrl.replace(/\/+$/, "")}/api/ai-workspace/hub/v1/assistant-relays/${encodeURIComponent(config.assistantActionRelayId)}/actions`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return cleanHttpEndpoint(config.hubInternalHttpUrl || httpUrlFromWebSocketUrl(config.hubWebSocketUrl));
|
||||||
|
}
|
||||||
|
|
||||||
|
function actionGatewayEndpointPath(gatewayUrl) {
|
||||||
|
try {
|
||||||
|
const url = new URL(gatewayUrl);
|
||||||
|
return `${url.pathname}${url.search || ""}`;
|
||||||
|
} catch {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function assistantActionIdsForRun() {
|
||||||
|
const now = Date.now();
|
||||||
|
if (
|
||||||
|
assistantActionIdsCache.actionIds.length &&
|
||||||
|
now - assistantActionIdsCache.loadedAt < ASSISTANT_ACTION_CACHE_TTL_MS
|
||||||
|
) {
|
||||||
|
return assistantActionIdsCache.actionIds;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const catalog = await loadCatalog();
|
||||||
|
const actionIds = (Array.isArray(catalog?.assistantActions?.actions) ? catalog.assistantActions.actions : [])
|
||||||
|
.filter((action) => (
|
||||||
|
isPlainObject(action) &&
|
||||||
|
optionalString(action.id) &&
|
||||||
|
action.adapterStatus === "implemented" &&
|
||||||
|
action.confirmationMode !== "forbidden"
|
||||||
|
))
|
||||||
|
.map((action) => optionalString(action.id))
|
||||||
|
.filter(Boolean)
|
||||||
|
.sort();
|
||||||
|
assistantActionIdsCache = { loadedAt: now, actionIds };
|
||||||
|
return actionIds;
|
||||||
|
} catch {
|
||||||
|
assistantActionIdsCache = { loadedAt: now, actionIds: [] };
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function startAssistantActionRelayLoop() {
|
||||||
|
if (!config.assistantActionRelayEnabled) return;
|
||||||
|
if (!config.assistantActionRelayId || !config.hubInternalHttpUrl || !config.hubInternalAccessToken) {
|
||||||
|
console.warn("Assistant action relay disabled: relay id, hub URL, or hub token is missing.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
void assistantActionRelayLoop();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function assistantActionRelayLoop() {
|
||||||
|
while (!assistantActionRelayStopping) {
|
||||||
|
try {
|
||||||
|
const payload = await hubRequestJson(
|
||||||
|
`/api/ai-workspace/hub/v1/assistant-relays/${encodeURIComponent(config.assistantActionRelayId)}/poll`,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
body: {
|
||||||
|
limit: 5,
|
||||||
|
timeoutMs: ASSISTANT_ACTION_RELAY_POLL_TIMEOUT_MS,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
ASSISTANT_ACTION_RELAY_POLL_TIMEOUT_MS + 5000
|
||||||
|
);
|
||||||
|
const calls = Array.isArray(payload?.calls) ? payload.calls : [];
|
||||||
|
for (const call of calls) {
|
||||||
|
void handleAssistantActionRelayCall(call).catch((error) => {
|
||||||
|
console.error("Assistant action relay call failed:", errorMessage(error));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
await delay(ASSISTANT_ACTION_RELAY_POLL_IDLE_MS);
|
||||||
|
} catch (error) {
|
||||||
|
if (!assistantActionRelayStopping) {
|
||||||
|
console.error("Assistant action relay poll failed:", errorMessage(error));
|
||||||
|
await delay(ASSISTANT_ACTION_RELAY_ERROR_BACKOFF_MS);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleAssistantActionRelayCall(call) {
|
||||||
|
const callId = optionalString(call?.callId);
|
||||||
|
if (!callId) return;
|
||||||
|
|
||||||
|
let status = 200;
|
||||||
|
let body = null;
|
||||||
|
try {
|
||||||
|
const owner = getRequestOwner({ headers: relayCallHeaders(call?.headers), query: {} });
|
||||||
|
body = await executeAssistantActionRequest(isPlainObject(call?.payload) ? call.payload : {}, owner);
|
||||||
|
} catch (error) {
|
||||||
|
status = Number(error?.status || 500);
|
||||||
|
body = { ok: false, error: errorMessage(error) };
|
||||||
|
}
|
||||||
|
|
||||||
|
await hubRequestJson(
|
||||||
|
`/api/ai-workspace/hub/v1/assistant-relays/${encodeURIComponent(config.assistantActionRelayId)}/results/${encodeURIComponent(callId)}`,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
body: {
|
||||||
|
status,
|
||||||
|
body,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
10000
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function relayCallHeaders(value) {
|
||||||
|
const headers = {};
|
||||||
|
if (!isPlainObject(value)) return headers;
|
||||||
|
for (const [key, rawValue] of Object.entries(value)) {
|
||||||
|
const name = optionalString(key)?.toLowerCase();
|
||||||
|
const text = optionalString(rawValue);
|
||||||
|
if (!name || !text) continue;
|
||||||
|
headers[name] = text;
|
||||||
|
}
|
||||||
|
return headers;
|
||||||
|
}
|
||||||
|
|
||||||
|
function delay(ms) {
|
||||||
|
return new Promise((resolve) => setTimeout(resolve, Math.max(0, Number(ms || 0))));
|
||||||
|
}
|
||||||
|
|
||||||
async function resolveRunAppGrants({ owner, context, ownerSettings }) {
|
async function resolveRunAppGrants({ owner, context, ownerSettings }) {
|
||||||
const metadata = isPlainObject(ownerSettings?.metadata) ? ownerSettings.metadata : {};
|
const metadata = isPlainObject(ownerSettings?.metadata) ? ownerSettings.metadata : {};
|
||||||
const staticAppGrants = isPlainObject(metadata.appGrants) ? metadata.appGrants : {};
|
const staticAppGrants = isPlainObject(metadata.appGrants) ? metadata.appGrants : {};
|
||||||
|
|
@ -1709,7 +2047,7 @@ function summarizeRunAppGrants(metadata) {
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildRunProfilePolicyPrompt({ context, diagnostics }) {
|
function buildRunProfilePolicyPrompt({ context, diagnostics, assistantActions }) {
|
||||||
const lines = [
|
const lines = [
|
||||||
"AI Workspace dynamic run profile:",
|
"AI Workspace dynamic run profile:",
|
||||||
`- source surface: ${diagnostics.sourceSurface}`,
|
`- source surface: ${diagnostics.sourceSurface}`,
|
||||||
|
|
@ -1719,6 +2057,11 @@ function buildRunProfilePolicyPrompt({ context, diagnostics }) {
|
||||||
`- entitlement source: ${diagnostics.entitlementAdapters?.source || "settings"}`,
|
`- entitlement source: ${diagnostics.entitlementAdapters?.source || "settings"}`,
|
||||||
`- enabled tool packs: ${diagnostics.enabledToolPacks.length ? diagnostics.enabledToolPacks.join(", ") : "none"}`,
|
`- enabled tool packs: ${diagnostics.enabledToolPacks.length ? diagnostics.enabledToolPacks.join(", ") : "none"}`,
|
||||||
`- MCP servers available in this run: ${diagnostics.mcpServerNames.length ? diagnostics.mcpServerNames.join(", ") : "none"}`,
|
`- MCP servers available in this run: ${diagnostics.mcpServerNames.length ? diagnostics.mcpServerNames.join(", ") : "none"}`,
|
||||||
|
`- assistant action ids available: ${Array.isArray(assistantActions?.actionIds) ? assistantActions.actionIds.join(", ") : "none"}`,
|
||||||
|
"- Interpret the user's natural-language request first; call assistant actions only after selecting a structured action id.",
|
||||||
|
"- Read assistant actions may execute after structured action selection. Privileged/write assistant actions require preview, explicit user confirmation, then execute.",
|
||||||
|
"- Ops card actions advertised in this run are valid assistant actions: use ops.card.list_recent for reading cards, ops.card.create for creating cards, and ops.card.add_comment for comments instead of refusing because direct Ops MCP tools are absent.",
|
||||||
|
"- Destructive assistant actions are forbidden; offer safe alternatives such as block/disable instead of delete.",
|
||||||
"- MCP tokens and headers are runtime secrets and must never be printed in public answers.",
|
"- MCP tokens and headers are runtime secrets and must never be printed in public answers.",
|
||||||
];
|
];
|
||||||
const opsContext = isPlainObject(context?.contexts?.ops) ? context.contexts.ops : {};
|
const opsContext = isPlainObject(context?.contexts?.ops) ? context.contexts.ops : {};
|
||||||
|
|
@ -1754,19 +2097,29 @@ function runProfileHash(runProfile) {
|
||||||
|
|
||||||
function redactRunProfile(runProfile) {
|
function redactRunProfile(runProfile) {
|
||||||
if (!isPlainObject(runProfile)) return null;
|
if (!isPlainObject(runProfile)) return null;
|
||||||
|
const toolProfile = isPlainObject(runProfile.toolProfile) ? runProfile.toolProfile : {};
|
||||||
return {
|
return {
|
||||||
...runProfile,
|
...runProfile,
|
||||||
targetContexts: redactForPublicDiagnostics(runProfile.targetContexts),
|
targetContexts: redactForPublicDiagnostics(runProfile.targetContexts),
|
||||||
appGrants: redactForPublicDiagnostics(runProfile.appGrants),
|
appGrants: redactForPublicDiagnostics(runProfile.appGrants),
|
||||||
toolProfile: {
|
toolProfile: {
|
||||||
...(isPlainObject(runProfile.toolProfile) ? runProfile.toolProfile : {}),
|
...toolProfile,
|
||||||
mcpServers: Array.isArray(runProfile.toolProfile?.mcpServers)
|
assistantActions: redactAssistantActions(toolProfile.assistantActions),
|
||||||
? runProfile.toolProfile.mcpServers.map(redactMcpServer)
|
mcpServers: Array.isArray(toolProfile.mcpServers)
|
||||||
|
? toolProfile.mcpServers.map(redactMcpServer)
|
||||||
: [],
|
: [],
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function redactAssistantActions(value) {
|
||||||
|
if (!isPlainObject(value)) return value;
|
||||||
|
return {
|
||||||
|
...value,
|
||||||
|
...(value.gatewayToken ? { gatewayToken: "<redacted>" } : {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function redactMcpServer(server) {
|
function redactMcpServer(server) {
|
||||||
if (!isPlainObject(server)) return {};
|
if (!isPlainObject(server)) return {};
|
||||||
const headers = isPlainObject(server.httpHeaders) ? server.httpHeaders : {};
|
const headers = isPlainObject(server.httpHeaders) ? server.httpHeaders : {};
|
||||||
|
|
@ -2520,12 +2873,62 @@ function sanitizeOwnerSettingsCommand(payload) {
|
||||||
return command;
|
return command;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function sanitizeRunProfileCommand(payload) {
|
||||||
|
const source = isPlainObject(payload) ? payload : {};
|
||||||
|
const context = isPlainObject(source.context) ? source.context : {};
|
||||||
|
const client = isPlainObject(source.client) ? source.client : {};
|
||||||
|
const selectedExecutorId = source.selectedExecutorId || source.selected_executor_id || null;
|
||||||
|
const toolPacks = mergeToolPacks(
|
||||||
|
source.enabledToolPacks,
|
||||||
|
source.enabled_tool_packs,
|
||||||
|
context.enabledToolPacks,
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
selectedExecutorId: selectedExecutorId ? sanitizeUuid(selectedExecutorId, "selectedExecutorId") : null,
|
||||||
|
threadId: optionalString(source.threadId || source.thread_id),
|
||||||
|
threadTitle: optionalString(source.threadTitle || source.thread_title),
|
||||||
|
workspacePath: optionalString(source.workspacePath || source.workspace_path),
|
||||||
|
originSurface: sanitizeSurface(source.originSurface || source.origin_surface || context.sourceSurface || context.surface || client.surface || "engine"),
|
||||||
|
context,
|
||||||
|
client,
|
||||||
|
enabledToolPacks: toolPacks,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function sanitizeBridgeDispatchCommand(payload) {
|
||||||
|
const source = isPlainObject(payload) ? payload : {};
|
||||||
|
const command = sanitizeRunProfileCommand(source);
|
||||||
|
return {
|
||||||
|
...command,
|
||||||
|
message: optionalString(source.message || source.content),
|
||||||
|
displayMessage: optionalString(source.displayMessage || source.display_message),
|
||||||
|
publicUserMessage: optionalString(source.publicUserMessage || source.public_user_message),
|
||||||
|
resume: source.resume === true,
|
||||||
|
history: Array.isArray(source.history)
|
||||||
|
? source.history.slice(-40).map((item) => ({
|
||||||
|
role: optionalString(item?.role),
|
||||||
|
text: optionalString(item?.text || item?.content),
|
||||||
|
})).filter((item) => item.role && item.text)
|
||||||
|
: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function sanitizeThreadKind(value) {
|
function sanitizeThreadKind(value) {
|
||||||
const text = normalizeKey(value || "all");
|
const text = normalizeKey(value || "all");
|
||||||
if (text === "shared" || text === "remote" || text === "all") return text;
|
if (text === "shared" || text === "remote" || text === "all") return text;
|
||||||
return "all";
|
return "all";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function sanitizeBridgeStopCommand(payload) {
|
||||||
|
const source = isPlainObject(payload) ? payload : {};
|
||||||
|
return {
|
||||||
|
requestId: optionalString(source.requestId || source.request_id),
|
||||||
|
threadId: optionalString(source.threadId || source.thread_id),
|
||||||
|
reason: optionalString(source.reason) || "user_stop",
|
||||||
|
client: isPlainObject(source.client) ? source.client : {},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function sanitizeMessageCommand(payload) {
|
function sanitizeMessageCommand(payload) {
|
||||||
const source = isPlainObject(payload) ? payload : {};
|
const source = isPlainObject(payload) ? payload : {};
|
||||||
return {
|
return {
|
||||||
|
|
@ -2573,6 +2976,111 @@ function getRequestOwner(req) {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function sanitizeAssistantActionCommand(body, owner) {
|
||||||
|
const source = isPlainObject(body) ? body : {};
|
||||||
|
const phase = normalizeKey(source.phase || source.mode || "preview");
|
||||||
|
if (!["preview", "dry-run", "execute"].includes(phase)) {
|
||||||
|
throw badRequest("assistant_action_phase_invalid");
|
||||||
|
}
|
||||||
|
|
||||||
|
const sourceInput = isPlainObject(source.input) ? source.input : source;
|
||||||
|
const input = { ...sourceInput };
|
||||||
|
delete input.phase;
|
||||||
|
delete input.mode;
|
||||||
|
delete input.input;
|
||||||
|
|
||||||
|
const ownerContext = assistantOwnerContext(owner, sourceInput);
|
||||||
|
input.actorUserId = owner.userId || null;
|
||||||
|
input.actorEmail = owner.email || null;
|
||||||
|
input.actorSubject = owner.key;
|
||||||
|
input.groups = owner.groups;
|
||||||
|
input.launcherGlobalRole = ownerContext.launcherGlobalRole;
|
||||||
|
input.membershipRole = ownerContext.membershipRole;
|
||||||
|
input.membershipStatus = ownerContext.membershipStatus;
|
||||||
|
input.launcherUserStatus = ownerContext.launcherUserStatus;
|
||||||
|
input.assistantRole = ownerContext.assistantRole;
|
||||||
|
|
||||||
|
const confirmationToken = optionalString(source.confirmationToken || sourceInput.confirmationToken || source.confirmation?.token);
|
||||||
|
if (confirmationToken) input.confirmationToken = confirmationToken;
|
||||||
|
|
||||||
|
return { phase, input };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function executeAssistantActionRequest(body, owner) {
|
||||||
|
const command = sanitizeAssistantActionCommand(body, owner);
|
||||||
|
let opsGatewayToken = "";
|
||||||
|
if (command.phase === "execute") {
|
||||||
|
const ownerSettings = await getOwnerSettings(owner);
|
||||||
|
const grantResolution = await resolveRunAppGrants({
|
||||||
|
owner,
|
||||||
|
context: isPlainObject(ownerSettings.activeContext) ? ownerSettings.activeContext : {},
|
||||||
|
ownerSettings,
|
||||||
|
});
|
||||||
|
opsGatewayToken = opsGatewayTokenFromAppGrants(ownerSettings, grantResolution.appGrants, config.opsGatewayBaseUrl);
|
||||||
|
}
|
||||||
|
const action = await handleAssistantCallerRequest(command, {
|
||||||
|
baseUrl: config.ontologyLauncherBaseUrl,
|
||||||
|
launcherInternalToken: config.launcherInternalAccessToken,
|
||||||
|
opsGatewayBaseUrl: config.opsGatewayBaseUrl,
|
||||||
|
opsGatewayToken,
|
||||||
|
opsEntitlementUrl: config.opsEntitlementUrl,
|
||||||
|
opsEntitlementAuthorization: config.opsEntitlementAuthorization,
|
||||||
|
defaultOpsWorkspaceSlug: config.defaultOpsWorkspaceSlug,
|
||||||
|
defaultOpsProjectId: config.defaultOpsProjectId,
|
||||||
|
});
|
||||||
|
return { ok: true, owner: publicOwner(owner), action: publicAssistantCallerResult(action) };
|
||||||
|
}
|
||||||
|
|
||||||
|
function urlOrigin(value) {
|
||||||
|
try {
|
||||||
|
return new URL(value).origin;
|
||||||
|
} catch {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function opsGatewayTokenFromAppGrants(ownerSettings, appGrants, opsGatewayBaseUrl) {
|
||||||
|
const servers = runProfileMcpServersFromAppGrants(ownerSettings, appGrants);
|
||||||
|
const targetOrigin = urlOrigin(opsGatewayBaseUrl);
|
||||||
|
const preferred = servers.find((server) => (
|
||||||
|
server.appId === "ops" &&
|
||||||
|
(!targetOrigin || urlOrigin(server.url) === targetOrigin)
|
||||||
|
)) || servers.find((server) => server.appId === "ops");
|
||||||
|
return bearerTokenFromHeaders(preferred?.httpHeaders);
|
||||||
|
}
|
||||||
|
|
||||||
|
function assistantOwnerContext(owner, sourceInput = {}) {
|
||||||
|
const groups = new Set(owner.groups || []);
|
||||||
|
const ownerRole = normalizeKey(owner.role);
|
||||||
|
const isRoot = ownerRole === "root-admin" || ownerRole === "root_admin" || groups.has("nodedc:superadmin") || groups.has("nodedc:launcher:admin");
|
||||||
|
const membershipRole = launcherMembershipRoleFromOwner(ownerRole, sourceInput);
|
||||||
|
const assistantRole = assistantRoleFromOwner({ isRoot, sourceInput });
|
||||||
|
|
||||||
|
return {
|
||||||
|
assistantRole,
|
||||||
|
launcherGlobalRole: isRoot ? "root_admin" : ownerRole.replace(/-/g, "_"),
|
||||||
|
membershipRole,
|
||||||
|
membershipStatus: optionalString(sourceInput.membershipStatus) || "active",
|
||||||
|
launcherUserStatus: optionalString(sourceInput.launcherUserStatus || sourceInput.globalStatus) || "active",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function assistantRoleFromOwner({ isRoot, sourceInput }) {
|
||||||
|
if (isRoot) return "admin";
|
||||||
|
const value = normalizeKey(sourceInput.assistantRole || sourceInput.coreAssistantRole);
|
||||||
|
if (value === "assistant-admin" || value === "admin") return "admin";
|
||||||
|
if (value === "assistant-blocked" || value === "blocked") return "blocked";
|
||||||
|
return "member";
|
||||||
|
}
|
||||||
|
|
||||||
|
function launcherMembershipRoleFromOwner(ownerRole, sourceInput = {}) {
|
||||||
|
const role = ownerRole.replace(/-/g, "_");
|
||||||
|
if (["client_owner", "client_admin", "member"].includes(role)) return role;
|
||||||
|
const sourceRole = normalizeKey(sourceInput.membershipRole).replace(/-/g, "_");
|
||||||
|
if (["client_owner", "client_admin", "member"].includes(sourceRole)) return sourceRole;
|
||||||
|
return "member";
|
||||||
|
}
|
||||||
|
|
||||||
function publicOwner(owner) {
|
function publicOwner(owner) {
|
||||||
return {
|
return {
|
||||||
ownerKey: owner.key,
|
ownerKey: owner.key,
|
||||||
|
|
@ -2583,6 +3091,12 @@ function publicOwner(owner) {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function publicAssistantCallerResult(result) {
|
||||||
|
if (!isPlainObject(result)) return result;
|
||||||
|
const { raw: _raw, ...publicResult } = result;
|
||||||
|
return publicResult;
|
||||||
|
}
|
||||||
|
|
||||||
function publicOwnerSettings(settings) {
|
function publicOwnerSettings(settings) {
|
||||||
if (!settings || !isPlainObject(settings)) return settings;
|
if (!settings || !isPlainObject(settings)) return settings;
|
||||||
return {
|
return {
|
||||||
|
|
@ -2874,6 +3388,11 @@ function isTruthy(value) {
|
||||||
return text === "1" || text === "true" || text === "yes" || text === "on";
|
return text === "1" || text === "true" || text === "yes" || text === "on";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isFalsy(value) {
|
||||||
|
const text = optionalString(value)?.toLowerCase() || "";
|
||||||
|
return text === "0" || text === "false" || text === "no" || text === "off";
|
||||||
|
}
|
||||||
|
|
||||||
function normalizeEmail(value) {
|
function normalizeEmail(value) {
|
||||||
const text = optionalString(value);
|
const text = optionalString(value);
|
||||||
return text ? text.toLowerCase() : null;
|
return text ? text.toLowerCase() : null;
|
||||||
|
|
@ -3051,6 +3570,7 @@ function cleanHttpEndpoint(value) {
|
||||||
}
|
}
|
||||||
|
|
||||||
function readConfig() {
|
function readConfig() {
|
||||||
|
const nodedcEnv = normalizeKey(process.env.NODEDC_ENV || process.env.NDC_ENV || process.env.NODE_ENV || "");
|
||||||
const databaseUrl =
|
const databaseUrl =
|
||||||
process.env.DATABASE_URL ||
|
process.env.DATABASE_URL ||
|
||||||
process.env.AI_WORKSPACE_ASSISTANT_DATABASE_URL ||
|
process.env.AI_WORKSPACE_ASSISTANT_DATABASE_URL ||
|
||||||
|
|
@ -3070,8 +3590,44 @@ function readConfig() {
|
||||||
optionalString(process.env.NDC_AI_WORKSPACE_HUB_TOKEN) ||
|
optionalString(process.env.NDC_AI_WORKSPACE_HUB_TOKEN) ||
|
||||||
"";
|
"";
|
||||||
const sharedInternalAccessToken = optionalString(process.env.NODEDC_INTERNAL_ACCESS_TOKEN) || "";
|
const sharedInternalAccessToken = optionalString(process.env.NODEDC_INTERNAL_ACCESS_TOKEN) || "";
|
||||||
|
const defaultOntologyLauncherBaseUrl =
|
||||||
|
process.env.NODE_ENV === 'production' ? 'http://launcher:5173' : 'http://launcher.local.nodedc'
|
||||||
|
const ontologyLauncherBaseUrl =
|
||||||
|
optionalString(process.env.NDC_ONTOLOGY_LAUNCHER_BASE_URL) ||
|
||||||
|
optionalString(process.env.NDC_LAUNCHER_BASE_URL) ||
|
||||||
|
optionalString(process.env.NDC_LAUNCHER_INTERNAL_URL) ||
|
||||||
|
optionalString(process.env.LAUNCHER_INTERNAL_URL) ||
|
||||||
|
optionalString(process.env.LAUNCHER_BASE_URL) ||
|
||||||
|
defaultOntologyLauncherBaseUrl;
|
||||||
|
const launcherInternalAccessToken =
|
||||||
|
optionalString(process.env.NDC_LAUNCHER_INTERNAL_ACCESS_TOKEN) ||
|
||||||
|
optionalString(process.env.LAUNCHER_INTERNAL_TOKEN) ||
|
||||||
|
sharedInternalAccessToken;
|
||||||
|
const assistantActionRelayId =
|
||||||
|
optionalString(process.env.AI_WORKSPACE_ASSISTANT_ACTION_RELAY_ID) ||
|
||||||
|
optionalString(process.env.NDC_AI_WORKSPACE_ASSISTANT_ACTION_RELAY_ID) ||
|
||||||
|
(nodedcEnv === "local" || nodedcEnv === "tunnel-local-e2e" ? "local-dev" : "");
|
||||||
|
const assistantActionRelayEnabledRaw = optionalString(
|
||||||
|
process.env.AI_WORKSPACE_ASSISTANT_ACTION_RELAY_ENABLED ||
|
||||||
|
process.env.NDC_AI_WORKSPACE_ASSISTANT_ACTION_RELAY_ENABLED
|
||||||
|
);
|
||||||
|
const assistantActionRelayDefaultEnabled = Boolean(
|
||||||
|
assistantActionRelayId &&
|
||||||
|
(nodedcEnv === "local" || nodedcEnv === "tunnel-local-e2e") &&
|
||||||
|
isDeployedPublicHubUrl(hubInternalHttpUrl)
|
||||||
|
);
|
||||||
|
const assistantActionRelayEnabled = assistantActionRelayEnabledRaw
|
||||||
|
? isTruthy(assistantActionRelayEnabledRaw) && !isFalsy(assistantActionRelayEnabledRaw)
|
||||||
|
: assistantActionRelayDefaultEnabled;
|
||||||
|
const entitlementAdapters = parseEntitlementAdapters();
|
||||||
|
const opsEntitlementAdapter = entitlementAdapters.find((adapter) => adapter.appId === "ops") || null;
|
||||||
|
const opsGatewayBaseUrl =
|
||||||
|
optionalString(process.env.AI_WORKSPACE_OPS_GATEWAY_BASE_URL) ||
|
||||||
|
optionalString(process.env.NDC_AI_WORKSPACE_OPS_GATEWAY_BASE_URL) ||
|
||||||
|
"";
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
nodedcEnv,
|
||||||
port: Number(process.env.PORT || process.env.AI_WORKSPACE_ASSISTANT_PORT || "18082"),
|
port: Number(process.env.PORT || process.env.AI_WORKSPACE_ASSISTANT_PORT || "18082"),
|
||||||
databaseUrl,
|
databaseUrl,
|
||||||
databasePoolSize: Number(process.env.AI_WORKSPACE_ASSISTANT_DATABASE_POOL_SIZE || "10"),
|
databasePoolSize: Number(process.env.AI_WORKSPACE_ASSISTANT_DATABASE_POOL_SIZE || "10"),
|
||||||
|
|
@ -3079,6 +3635,21 @@ function readConfig() {
|
||||||
hubInternalHttpUrl: hubInternalHttpUrl.replace(/\/+$/, ""),
|
hubInternalHttpUrl: hubInternalHttpUrl.replace(/\/+$/, ""),
|
||||||
hubInternalAccessToken:
|
hubInternalAccessToken:
|
||||||
explicitHubAccessToken || (isDeployedPublicHubUrl(hubInternalHttpUrl) ? "" : sharedInternalAccessToken),
|
explicitHubAccessToken || (isDeployedPublicHubUrl(hubInternalHttpUrl) ? "" : sharedInternalAccessToken),
|
||||||
|
ontologyLauncherBaseUrl: ontologyLauncherBaseUrl.replace(/\/+$/, ""),
|
||||||
|
launcherInternalAccessToken,
|
||||||
|
opsGatewayBaseUrl: opsGatewayBaseUrl.replace(/\/+$/, ""),
|
||||||
|
opsEntitlementUrl: opsEntitlementAdapter?.url || "",
|
||||||
|
opsEntitlementAuthorization: opsEntitlementAdapter?.authorization || "",
|
||||||
|
defaultOpsWorkspaceSlug:
|
||||||
|
optionalString(process.env.AI_WORKSPACE_OPS_DEFAULT_WORKSPACE_SLUG) ||
|
||||||
|
optionalString(process.env.NDC_AI_WORKSPACE_OPS_DEFAULT_WORKSPACE_SLUG) ||
|
||||||
|
"",
|
||||||
|
defaultOpsProjectId:
|
||||||
|
optionalString(process.env.AI_WORKSPACE_OPS_DEFAULT_PROJECT_ID) ||
|
||||||
|
optionalString(process.env.NDC_AI_WORKSPACE_OPS_DEFAULT_PROJECT_ID) ||
|
||||||
|
"",
|
||||||
|
assistantActionRelayEnabled,
|
||||||
|
assistantActionRelayId,
|
||||||
hubFallbackWebSocketUrls: String(
|
hubFallbackWebSocketUrls: String(
|
||||||
process.env.AI_WORKSPACE_HUB_FALLBACK_URLS ||
|
process.env.AI_WORKSPACE_HUB_FALLBACK_URLS ||
|
||||||
process.env.NDC_AI_WORKSPACE_HUB_FALLBACK_URLS ||
|
process.env.NDC_AI_WORKSPACE_HUB_FALLBACK_URLS ||
|
||||||
|
|
@ -3092,11 +3663,12 @@ function readConfig() {
|
||||||
process.env.NODEDC_INTERNAL_ACCESS_TOKEN,
|
process.env.NODEDC_INTERNAL_ACCESS_TOKEN,
|
||||||
process.env.NODEDC_PLATFORM_SERVICE_TOKEN,
|
process.env.NODEDC_PLATFORM_SERVICE_TOKEN,
|
||||||
]),
|
]),
|
||||||
entitlementAdapters: parseEntitlementAdapters(),
|
entitlementAdapters,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async function shutdown() {
|
async function shutdown() {
|
||||||
|
assistantActionRelayStopping = true;
|
||||||
try {
|
try {
|
||||||
httpServer.close();
|
httpServer.close();
|
||||||
await pool.end();
|
await pool.end();
|
||||||
|
|
|
||||||
|
|
@ -879,6 +879,26 @@ function cleanRunKey(value, limit = 160) {
|
||||||
return String(value || '').trim().slice(0, limit)
|
return String(value || '').trim().slice(0, limit)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function terminateProcessTree(child, signal = 'SIGTERM') {
|
||||||
|
if (!child) return
|
||||||
|
if (process.platform === 'win32' && child.pid) {
|
||||||
|
try {
|
||||||
|
const args = ['/PID', String(child.pid), '/T']
|
||||||
|
if (signal === 'SIGKILL') args.push('/F')
|
||||||
|
const killer = spawn('taskkill', args, {
|
||||||
|
windowsHide: true,
|
||||||
|
stdio: 'ignore',
|
||||||
|
shell: false,
|
||||||
|
})
|
||||||
|
killer.unref?.()
|
||||||
|
return
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
child.kill(signal)
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
function registerActiveCodexRun(control = {}, child, onEvent = () => {}) {
|
function registerActiveCodexRun(control = {}, child, onEvent = () => {}) {
|
||||||
const requestId = cleanRunKey(control.requestId, 120)
|
const requestId = cleanRunKey(control.requestId, 120)
|
||||||
const threadId = cleanRunKey(control.threadId, 160)
|
const threadId = cleanRunKey(control.threadId, 160)
|
||||||
|
|
@ -898,14 +918,10 @@ function registerActiveCodexRun(control = {}, child, onEvent = () => {}) {
|
||||||
try {
|
try {
|
||||||
run.onEvent({ kind: 'stopped', message: 'Codex stop requested.' })
|
run.onEvent({ kind: 'stopped', message: 'Codex stop requested.' })
|
||||||
} catch {}
|
} catch {}
|
||||||
try {
|
terminateProcessTree(run.child, 'SIGTERM')
|
||||||
run.child.kill('SIGTERM')
|
|
||||||
} catch {}
|
|
||||||
run.stopTimer = setTimeout(() => {
|
run.stopTimer = setTimeout(() => {
|
||||||
if (run.closed) return
|
if (run.closed) return
|
||||||
try {
|
terminateProcessTree(run.child, 'SIGKILL')
|
||||||
run.child.kill('SIGKILL')
|
|
||||||
} catch {}
|
|
||||||
}, CODEX_STOP_GRACE_MS)
|
}, CODEX_STOP_GRACE_MS)
|
||||||
return true
|
return true
|
||||||
},
|
},
|
||||||
|
|
@ -1049,6 +1065,13 @@ async function readConversations() {
|
||||||
function buildPrompt(payload) {
|
function buildPrompt(payload) {
|
||||||
const context = payload?.context && typeof payload.context === 'object' ? payload.context : {}
|
const context = payload?.context && typeof payload.context === 'object' ? payload.context : {}
|
||||||
const runProfile = payload?.runProfile && typeof payload.runProfile === 'object' ? payload.runProfile : {}
|
const runProfile = payload?.runProfile && typeof payload.runProfile === 'object' ? payload.runProfile : {}
|
||||||
|
const toolProfile = runProfile.toolProfile && typeof runProfile.toolProfile === 'object' ? runProfile.toolProfile : {}
|
||||||
|
const assistantActions = toolProfile.assistantActions && typeof toolProfile.assistantActions === 'object'
|
||||||
|
? toolProfile.assistantActions
|
||||||
|
: {}
|
||||||
|
const assistantActionIds = Array.isArray(assistantActions.actionIds)
|
||||||
|
? assistantActions.actionIds.map((item) => String(item || '').trim()).filter(Boolean)
|
||||||
|
: []
|
||||||
const runProfilePolicyPrompt = String(runProfile.policyPrompt || '').trim()
|
const runProfilePolicyPrompt = String(runProfile.policyPrompt || '').trim()
|
||||||
const userMessage = String(payload?.message || '').trim()
|
const userMessage = String(payload?.message || '').trim()
|
||||||
const history = payload?.resume ? [] : normalizeHistory(payload?.history || [])
|
const history = payload?.resume ? [] : normalizeHistory(payload?.history || [])
|
||||||
|
|
@ -1116,7 +1139,7 @@ function buildPrompt(payload) {
|
||||||
`- Selected agentNodeId: ${context.agentNodeId || 'unknown'}`,
|
`- Selected agentNodeId: ${context.agentNodeId || 'unknown'}`,
|
||||||
'- Read the second-level graph with GET /subworkflow?workflowId=<workflowId>&nodeId=<agentNodeId>.',
|
'- Read the second-level graph with GET /subworkflow?workflowId=<workflowId>&nodeId=<agentNodeId>.',
|
||||||
'- Apply graph edits with POST /subworkflow/patch using JSON: { workflowId, nodeId, intent, operations }.',
|
'- Apply graph edits with POST /subworkflow/patch using JSON: { workflowId, nodeId, intent, operations }.',
|
||||||
'- Prefer the MCP tools exposed by the ndc_agent_core server: ndc_get_context, ndc_get_subworkflow, ndc_search_nodes, ndc_get_node_definition, ndc_apply_subworkflow_patch, ndc_validate_subworkflow.',
|
'- Prefer the MCP tools exposed by the ndc_agent_core server: ndc_get_context, ndc_get_subworkflow, ndc_search_nodes, ndc_get_node_definition, ndc_apply_subworkflow_patch, ndc_validate_subworkflow, assistant_action_call.',
|
||||||
'- Do not use direct NDC core runtime MCP servers, local workflow files, or shell probes for graph edits in this mode.',
|
'- Do not use direct NDC core runtime MCP servers, local workflow files, or shell probes for graph edits in this mode.',
|
||||||
'- Use only ndc_agent_core MCP tools for the selected second-level workflow and stop after a cancellation, fetch, or policy error with the exact tool name and error.',
|
'- Use only ndc_agent_core MCP tools for the selected second-level workflow and stop after a cancellation, fetch, or policy error with the exact tool name and error.',
|
||||||
'- In public answers, use only NDC labels: NDC workflow, NDC node, NDC node type, NDC nodebase, and NDC Agent Core.',
|
'- In public answers, use only NDC labels: NDC workflow, NDC node, NDC node type, NDC nodebase, and NDC Agent Core.',
|
||||||
|
|
@ -1125,6 +1148,15 @@ function buildPrompt(payload) {
|
||||||
'- Prefer small patches and preserve existing node ids, node names, positions, parameters, and connections unless the user asks to change them.',
|
'- Prefer small patches and preserve existing node ids, node names, positions, parameters, and connections unless the user asks to change them.',
|
||||||
'- After saving through the Engine API, the open Engine canvas refreshes from dc.subworkflow.json; never edit local files or use shell fallbacks for graph changes in this mode.',
|
'- After saving through the Engine API, the open Engine canvas refreshes from dc.subworkflow.json; never edit local files or use shell fallbacks for graph changes in this mode.',
|
||||||
],
|
],
|
||||||
|
'',
|
||||||
|
'Assistant action routing contract:',
|
||||||
|
`- Assistant action ids available in this run: ${assistantActionIds.length ? assistantActionIds.join(', ') : 'none'}.`,
|
||||||
|
'- For Launcher/HUB/admin/access/users/invites/roles/service grants requests, use only the ndc_agent_core MCP tool assistant_action_call.',
|
||||||
|
'- Do not use codex_apps readonly connectors, read_handoff, local files, shell search, logs, or workspace scans for Launcher/HUB live administrative data.',
|
||||||
|
'- Use phase="execute" for read-only action calls after selecting the structured action id.',
|
||||||
|
'- Use phase="preview" before any privileged/write action, ask for explicit confirmation, then use phase="execute" only after confirmation.',
|
||||||
|
'- Useful read action ids when advertised: hub.access_request.list_pending, hub.invite.list_pending, hub.user.read_admin_summary.',
|
||||||
|
'- If assistant_action_call is unavailable or the gateway returns an error, report that exact tool/error and stop; do not guess from local files.',
|
||||||
] : [],
|
] : [],
|
||||||
...(isOpsMode || opsContext.opsWorkspaceSlug || opsContext.opsProjectId ? [
|
...(isOpsMode || opsContext.opsWorkspaceSlug || opsContext.opsProjectId ? [
|
||||||
'',
|
'',
|
||||||
|
|
@ -1134,8 +1166,12 @@ function buildPrompt(payload) {
|
||||||
`- Selected Ops project id: ${opsContext.opsProjectId || 'unknown'}`,
|
`- Selected Ops project id: ${opsContext.opsProjectId || 'unknown'}`,
|
||||||
`- Selected Ops project identifier: ${opsContext.opsProjectIdentifier || 'unknown'}`,
|
`- Selected Ops project identifier: ${opsContext.opsProjectIdentifier || 'unknown'}`,
|
||||||
'- Prefer the MCP tools exposed for NODE.DC Ops when creating, updating, moving, or reading tasks.',
|
'- Prefer the MCP tools exposed for NODE.DC Ops when creating, updating, moving, or reading tasks.',
|
||||||
|
'- If assistant action ids include ops.card.list_recent, ops.card.create, or ops.card.add_comment, these are the canonical Ops card actions for this run.',
|
||||||
|
'- Use assistant_action_call phase="execute" for Ops card reads after selecting ops.card.list_recent.',
|
||||||
|
'- Use assistant_action_call phase="preview" before Ops card create/comment writes, ask for explicit confirmation, then call phase="execute" with the returned confirmation token.',
|
||||||
'- Before writing Ops tasks, use the Ops MCP project/context tools when available and include a unique idempotency key for write tools.',
|
'- Before writing Ops tasks, use the Ops MCP project/context tools when available and include a unique idempotency key for write tools.',
|
||||||
'- If Ops MCP tools are unavailable in the Codex session, say that the Ops context is selected but the Ops MCP tools are unavailable.',
|
'- If direct Ops MCP tools are unavailable but the Ops assistant action ids are advertised, do not refuse; route through assistant_action_call.',
|
||||||
|
'- If neither direct Ops MCP tools nor Ops assistant action ids are available, say that the Ops context is selected but live Ops tools are unavailable.',
|
||||||
'- Do not claim that an Engine workflow or Engine agent node is required for Ops task writes.',
|
'- Do not claim that an Engine workflow or Engine agent node is required for Ops task writes.',
|
||||||
'- If the current request is about Ops tasks and Ops workspace/project are selected, treat that as the writable Ops target.',
|
'- If the current request is about Ops tasks and Ops workspace/project are selected, treat that as the writable Ops target.',
|
||||||
'- In public answers, use NODE.DC/Ops labels and never expose internal vendor names.',
|
'- In public answers, use NODE.DC/Ops labels and never expose internal vendor names.',
|
||||||
|
|
@ -1480,6 +1516,11 @@ function insertBeforePromptStdin(args, extras) {
|
||||||
|
|
||||||
function buildNdcAgentMcpContext(payload, cwd) {
|
function buildNdcAgentMcpContext(payload, cwd) {
|
||||||
const context = payload?.context && typeof payload.context === 'object' ? payload.context : {}
|
const context = payload?.context && typeof payload.context === 'object' ? payload.context : {}
|
||||||
|
const runProfile = payload?.runProfile && typeof payload.runProfile === 'object' ? payload.runProfile : {}
|
||||||
|
const toolProfile = runProfile.toolProfile && typeof runProfile.toolProfile === 'object' ? runProfile.toolProfile : {}
|
||||||
|
const assistantActions = toolProfile.assistantActions && typeof toolProfile.assistantActions === 'object'
|
||||||
|
? toolProfile.assistantActions
|
||||||
|
: {}
|
||||||
const apiBaseUrl = deriveNdcAgentMcpApiBaseUrl(context)
|
const apiBaseUrl = deriveNdcAgentMcpApiBaseUrl(context)
|
||||||
return {
|
return {
|
||||||
workflowId: String(context.workflowId || '').trim(),
|
workflowId: String(context.workflowId || '').trim(),
|
||||||
|
|
@ -1492,6 +1533,14 @@ function buildNdcAgentMcpContext(payload, cwd) {
|
||||||
schemaVersion: 'v2.3.2',
|
schemaVersion: 'v2.3.2',
|
||||||
n8nMcpRefPath: NDC_AGENT_MCP_REF_PATH,
|
n8nMcpRefPath: NDC_AGENT_MCP_REF_PATH,
|
||||||
workspacePath: cwd,
|
workspacePath: cwd,
|
||||||
|
assistantActions: {
|
||||||
|
actionIds: Array.isArray(assistantActions.actionIds)
|
||||||
|
? assistantActions.actionIds.map((item) => String(item || '').trim()).filter(Boolean)
|
||||||
|
: [],
|
||||||
|
},
|
||||||
|
assistantActionOwner: runProfile.owner && typeof runProfile.owner === 'object' ? runProfile.owner : {},
|
||||||
|
assistantActionGatewayUrl: String(assistantActions.gatewayUrl || '').trim(),
|
||||||
|
assistantActionGatewayToken: String(assistantActions.gatewayToken || '').trim(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1502,6 +1551,7 @@ function deriveNdcAgentMcpApiBaseUrl(context) {
|
||||||
if (!isHttpUrl(explicit)) return hubDerived || DEFAULT_NDC_AGENT_MCP_API_BASE
|
if (!isHttpUrl(explicit)) return hubDerived || DEFAULT_NDC_AGENT_MCP_API_BASE
|
||||||
if (hubDerived && isProtectedNodedcEngineUrl(explicit)) return hubDerived
|
if (hubDerived && isProtectedNodedcEngineUrl(explicit)) return hubDerived
|
||||||
if (isLoopbackUrl(explicit) && hubDerived) return hubDerived
|
if (isLoopbackUrl(explicit) && hubDerived) return hubDerived
|
||||||
|
if (isPrivateNetworkUrl(explicit) && hubDerived) return hubDerived
|
||||||
if (!explicit.endsWith('/api/ndc-agent-mcp')) return `${explicit.replace(/\/+$/, '')}/api/ndc-agent-mcp`
|
if (!explicit.endsWith('/api/ndc-agent-mcp')) return `${explicit.replace(/\/+$/, '')}/api/ndc-agent-mcp`
|
||||||
return explicit
|
return explicit
|
||||||
}
|
}
|
||||||
|
|
@ -1521,6 +1571,21 @@ function isLoopbackUrl(value) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isPrivateNetworkUrl(value) {
|
||||||
|
try {
|
||||||
|
const url = new URL(value)
|
||||||
|
const host = url.hostname.toLowerCase()
|
||||||
|
if (host === 'localhost' || host === '0.0.0.0' || host === '::1' || host.startsWith('127.')) return true
|
||||||
|
if (host.startsWith('192.168.')) return true
|
||||||
|
if (host.startsWith('10.')) return true
|
||||||
|
if (host.startsWith('169.254.')) return true
|
||||||
|
const match = host.match(/^172\.(\d+)\./)
|
||||||
|
return Boolean(match && Number(match[1]) >= 16 && Number(match[1]) <= 31)
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function isHttpUrl(value) {
|
function isHttpUrl(value) {
|
||||||
try {
|
try {
|
||||||
const url = new URL(value)
|
const url = new URL(value)
|
||||||
|
|
@ -1854,7 +1919,7 @@ async function prepareRunCodexHome({ payload = {}, cwd = CODEX_CWD, mcpContext =
|
||||||
: NDC_AGENT_CODEX_HOME
|
: NDC_AGENT_CODEX_HOME
|
||||||
await fs.mkdir(codexHome, { recursive: true })
|
await fs.mkdir(codexHome, { recursive: true })
|
||||||
await copyIfExists(path.join(CODEX_HOME, 'auth.json'), path.join(codexHome, 'auth.json'))
|
await copyIfExists(path.join(CODEX_HOME, 'auth.json'), path.join(codexHome, 'auth.json'))
|
||||||
const legacyOpsMcpConfig = needsNdcAgentCore && !hasDynamicMcp ? await readOptionalOpsMcpConfig() : ''
|
const legacyOpsMcpConfig = ''
|
||||||
const config = [
|
const config = [
|
||||||
runtimeCodexConfig(),
|
runtimeCodexConfig(),
|
||||||
needsNdcAgentCore ? ndcAgentMcpServerConfig(mcpContext, cwd) : '',
|
needsNdcAgentCore ? ndcAgentMcpServerConfig(mcpContext, cwd) : '',
|
||||||
|
|
@ -1878,6 +1943,8 @@ async function codexInvocationForPayload(baseArgs, payload, cwd) {
|
||||||
NDC_AGENT_MCP_ROOT: CODEX_CWD,
|
NDC_AGENT_MCP_ROOT: CODEX_CWD,
|
||||||
NDC_AGENT_MCP_CONTEXT: JSON.stringify(mcpContext),
|
NDC_AGENT_MCP_CONTEXT: JSON.stringify(mcpContext),
|
||||||
NDC_AGENT_MCP_API_BASE_URL: mcpContext.ndcAgentMcpApiBaseUrl,
|
NDC_AGENT_MCP_API_BASE_URL: mcpContext.ndcAgentMcpApiBaseUrl,
|
||||||
|
NDC_AGENT_MCP_FETCH_TIMEOUT_MS: process.env.NDC_AGENT_MCP_FETCH_TIMEOUT_MS || '30000',
|
||||||
|
AI_WORKSPACE_ASSISTANT_ACTION_FETCH_TIMEOUT_MS: process.env.AI_WORKSPACE_ASSISTANT_ACTION_FETCH_TIMEOUT_MS || '45000',
|
||||||
AI_BRIDGE_PAIRING_CODE: PAIRING_CODE,
|
AI_BRIDGE_PAIRING_CODE: PAIRING_CODE,
|
||||||
} : {}),
|
} : {}),
|
||||||
},
|
},
|
||||||
|
|
@ -2399,7 +2466,7 @@ function runCodex(prompt, args = CODEX_ARGS, onEvent = () => {}, timeoutMs = MES
|
||||||
const timeout = setTimeout(() => {
|
const timeout = setTimeout(() => {
|
||||||
killed = true
|
killed = true
|
||||||
onEvent({ kind: 'timeout', message: 'Codex process timed out.' })
|
onEvent({ kind: 'timeout', message: 'Codex process timed out.' })
|
||||||
child.kill('SIGTERM')
|
terminateProcessTree(child, 'SIGTERM')
|
||||||
}, timeoutMs)
|
}, timeoutMs)
|
||||||
|
|
||||||
child.stdout.on('data', (chunk) => {
|
child.stdout.on('data', (chunk) => {
|
||||||
|
|
@ -2431,7 +2498,7 @@ function runCodex(prompt, args = CODEX_ARGS, onEvent = () => {}, timeoutMs = MES
|
||||||
plainStdout += text
|
plainStdout += text
|
||||||
onEvent({ kind: 'stdout', stream: 'stdout', text })
|
onEvent({ kind: 'stdout', stream: 'stdout', text })
|
||||||
}
|
}
|
||||||
if (Buffer.byteLength(stdout) > MAX_STDIO_BYTES) child.kill('SIGTERM')
|
if (Buffer.byteLength(stdout) > MAX_STDIO_BYTES) terminateProcessTree(child, 'SIGTERM')
|
||||||
})
|
})
|
||||||
child.stderr.on('data', (chunk) => {
|
child.stderr.on('data', (chunk) => {
|
||||||
const text = String(chunk)
|
const text = String(chunk)
|
||||||
|
|
@ -2441,7 +2508,7 @@ function runCodex(prompt, args = CODEX_ARGS, onEvent = () => {}, timeoutMs = MES
|
||||||
}
|
}
|
||||||
stderr += text
|
stderr += text
|
||||||
onEvent({ kind: 'stderr', stream: 'stderr', text: sanitizeCodexErrorText(text) })
|
onEvent({ kind: 'stderr', stream: 'stderr', text: sanitizeCodexErrorText(text) })
|
||||||
if (Buffer.byteLength(stderr) > MAX_STDIO_BYTES) child.kill('SIGTERM')
|
if (Buffer.byteLength(stderr) > MAX_STDIO_BYTES) terminateProcessTree(child, 'SIGTERM')
|
||||||
})
|
})
|
||||||
child.on('error', (error) => {
|
child.on('error', (error) => {
|
||||||
clearTimeout(timeout)
|
clearTimeout(timeout)
|
||||||
|
|
@ -2927,6 +2994,8 @@ const ROOT = process.env.NDC_AGENT_MCP_ROOT || process.cwd()
|
||||||
const DEFAULT_API_BASE = 'http://127.0.0.1:3001/api/ndc-agent-mcp'
|
const DEFAULT_API_BASE = 'http://127.0.0.1:3001/api/ndc-agent-mcp'
|
||||||
const DEFAULT_SCHEMA_VERSION = 'v2.3.2'
|
const DEFAULT_SCHEMA_VERSION = 'v2.3.2'
|
||||||
const ENGINE_FETCH_TIMEOUT_MS = Number(process.env.NDC_AGENT_MCP_FETCH_TIMEOUT_MS || 8000)
|
const ENGINE_FETCH_TIMEOUT_MS = Number(process.env.NDC_AGENT_MCP_FETCH_TIMEOUT_MS || 8000)
|
||||||
|
const DEFAULT_ASSISTANT_ACTION_GATEWAY_PATH = '/api/ai-workspace/assistant/v1/actions'
|
||||||
|
const ASSISTANT_ACTION_FETCH_TIMEOUT_MS = Number(process.env.AI_WORKSPACE_ASSISTANT_ACTION_FETCH_TIMEOUT_MS || ENGINE_FETCH_TIMEOUT_MS)
|
||||||
const context = parseContext()
|
const context = parseContext()
|
||||||
const nodeCatalogCache = new Map()
|
const nodeCatalogCache = new Map()
|
||||||
let transportMode = null
|
let transportMode = null
|
||||||
|
|
@ -3025,8 +3094,13 @@ function parseContext() {
|
||||||
if (raw) {
|
if (raw) {
|
||||||
try { parsed = JSON.parse(raw) || {} } catch {}
|
try { parsed = JSON.parse(raw) || {} } catch {}
|
||||||
}
|
}
|
||||||
|
const assistantActions = parseObject(
|
||||||
|
parsed.assistantActions ||
|
||||||
|
process.env.AI_WORKSPACE_ASSISTANT_ACTIONS ||
|
||||||
|
{},
|
||||||
|
)
|
||||||
return {
|
return {
|
||||||
modeId: 'ndc-agent-core',
|
modeId: cleanString(parsed.modeId || 'ndc-agent-core'),
|
||||||
workflowId: cleanString(parsed.workflowId || process.env.NDC_AGENT_MCP_WORKFLOW_ID || ''),
|
workflowId: cleanString(parsed.workflowId || process.env.NDC_AGENT_MCP_WORKFLOW_ID || ''),
|
||||||
workflowTitle: cleanString(parsed.workflowTitle || process.env.NDC_AGENT_MCP_WORKFLOW_TITLE || ''),
|
workflowTitle: cleanString(parsed.workflowTitle || process.env.NDC_AGENT_MCP_WORKFLOW_TITLE || ''),
|
||||||
agentNodeId: cleanString(parsed.agentNodeId || process.env.NDC_AGENT_MCP_NODE_ID || ''),
|
agentNodeId: cleanString(parsed.agentNodeId || process.env.NDC_AGENT_MCP_NODE_ID || ''),
|
||||||
|
|
@ -3036,6 +3110,20 @@ function parseContext() {
|
||||||
apiBaseUrl: cleanApiBase(parsed.ndcAgentMcpApiBaseUrl || process.env.NDC_AGENT_MCP_API_BASE_URL || DEFAULT_API_BASE),
|
apiBaseUrl: cleanApiBase(parsed.ndcAgentMcpApiBaseUrl || process.env.NDC_AGENT_MCP_API_BASE_URL || DEFAULT_API_BASE),
|
||||||
schemaVersion: cleanString(parsed.schemaVersion || process.env.NDC_AGENT_MCP_SCHEMA_VERSION || DEFAULT_SCHEMA_VERSION),
|
schemaVersion: cleanString(parsed.schemaVersion || process.env.NDC_AGENT_MCP_SCHEMA_VERSION || DEFAULT_SCHEMA_VERSION),
|
||||||
n8nMcpRefPath: cleanString(parsed.n8nMcpRefPath || process.env.NDC_AGENT_MCP_REF_PATH || path.resolve(ROOT, '..', 'tools', 'NDCMCP')),
|
n8nMcpRefPath: cleanString(parsed.n8nMcpRefPath || process.env.NDC_AGENT_MCP_REF_PATH || path.resolve(ROOT, '..', 'tools', 'NDCMCP')),
|
||||||
|
assistantActions,
|
||||||
|
assistantActionOwner: parseObject(parsed.assistantActionOwner || {}),
|
||||||
|
assistantActionGatewayUrl: cleanHttpUrl(
|
||||||
|
parsed.assistantActionGatewayUrl ||
|
||||||
|
process.env.AI_WORKSPACE_ASSISTANT_ACTION_GATEWAY_URL ||
|
||||||
|
deriveAssistantActionGatewayUrlFromHub() ||
|
||||||
|
'',
|
||||||
|
),
|
||||||
|
assistantActionGatewayToken: cleanString(
|
||||||
|
parsed.assistantActionGatewayToken ||
|
||||||
|
process.env.AI_WORKSPACE_ASSISTANT_ACTION_GATEWAY_TOKEN ||
|
||||||
|
'',
|
||||||
|
4000,
|
||||||
|
),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -3043,6 +3131,32 @@ function cleanString(value, max = 1000) {
|
||||||
return String(value || '').trim().slice(0, max)
|
return String(value || '').trim().slice(0, max)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function parseObject(value, fallback = {}) {
|
||||||
|
if (value && typeof value === 'object' && !Array.isArray(value)) return value
|
||||||
|
const text = String(value || '').trim()
|
||||||
|
if (!text) return fallback
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(text)
|
||||||
|
return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : fallback
|
||||||
|
} catch {
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function cleanHttpUrl(value) {
|
||||||
|
const text = cleanString(value, 2000).replace(/\/+$/, '')
|
||||||
|
if (!text) return ''
|
||||||
|
try {
|
||||||
|
const url = new URL(text)
|
||||||
|
if (url.protocol !== 'http:' && url.protocol !== 'https:') return ''
|
||||||
|
url.username = ''
|
||||||
|
url.password = ''
|
||||||
|
return url.toString().replace(/\/+$/, '')
|
||||||
|
} catch {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function cleanApiBase(value) {
|
function cleanApiBase(value) {
|
||||||
const raw = cleanString(value || DEFAULT_API_BASE)
|
const raw = cleanString(value || DEFAULT_API_BASE)
|
||||||
return raw.replace(/\/+$/, '') || DEFAULT_API_BASE
|
return raw.replace(/\/+$/, '') || DEFAULT_API_BASE
|
||||||
|
|
@ -3069,6 +3183,20 @@ function isLoopbackUrl(value) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isPrivateNetworkUrl(value) {
|
||||||
|
try {
|
||||||
|
const host = new URL(value).hostname.toLowerCase()
|
||||||
|
if (host === 'localhost' || host === '0.0.0.0' || host === '::1' || host.startsWith('127.')) return true
|
||||||
|
if (host.startsWith('192.168.')) return true
|
||||||
|
if (host.startsWith('10.')) return true
|
||||||
|
if (host.startsWith('169.254.')) return true
|
||||||
|
const match = host.match(/^172\.(\d+)\./)
|
||||||
|
return Boolean(match && Number(match[1]) >= 16 && Number(match[1]) <= 31)
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function hubOriginToApiBase(value) {
|
function hubOriginToApiBase(value) {
|
||||||
try {
|
try {
|
||||||
const url = new URL(cleanString(value, 1000))
|
const url = new URL(cleanString(value, 1000))
|
||||||
|
|
@ -3091,6 +3219,32 @@ function hubOriginToApiBase(value) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function hubOriginToAssistantActionGateway(value) {
|
||||||
|
try {
|
||||||
|
const url = new URL(cleanString(value, 1000))
|
||||||
|
const host = url.hostname.toLowerCase()
|
||||||
|
if (!host) return ''
|
||||||
|
if (url.protocol === 'wss:') url.protocol = 'https:'
|
||||||
|
else if (url.protocol === 'ws:') url.protocol = 'http:'
|
||||||
|
else if (url.protocol !== 'http:' && url.protocol !== 'https:') return ''
|
||||||
|
url.pathname = ''
|
||||||
|
url.search = ''
|
||||||
|
url.hash = ''
|
||||||
|
const origin = url.toString().replace(/\/+$/, '')
|
||||||
|
return `${origin}${DEFAULT_ASSISTANT_ACTION_GATEWAY_PATH}`
|
||||||
|
} catch {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function deriveAssistantActionGatewayUrlFromHub() {
|
||||||
|
const candidates = [
|
||||||
|
hubOriginToAssistantActionGateway(process.env.AI_BRIDGE_HUB_URL),
|
||||||
|
...splitList(process.env.AI_BRIDGE_HUB_URLS).map(hubOriginToAssistantActionGateway),
|
||||||
|
].filter(Boolean)
|
||||||
|
return uniqueStrings(candidates)[0] || ''
|
||||||
|
}
|
||||||
|
|
||||||
function cleanPairingCode(value) {
|
function cleanPairingCode(value) {
|
||||||
return cleanString(value, 100).toUpperCase().replace(/[^A-Z0-9]/g, '').slice(0, 32)
|
return cleanString(value, 100).toUpperCase().replace(/[^A-Z0-9]/g, '').slice(0, 32)
|
||||||
}
|
}
|
||||||
|
|
@ -3106,10 +3260,10 @@ function engineApiBaseCandidates() {
|
||||||
...splitList(process.env.AI_BRIDGE_HUB_URLS).map(hubOriginToApiBase),
|
...splitList(process.env.AI_BRIDGE_HUB_URLS).map(hubOriginToApiBase),
|
||||||
]
|
]
|
||||||
const envBase = cleanString(process.env.NDC_AGENT_MCP_API_BASE_URL, 1000).replace(/\/+$/, '')
|
const envBase = cleanString(process.env.NDC_AGENT_MCP_API_BASE_URL, 1000).replace(/\/+$/, '')
|
||||||
if (explicit && !isLoopbackUrl(explicit)) {
|
if (explicit && !isPrivateNetworkUrl(explicit)) {
|
||||||
return uniqueStrings([explicit, envBase])
|
return uniqueStrings([explicit, envBase])
|
||||||
}
|
}
|
||||||
if (envBase && !isLoopbackUrl(envBase)) {
|
if (envBase && !isPrivateNetworkUrl(envBase)) {
|
||||||
return uniqueStrings([envBase, explicit])
|
return uniqueStrings([envBase, explicit])
|
||||||
}
|
}
|
||||||
if (hubBases.some(Boolean)) {
|
if (hubBases.some(Boolean)) {
|
||||||
|
|
@ -3191,6 +3345,88 @@ async function loadN8nMcpVersion() {
|
||||||
return pkg?.version ? String(pkg.version) : '2.33.2'
|
return pkg?.version ? String(pkg.version) : '2.33.2'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function assistantActionGatewayEndpoint() {
|
||||||
|
const base = cleanHttpUrl(context.assistantActionGatewayUrl)
|
||||||
|
if (!base) return ''
|
||||||
|
if (base.endsWith(DEFAULT_ASSISTANT_ACTION_GATEWAY_PATH) || base.endsWith('/actions')) return base
|
||||||
|
return `${base}${DEFAULT_ASSISTANT_ACTION_GATEWAY_PATH}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function assistantActionIds() {
|
||||||
|
return Array.isArray(context.assistantActions?.actionIds)
|
||||||
|
? context.assistantActions.actionIds.map((item) => cleanString(item, 200)).filter(Boolean)
|
||||||
|
: []
|
||||||
|
}
|
||||||
|
|
||||||
|
function assistantActionOwnerHeaders() {
|
||||||
|
const owner = parseObject(context.assistantActionOwner, {})
|
||||||
|
const headers = {}
|
||||||
|
const put = (name, value) => {
|
||||||
|
const text = cleanString(value, 1000)
|
||||||
|
if (text) headers[name] = text
|
||||||
|
}
|
||||||
|
put('x-nodedc-user-id', owner.userId || owner.user_id)
|
||||||
|
put('x-nodedc-user-email', owner.email)
|
||||||
|
put('x-nodedc-user-role', owner.role)
|
||||||
|
const groups = Array.isArray(owner.groups)
|
||||||
|
? owner.groups.map((item) => cleanString(item, 120)).filter(Boolean).join(',')
|
||||||
|
: owner.groups
|
||||||
|
put('x-nodedc-user-groups', groups)
|
||||||
|
return headers
|
||||||
|
}
|
||||||
|
|
||||||
|
async function assistantActionFetch(payload = {}) {
|
||||||
|
const endpoint = assistantActionGatewayEndpoint()
|
||||||
|
const token = context.assistantActionGatewayToken
|
||||||
|
if (!endpoint || !token) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
decision: 'assistant_action_gateway_unavailable',
|
||||||
|
reason: 'Assistant action gateway URL/token is not configured for this run.',
|
||||||
|
actionIds: assistantActionIds(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let res = null
|
||||||
|
let text = ''
|
||||||
|
let json = null
|
||||||
|
try {
|
||||||
|
res = await fetch(endpoint, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
Accept: 'application/json',
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
...assistantActionOwnerHeaders(),
|
||||||
|
},
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
signal: AbortSignal.timeout(ASSISTANT_ACTION_FETCH_TIMEOUT_MS),
|
||||||
|
})
|
||||||
|
text = await res.text().catch(() => '')
|
||||||
|
try { json = text ? JSON.parse(text) : null } catch {}
|
||||||
|
} catch (error) {
|
||||||
|
const out = new Error(`assistant_action_gateway_fetch_failed:${fetchFailureText(error)}`)
|
||||||
|
out.payload = { decision: 'assistant_action_gateway_fetch_failed' }
|
||||||
|
throw out
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!res.ok || json?.ok === false) {
|
||||||
|
const out = new Error(json?.message || json?.error || `assistant_action_http_${res.status}`)
|
||||||
|
out.status = res.status
|
||||||
|
out.payload = json && typeof json === 'object'
|
||||||
|
? json
|
||||||
|
: { status: res.status, preview: cleanString(text, 400) }
|
||||||
|
throw out
|
||||||
|
}
|
||||||
|
if (!json || typeof json !== 'object') {
|
||||||
|
const out = new Error(`assistant_action_non_json_response:${res.status}`)
|
||||||
|
out.status = 502
|
||||||
|
out.payload = { status: res.status, preview: cleanString(text, 400) }
|
||||||
|
throw out
|
||||||
|
}
|
||||||
|
return json
|
||||||
|
}
|
||||||
|
|
||||||
function engineApiUrl(pathname) {
|
function engineApiUrl(pathname) {
|
||||||
return engineApiUrls(pathname)[0] || ''
|
return engineApiUrls(pathname)[0] || ''
|
||||||
}
|
}
|
||||||
|
|
@ -3365,6 +3601,7 @@ async function handleGetContext() {
|
||||||
...context,
|
...context,
|
||||||
apiBaseCandidates: engineApiBaseCandidates(),
|
apiBaseCandidates: engineApiBaseCandidates(),
|
||||||
n8nMcpVersion,
|
n8nMcpVersion,
|
||||||
|
assistantActionGatewayConfigured: Boolean(context.assistantActionGatewayUrl && context.assistantActionGatewayToken),
|
||||||
contract: {
|
contract: {
|
||||||
sourceOfTruth: 'Engine second-level dc.subworkflow.json',
|
sourceOfTruth: 'Engine second-level dc.subworkflow.json',
|
||||||
writeApi: `${context.apiBaseUrl}/subworkflow/patch`,
|
writeApi: `${context.apiBaseUrl}/subworkflow/patch`,
|
||||||
|
|
@ -3375,6 +3612,40 @@ async function handleGetContext() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handleAssistantActionCall(args = {}) {
|
||||||
|
const phase = cleanString(args.phase || args.mode || 'preview', 40)
|
||||||
|
if (!['preview', 'dry-run', 'execute'].includes(phase)) throw new Error('assistant_action_phase_invalid')
|
||||||
|
|
||||||
|
const input = parseObject(args.input || {}, {})
|
||||||
|
const actionId = cleanString(args.actionId || input.actionId || '', 240)
|
||||||
|
const intent = cleanString(args.intent || input.intent || '', 2000)
|
||||||
|
if (actionId) input.actionId = actionId
|
||||||
|
if (intent) input.intent = intent
|
||||||
|
|
||||||
|
const availableIds = assistantActionIds()
|
||||||
|
if (actionId && availableIds.length && !availableIds.includes(actionId)) {
|
||||||
|
throw new Error(`assistant_action_not_advertised:${actionId}`)
|
||||||
|
}
|
||||||
|
if (!actionId && !intent) {
|
||||||
|
throw new Error('assistant_action_input_required')
|
||||||
|
}
|
||||||
|
|
||||||
|
const confirmationToken = cleanString(
|
||||||
|
args.confirmationToken ||
|
||||||
|
args.confirmation?.token ||
|
||||||
|
input.confirmationToken ||
|
||||||
|
'',
|
||||||
|
2000,
|
||||||
|
)
|
||||||
|
if (confirmationToken) input.confirmationToken = confirmationToken
|
||||||
|
|
||||||
|
return assistantActionFetch({
|
||||||
|
phase,
|
||||||
|
input,
|
||||||
|
...(confirmationToken ? { confirmationToken } : {}),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
async function handleGetSubworkflow(args) {
|
async function handleGetSubworkflow(args) {
|
||||||
const target = targetFromArgs(args)
|
const target = targetFromArgs(args)
|
||||||
const q = new URLSearchParams(target)
|
const q = new URLSearchParams(target)
|
||||||
|
|
@ -3571,6 +3842,22 @@ const tools = [
|
||||||
additionalProperties: false,
|
additionalProperties: false,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: 'assistant_action_call',
|
||||||
|
description: 'Call the NODE.DC assistant action layer after interpreting user intent. Use execute for read actions after structured action selection; use preview before any privileged/write action and execute only after explicit confirmation.',
|
||||||
|
inputSchema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
phase: { type: 'string', enum: ['preview', 'dry-run', 'execute'] },
|
||||||
|
actionId: { type: 'string' },
|
||||||
|
intent: { type: 'string' },
|
||||||
|
input: { type: 'object', additionalProperties: true },
|
||||||
|
confirmationToken: { type: 'string' },
|
||||||
|
confirmation: { type: 'object', additionalProperties: true },
|
||||||
|
},
|
||||||
|
additionalProperties: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
async function callTool(name, args) {
|
async function callTool(name, args) {
|
||||||
|
|
@ -3580,6 +3867,7 @@ async function callTool(name, args) {
|
||||||
if (name === 'ndc_search_nodes') return handleSearchNodes(args)
|
if (name === 'ndc_search_nodes') return handleSearchNodes(args)
|
||||||
if (name === 'ndc_get_node_definition') return handleGetNodeDefinition(args)
|
if (name === 'ndc_get_node_definition') return handleGetNodeDefinition(args)
|
||||||
if (name === 'ndc_validate_subworkflow') return handleValidateSubworkflow(args)
|
if (name === 'ndc_validate_subworkflow') return handleValidateSubworkflow(args)
|
||||||
|
if (name === 'assistant_action_call') return handleAssistantActionCall(args)
|
||||||
throw new Error(`unknown_tool:${name}`)
|
throw new Error(`unknown_tool:${name}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -8,12 +8,17 @@ const MAX_EVENTS_PER_AGENT = 5000;
|
||||||
const MAX_REQUEST_META_PER_AGENT = 1000;
|
const MAX_REQUEST_META_PER_AGENT = 1000;
|
||||||
const HEARTBEAT_INTERVAL_MS = 30000;
|
const HEARTBEAT_INTERVAL_MS = 30000;
|
||||||
const AGENT_STALE_MS = 75000;
|
const AGENT_STALE_MS = 75000;
|
||||||
|
const ASSISTANT_RELAY_POLL_TIMEOUT_MS = Number(process.env.AI_WORKSPACE_ASSISTANT_RELAY_POLL_TIMEOUT_MS || 25000);
|
||||||
|
const ASSISTANT_RELAY_ACTION_TIMEOUT_MS = Number(process.env.AI_WORKSPACE_ASSISTANT_RELAY_ACTION_TIMEOUT_MS || 45000);
|
||||||
|
const ASSISTANT_RELAY_MAX_PENDING = Number(process.env.AI_WORKSPACE_ASSISTANT_RELAY_MAX_PENDING || 200);
|
||||||
|
const ASSISTANT_RELAY_MAX_BATCH = Number(process.env.AI_WORKSPACE_ASSISTANT_RELAY_MAX_BATCH || 10);
|
||||||
|
|
||||||
const config = readConfig();
|
const config = readConfig();
|
||||||
const app = express();
|
const app = express();
|
||||||
const httpServer = createServer(app);
|
const httpServer = createServer(app);
|
||||||
const wss = new WebSocketServer({ noServer: true });
|
const wss = new WebSocketServer({ noServer: true });
|
||||||
const agentsByCode = new Map();
|
const agentsByCode = new Map();
|
||||||
|
const assistantRelaysById = new Map();
|
||||||
let eventSeq = 0;
|
let eventSeq = 0;
|
||||||
|
|
||||||
app.disable("x-powered-by");
|
app.disable("x-powered-by");
|
||||||
|
|
@ -25,6 +30,7 @@ app.get("/healthz", (_req, res) => {
|
||||||
service: "nodedc-ai-workspace-hub",
|
service: "nodedc-ai-workspace-hub",
|
||||||
wsPath: config.wsPath,
|
wsPath: config.wsPath,
|
||||||
agentsOnline: Array.from(agentsByCode.values()).filter(isAgentOnline).length,
|
agentsOnline: Array.from(agentsByCode.values()).filter(isAgentOnline).length,
|
||||||
|
assistantRelays: assistantRelaysById.size,
|
||||||
internalApiConfigured: config.internalAccessTokens.length > 0,
|
internalApiConfigured: config.internalAccessTokens.length > 0,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
@ -60,6 +66,12 @@ app.post("/api/ai-workspace/hub/v1/agents/:pairingCode/dispatch", requireInterna
|
||||||
res.json({ ok: true, requestId: dispatch.requestId });
|
res.json({ ok: true, requestId: dispatch.requestId });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
app.post("/api/ai-workspace/assistant/v1/actions", requireInternalApi, asyncRoute(proxyAssistantActions));
|
||||||
|
|
||||||
|
app.post("/api/ai-workspace/hub/v1/assistant-relays/:relayId/actions", requireInternalApi, asyncRoute(callAssistantRelayAction));
|
||||||
|
app.post("/api/ai-workspace/hub/v1/assistant-relays/:relayId/poll", requireInternalApi, asyncRoute(pollAssistantRelay));
|
||||||
|
app.post("/api/ai-workspace/hub/v1/assistant-relays/:relayId/results/:callId", requireInternalApi, asyncRoute(completeAssistantRelayCall));
|
||||||
|
|
||||||
app.use("/api/ai-workspace/hub/v1/ndc-agent-mcp/:pairingCode", asyncRoute(proxyNdcAgentMcp));
|
app.use("/api/ai-workspace/hub/v1/ndc-agent-mcp/:pairingCode", asyncRoute(proxyNdcAgentMcp));
|
||||||
|
|
||||||
app.use((error, _req, res, _next) => {
|
app.use((error, _req, res, _next) => {
|
||||||
|
|
@ -256,6 +268,179 @@ async function proxyNdcAgentMcp(req, res) {
|
||||||
res.send(text);
|
res.send(text);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function proxyAssistantActions(req, res) {
|
||||||
|
if (!config.assistantInternalUrl || !config.assistantInternalAccessToken) {
|
||||||
|
res.status(503).json({ ok: false, error: "assistant_action_proxy_not_configured" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const targetUrl = `${config.assistantInternalUrl.replace(/\/+$/, "")}/api/ai-workspace/assistant/v1/actions`;
|
||||||
|
const upstream = await fetch(targetUrl, {
|
||||||
|
method: "POST",
|
||||||
|
redirect: "manual",
|
||||||
|
headers: {
|
||||||
|
Accept: "application/json",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
Authorization: `Bearer ${config.assistantInternalAccessToken}`,
|
||||||
|
...forwardOwnerHeaders(req),
|
||||||
|
},
|
||||||
|
body: JSON.stringify(req.body || {}),
|
||||||
|
});
|
||||||
|
const contentType = upstream.headers.get("content-type") || "application/json; charset=utf-8";
|
||||||
|
const text = await upstream.text();
|
||||||
|
res.status(upstream.status);
|
||||||
|
res.setHeader("content-type", contentType);
|
||||||
|
res.send(text);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function callAssistantRelayAction(req, res) {
|
||||||
|
const relayId = cleanRelayId(req.params.relayId);
|
||||||
|
if (!relayId) {
|
||||||
|
res.status(400).json({ ok: false, error: "assistant_relay_id_required" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const relay = getAssistantRelay(relayId);
|
||||||
|
pruneAssistantRelay(relay);
|
||||||
|
if (relay.calls.size >= ASSISTANT_RELAY_MAX_PENDING) {
|
||||||
|
res.status(429).json({ ok: false, error: "assistant_relay_backpressure" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const callId = crypto.randomUUID();
|
||||||
|
const timeoutMs = sanitizeTimeoutMs(req.body?.timeoutMs || req.query?.timeoutMs || ASSISTANT_RELAY_ACTION_TIMEOUT_MS, ASSISTANT_RELAY_ACTION_TIMEOUT_MS);
|
||||||
|
const createdAt = new Date().toISOString();
|
||||||
|
const call = {
|
||||||
|
callId,
|
||||||
|
relayId,
|
||||||
|
createdAt,
|
||||||
|
payload: req.body || {},
|
||||||
|
headers: forwardOwnerHeaders(req),
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await new Promise((resolve, reject) => {
|
||||||
|
const timeout = setTimeout(() => {
|
||||||
|
relay.calls.delete(callId);
|
||||||
|
relay.pending = relay.pending.filter((item) => item.callId !== callId);
|
||||||
|
const error = new Error("assistant_relay_timeout");
|
||||||
|
error.status = 504;
|
||||||
|
reject(error);
|
||||||
|
}, timeoutMs);
|
||||||
|
timeout.unref?.();
|
||||||
|
relay.calls.set(callId, { resolve, reject, timeout, createdAt: Date.now() });
|
||||||
|
relay.pending.push(call);
|
||||||
|
notifyAssistantRelayWaiters(relay);
|
||||||
|
});
|
||||||
|
|
||||||
|
res.status(result.status).json(result.body);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function pollAssistantRelay(req, res) {
|
||||||
|
const relayId = cleanRelayId(req.params.relayId);
|
||||||
|
if (!relayId) {
|
||||||
|
res.status(400).json({ ok: false, error: "assistant_relay_id_required" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const relay = getAssistantRelay(relayId);
|
||||||
|
relay.lastSeenAt = new Date().toISOString();
|
||||||
|
const limit = sanitizeInteger(req.body?.limit, ASSISTANT_RELAY_MAX_BATCH, 1, ASSISTANT_RELAY_MAX_BATCH);
|
||||||
|
const timeoutMs = sanitizeTimeoutMs(req.body?.timeoutMs || req.query?.timeoutMs || ASSISTANT_RELAY_POLL_TIMEOUT_MS, ASSISTANT_RELAY_POLL_TIMEOUT_MS);
|
||||||
|
const calls = await waitForAssistantRelayCalls(relay, limit, timeoutMs);
|
||||||
|
res.json({ ok: true, relayId, calls });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function completeAssistantRelayCall(req, res) {
|
||||||
|
const relayId = cleanRelayId(req.params.relayId);
|
||||||
|
const callId = cleanString(req.params.callId, 120);
|
||||||
|
const relay = assistantRelaysById.get(relayId);
|
||||||
|
const pending = relay?.calls.get(callId);
|
||||||
|
if (!relay || !pending) {
|
||||||
|
res.status(404).json({ ok: false, error: "assistant_relay_call_not_found" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
relay.calls.delete(callId);
|
||||||
|
clearTimeout(pending.timeout);
|
||||||
|
pending.resolve({
|
||||||
|
status: sanitizeHttpStatus(req.body?.status),
|
||||||
|
body: req.body?.body === undefined ? { ok: true } : req.body.body,
|
||||||
|
});
|
||||||
|
res.json({ ok: true, relayId, callId });
|
||||||
|
}
|
||||||
|
|
||||||
|
function getAssistantRelay(relayId) {
|
||||||
|
const cleanId = cleanRelayId(relayId);
|
||||||
|
let relay = assistantRelaysById.get(cleanId);
|
||||||
|
if (!relay) {
|
||||||
|
relay = {
|
||||||
|
relayId: cleanId,
|
||||||
|
pending: [],
|
||||||
|
waiters: [],
|
||||||
|
calls: new Map(),
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
lastSeenAt: "",
|
||||||
|
};
|
||||||
|
assistantRelaysById.set(cleanId, relay);
|
||||||
|
}
|
||||||
|
return relay;
|
||||||
|
}
|
||||||
|
|
||||||
|
function waitForAssistantRelayCalls(relay, limit, timeoutMs) {
|
||||||
|
const ready = takeAssistantRelayCalls(relay, limit);
|
||||||
|
if (ready.length) return Promise.resolve(ready);
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const timeout = setTimeout(() => {
|
||||||
|
relay.waiters = relay.waiters.filter((waiter) => waiter.resolve !== resolve);
|
||||||
|
resolve([]);
|
||||||
|
}, timeoutMs);
|
||||||
|
timeout.unref?.();
|
||||||
|
relay.waiters.push({ resolve, timeout, limit });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function notifyAssistantRelayWaiters(relay) {
|
||||||
|
while (relay.waiters.length && relay.pending.length) {
|
||||||
|
const waiter = relay.waiters.shift();
|
||||||
|
clearTimeout(waiter.timeout);
|
||||||
|
waiter.resolve(takeAssistantRelayCalls(relay, waiter.limit));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function takeAssistantRelayCalls(relay, limit) {
|
||||||
|
const calls = [];
|
||||||
|
while (relay.pending.length && calls.length < limit) {
|
||||||
|
const call = relay.pending.shift();
|
||||||
|
if (relay.calls.has(call.callId)) calls.push(call);
|
||||||
|
}
|
||||||
|
return calls;
|
||||||
|
}
|
||||||
|
|
||||||
|
function pruneAssistantRelay(relay) {
|
||||||
|
relay.pending = relay.pending.filter((call) => relay.calls.has(call.callId));
|
||||||
|
}
|
||||||
|
|
||||||
|
function cleanRelayId(value) {
|
||||||
|
return String(value || "").trim().replace(/[^A-Za-z0-9_.:-]/g, "").slice(0, 120);
|
||||||
|
}
|
||||||
|
|
||||||
|
function sanitizeHttpStatus(value) {
|
||||||
|
const status = Number(value || 200);
|
||||||
|
return Number.isInteger(status) && status >= 100 && status <= 599 ? status : 200;
|
||||||
|
}
|
||||||
|
|
||||||
|
function forwardOwnerHeaders(req) {
|
||||||
|
const headers = {};
|
||||||
|
for (const name of [
|
||||||
|
"x-nodedc-user-id",
|
||||||
|
"x-nodedc-user-email",
|
||||||
|
"x-nodedc-user-role",
|
||||||
|
"x-nodedc-user-groups",
|
||||||
|
]) {
|
||||||
|
const value = req.headers[name];
|
||||||
|
if (typeof value === "string" && value.trim()) headers[name] = value.trim();
|
||||||
|
}
|
||||||
|
return headers;
|
||||||
|
}
|
||||||
|
|
||||||
function startAgentRequest(pairingCodeRaw, command, payload = {}, timeoutMs = 30000, options = {}) {
|
function startAgentRequest(pairingCodeRaw, command, payload = {}, timeoutMs = 30000, options = {}) {
|
||||||
const pairingCode = cleanPairingCode(pairingCodeRaw);
|
const pairingCode = cleanPairingCode(pairingCodeRaw);
|
||||||
const agent = agentsByCode.get(pairingCode);
|
const agent = agentsByCode.get(pairingCode);
|
||||||
|
|
@ -432,6 +617,12 @@ function sanitizeTimeoutMs(value, fallback) {
|
||||||
return Math.max(1000, Math.min(12 * 60 * 60 * 1000, numeric));
|
return Math.max(1000, Math.min(12 * 60 * 60 * 1000, numeric));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function sanitizeInteger(value, fallback, min, max) {
|
||||||
|
const numeric = Number(value || fallback);
|
||||||
|
if (!Number.isFinite(numeric)) return fallback;
|
||||||
|
return Math.min(Math.max(Math.trunc(numeric), min), max);
|
||||||
|
}
|
||||||
|
|
||||||
function elapsedLabel(startedAt) {
|
function elapsedLabel(startedAt) {
|
||||||
const elapsed = Math.max(0, Date.now() - Number(startedAt || Date.now()));
|
const elapsed = Math.max(0, Date.now() - Number(startedAt || Date.now()));
|
||||||
if (elapsed < 1000) return `${elapsed}ms`;
|
if (elapsed < 1000) return `${elapsed}ms`;
|
||||||
|
|
@ -473,6 +664,14 @@ function readConfig() {
|
||||||
internalAccessTokens,
|
internalAccessTokens,
|
||||||
engineInternalUrl: cleanString(process.env.NODEDC_ENGINE_INTERNAL_URL, 1000).replace(/\/+$/, ""),
|
engineInternalUrl: cleanString(process.env.NODEDC_ENGINE_INTERNAL_URL, 1000).replace(/\/+$/, ""),
|
||||||
engineInternalAccessToken: cleanString(process.env.NODEDC_INTERNAL_ACCESS_TOKEN, 1000),
|
engineInternalAccessToken: cleanString(process.env.NODEDC_INTERNAL_ACCESS_TOKEN, 1000),
|
||||||
|
assistantInternalUrl: cleanString(
|
||||||
|
process.env.NODEDC_AI_WORKSPACE_ASSISTANT_URL ||
|
||||||
|
process.env.NDC_AI_WORKSPACE_ASSISTANT_INTERNAL_URL ||
|
||||||
|
process.env.AI_WORKSPACE_ASSISTANT_INTERNAL_URL ||
|
||||||
|
"http://ai-workspace-assistant:18082",
|
||||||
|
1000,
|
||||||
|
).replace(/\/+$/, ""),
|
||||||
|
assistantInternalAccessToken: cleanString(process.env.NODEDC_INTERNAL_ACCESS_TOKEN, 1000),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue