From 3526351b1b7ecc537a606e0afd31b8f9a0e878d7 Mon Sep 17 00:00:00 2001 From: Codex Date: Sat, 20 Jun 2026 12:54:19 +0300 Subject: [PATCH] feat(ai-workspace): add local relay profiles --- docs/AI_WORKSPACE_CONFIG_CONTRACT.md | 31 +- .../AI_WORKSPACE_SECURITY_AUDIT_2026-06-20.md | 171 +++++ docs/AI_WORKSPACE_TEST_MATRIX.md | 74 +++ docs/DEPLOYMENT_LOCAL.md | 9 +- docs/ENVIRONMENT_CONTRACT.md | 106 ++++ docs/LOCAL_TESTING_SYSTEM.md | 174 ++++++ infra/.env.example | 9 +- infra/README.md | 9 + infra/docker-compose.dev.yml | 47 +- .../check-ai-workspace-config-contract.sh | 53 +- .../check-ai-workspace-release-gates.sh | 5 +- infra/scripts/check-ai-workspace-topology.sh | 68 +- .../check-local-environment-safety.mjs | 147 +++++ infra/scripts/check-local-test-system.sh | 183 ++++++ infra/scripts/init-dev-env.sh | 8 +- infra/synology/README.md | 38 ++ infra/synology/apply-ai-hub-relay.sh | 218 +++++++ infra/synology/deploy-current.sh | 8 + .../synology/docker-compose.platform-http.yml | 2 + infra/synology/sync-ai-hub-relay.sh | 56 ++ services/ai-workspace-assistant/Dockerfile | 1 + .../ai-workspace-assistant/Dockerfile.dev | 15 + services/ai-workspace-assistant/README.md | 45 ++ .../docs/AI_WORKSPACE_PROTOCOL_V1.md | 434 +++++++++++++ .../src/scripts/smoke-run-profile.mjs | 24 + .../ai-workspace-assistant/src/server.mjs | 584 +++++++++++++++++- .../src/templates/install-windows.ps1 | 318 +++++++++- services/ai-workspace-hub/src/server.mjs | 199 ++++++ 28 files changed, 2959 insertions(+), 77 deletions(-) create mode 100644 docs/AI_WORKSPACE_SECURITY_AUDIT_2026-06-20.md create mode 100644 docs/AI_WORKSPACE_TEST_MATRIX.md create mode 100644 docs/ENVIRONMENT_CONTRACT.md create mode 100644 docs/LOCAL_TESTING_SYSTEM.md create mode 100644 infra/scripts/check-local-environment-safety.mjs create mode 100755 infra/scripts/check-local-test-system.sh create mode 100755 infra/synology/apply-ai-hub-relay.sh create mode 100755 infra/synology/sync-ai-hub-relay.sh create mode 100644 services/ai-workspace-assistant/Dockerfile.dev create mode 100644 services/ai-workspace-assistant/docs/AI_WORKSPACE_PROTOCOL_V1.md diff --git a/docs/AI_WORKSPACE_CONFIG_CONTRACT.md b/docs/AI_WORKSPACE_CONFIG_CONTRACT.md index 002feef..1211973 100644 --- a/docs/AI_WORKSPACE_CONFIG_CONTRACT.md +++ b/docs/AI_WORKSPACE_CONFIG_CONTRACT.md @@ -13,15 +13,19 @@ Do not mix these classes. A URL reachable from the Mac browser is not automatica ## Current Profiles -`local/hybrid-prod-bridge`: +`local/remote-worker-relay`: - Tasker UI: `http://task.local.nodedc` - Platform Assistant: `http://127.0.0.1:18082` - 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 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`: @@ -52,3 +56,24 @@ New apps must join AI Workspace through app manifests/config/adapters: - owner repo/service. 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 +``` diff --git a/docs/AI_WORKSPACE_SECURITY_AUDIT_2026-06-20.md b/docs/AI_WORKSPACE_SECURITY_AUDIT_2026-06-20.md new file mode 100644 index 0000000..8263d66 --- /dev/null +++ b/docs/AI_WORKSPACE_SECURITY_AUDIT_2026-06-20.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 ` 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-` 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. diff --git a/docs/AI_WORKSPACE_TEST_MATRIX.md b/docs/AI_WORKSPACE_TEST_MATRIX.md new file mode 100644 index 0000000..2083331 --- /dev/null +++ b/docs/AI_WORKSPACE_TEST_MATRIX.md @@ -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. diff --git a/docs/DEPLOYMENT_LOCAL.md b/docs/DEPLOYMENT_LOCAL.md index 60d7712..d2e3383 100644 --- a/docs/DEPLOYMENT_LOCAL.md +++ b/docs/DEPLOYMENT_LOCAL.md @@ -50,20 +50,23 @@ Source fork: ```bash 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. - `local-ops-vertical`: локальный Ops Gateway и локальный Tasker проверяются вертикально, без live Codex worker. - `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 находятся в одной локальной топологии. - `tunnel-local-e2e`: то же самое через явно выбранный tunnel/Tailscale-профиль. Не надо пытаться неявно “дотянуть” 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: ```bash diff --git a/docs/ENVIRONMENT_CONTRACT.md b/docs/ENVIRONMENT_CONTRACT.md new file mode 100644 index 0000000..143b73b --- /dev/null +++ b/docs/ENVIRONMENT_CONTRACT.md @@ -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//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 +``` diff --git a/docs/LOCAL_TESTING_SYSTEM.md b/docs/LOCAL_TESTING_SYSTEM.md new file mode 100644 index 0000000..d8d0061 --- /dev/null +++ b/docs/LOCAL_TESTING_SYSTEM.md @@ -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 +``` diff --git a/infra/.env.example b/infra/.env.example index 75c50fb..350105a 100644 --- a/infra/.env.example +++ b/infra/.env.example @@ -1,4 +1,5 @@ # domains +NODEDC_ENV=local AUTH_DOMAIN=auth.local.nodedc AUTH_ADMIN_DOMAIN=auth-admin.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_TOKEN=replace-with-ops-agent-gateway-internal-token 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. -# Default local development uses the deployed relay and is a hybrid-prod-bridge topology: -# local UI/services can be tested, but live Codex worker write-path e2e is not proven. -# For true local/tunnel e2e, change both Hub URLs and Ops Gateway public URL deliberately. +# Default local development may use the deployed AI Hub only as a relay for remote 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_HUB_TOKEN=change-me-generate-with-infra-scripts-init-dev-env AI_WORKSPACE_HUB_HOST_BIND=127.0.0.1:18081 AI_WORKSPACE_HUB_PUBLIC_URL=wss://ai-hub.nodedc.ru/api/ai-workspace/hub diff --git a/infra/README.md b/infra/README.md index dba3c75..3f212f1 100644 --- a/infra/README.md +++ b/infra/README.md @@ -32,6 +32,15 @@ infra/ ## 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`: ```text diff --git a/infra/docker-compose.dev.yml b/infra/docker-compose.dev.yml index 913cbd5..aaade14 100644 --- a/infra/docker-compose.dev.yml +++ b/infra/docker-compose.dev.yml @@ -4,9 +4,6 @@ services: reverse-proxy: image: ${PLATFORM_PROXY_IMAGE:-nodedc/plane-proxy:ru} restart: unless-stopped - env_file: - - path: .env - required: false ports: - "${PLATFORM_HTTP_PORT:-80}:80" volumes: @@ -28,9 +25,6 @@ services: postgresql-authentik: image: docker.io/library/postgres:16-alpine restart: unless-stopped - env_file: - - path: .env - required: false environment: POSTGRES_DB: ${PG_DB:-authentik} POSTGRES_PASSWORD: ${PG_PASS:?database password required} @@ -48,9 +42,6 @@ services: image: ${AUTHENTIK_IMAGE:-ghcr.io/goauthentik/server}:${AUTHENTIK_TAG:-2026.2.2} command: server restart: unless-stopped - env_file: - - path: .env - required: false environment: AUTHENTIK_POSTGRESQL__HOST: postgresql-authentik AUTHENTIK_POSTGRESQL__NAME: ${PG_DB:-authentik} @@ -58,6 +49,10 @@ services: AUTHENTIK_POSTGRESQL__USER: ${PG_USER:-authentik} 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_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: postgresql-authentik: condition: service_healthy @@ -74,15 +69,13 @@ services: command: worker restart: unless-stopped user: root - env_file: - - path: .env - required: false environment: AUTHENTIK_POSTGRESQL__HOST: postgresql-authentik AUTHENTIK_POSTGRESQL__NAME: ${PG_DB:-authentik} AUTHENTIK_POSTGRESQL__PASSWORD: ${PG_PASS:?database password required} AUTHENTIK_POSTGRESQL__USER: ${PG_USER:-authentik} AUTHENTIK_SECRET_KEY: ${AUTHENTIK_SECRET_KEY:?secret key required} + AUTHENTIK_ERROR_REPORTING__ENABLED: ${AUTHENTIK_ERROR_REPORTING__ENABLED:-false} depends_on: postgresql-authentik: condition: service_healthy @@ -96,9 +89,6 @@ services: notification-postgres: image: docker.io/library/postgres:16-alpine restart: unless-stopped - env_file: - - path: .env - required: false environment: POSTGRES_DB: ${NOTIFICATION_PG_DB:-nodedc_notifications} POSTGRES_PASSWORD: ${NOTIFICATION_PG_PASS:-nodedc_notifications} @@ -117,9 +107,6 @@ services: build: context: ../services/notification-core restart: unless-stopped - env_file: - - path: .env - required: false environment: NODE_ENV: production PORT: 5185 @@ -133,9 +120,6 @@ services: ai-workspace-postgres: image: docker.io/library/postgres:16-alpine restart: unless-stopped - env_file: - - path: .env - required: false environment: POSTGRES_DB: ${AI_WORKSPACE_PG_DB:-nodedc_ai_workspace} POSTGRES_PASSWORD: ${AI_WORKSPACE_PG_PASS:-nodedc_ai_workspace} @@ -152,11 +136,9 @@ services: ai-workspace-assistant: image: nodedc/ai-workspace-assistant:local build: - context: ../services/ai-workspace-assistant + context: ../services + dockerfile: ai-workspace-assistant/Dockerfile.dev restart: unless-stopped - env_file: - - path: .env - required: false environment: NODE_ENV: production 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_TOKEN: ${AI_WORKSPACE_HUB_TOKEN:-} 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: - "18082" ports: @@ -179,14 +171,13 @@ services: build: context: ../services/ai-workspace-hub restart: unless-stopped - env_file: - - path: .env - required: false environment: NODE_ENV: production PORT: 18081 AI_WORKSPACE_HUB_TOKEN: ${AI_WORKSPACE_HUB_TOKEN:-dev-ai-workspace-hub-token} 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: - "18081" ports: diff --git a/infra/scripts/check-ai-workspace-config-contract.sh b/infra/scripts/check-ai-workspace-config-contract.sh index 8dc3908..20bc01c 100755 --- a/infra/scripts/check-ai-workspace-config-contract.sh +++ b/infra/scripts/check-ai-workspace-config-contract.sh @@ -55,6 +55,21 @@ require_contains() { 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() { file="$1" key="$2" @@ -88,9 +103,12 @@ require_env_value() { section "Required files" 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/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/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.synology.example" "Ops Gateway Synology env example 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" 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_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_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_ENABLED" "true" "Local Platform example enables Assistant action relay" +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_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" 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_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/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" 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 pass "Current Platform env has AI_WORKSPACE_OPS_ENTITLEMENT_URL" 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 warn "Current Platform env not found: $PLATFORM_ENV" fi diff --git a/infra/scripts/check-ai-workspace-release-gates.sh b/infra/scripts/check-ai-workspace-release-gates.sh index 02f8166..be6163d 100755 --- a/infra/scripts/check-ai-workspace-release-gates.sh +++ b/infra/scripts/check-ai-workspace-release-gates.sh @@ -65,9 +65,12 @@ run_in "$PLATFORM_ROOT" \ "infra/scripts/check-ai-workspace-topology.sh" 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" \ "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" \ "AI Workspace config contract" \ "infra/scripts/check-ai-workspace-config-contract.sh" diff --git a/infra/scripts/check-ai-workspace-topology.sh b/infra/scripts/check-ai-workspace-topology.sh index 6494c59..65a5f0f 100755 --- a/infra/scripts/check-ai-workspace-topology.sh +++ b/infra/scripts/check-ai-workspace-topology.sh @@ -90,40 +90,63 @@ bool_and() { } 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") task_code=$(http_status "$TASK_URL") engine_code=$(http_status "$ENGINE_URL") assistant_ok=$(ok_status "$assistant_code") -hub_ok=$(ok_status "$hub_code") gateway_ok=$(ok_status "$gateway_code") task_ok=$(ok_status "$task_code") engine_ok=$(ok_status "$engine_code") -agents_online=$(printf "%s" "$hub_body" | json_number agentsOnline) -case "$agents_online" in - ""|*[!0-9]*) agents_online=0 ;; +profile=$(read_env "$PLATFORM_ENV" NODEDC_ENV || true) +case "$profile" in + "") profile=local ;; esac - hub_public=$(read_env "$PLATFORM_ENV" AI_WORKSPACE_HUB_PUBLIC_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) +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_internal_kind=$(url_kind "$hub_internal") 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 if [ "$agents_online" -gt 0 ]; then worker_on_local_hub=true fi +action_relay_configured=false +if [ "$action_relay_enabled" = true ] && [ -n "$action_relay_id" ]; then + action_relay_configured=true +fi + hub_runtime_kind=custom case "$hub_public_kind:$hub_internal_kind" in local:local|local:missing|missing:local) hub_runtime_kind=local ;; @@ -135,6 +158,10 @@ esac classification=contract-only if [ "$local_services_ok" = true ]; then 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 ] \ && [ "$ops_entitlement_kind" = local ] \ && [ "$hub_runtime_kind" = local ]; then @@ -156,9 +183,11 @@ fi cat < { + 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); diff --git a/infra/scripts/check-local-test-system.sh b/infra/scripts/check-local-test-system.sh new file mode 100755 index 0000000..b39d556 --- /dev/null +++ b/infra/scripts/check-local-test-system.sh @@ -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 diff --git a/infra/scripts/init-dev-env.sh b/infra/scripts/init-dev-env.sh index 6e5863d..e46619b 100755 --- a/infra/scripts/init-dev-env.sh +++ b/infra/scripts/init-dev-env.sh @@ -16,6 +16,7 @@ rand() { cat > "$ENV_FILE" </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 Для правок только в Launcher/BFF не пересоздавать Authentik, reverse-proxy, Tasker и Ops Agents: diff --git a/infra/synology/apply-ai-hub-relay.sh b/infra/synology/apply-ai-hub-relay.sh new file mode 100755 index 0000000..272e217 --- /dev/null +++ b/infra/synology/apply-ai-hub-relay.sh @@ -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" diff --git a/infra/synology/deploy-current.sh b/infra/synology/deploy-current.sh index 2b1ca97..79c76d9 100755 --- a/infra/synology/deploy-current.sh +++ b/infra/synology/deploy-current.sh @@ -64,6 +64,14 @@ rsync -av "${RSYNC_METADATA_ARGS[@]}" --delete \ "${PLATFORM_REPO}/services/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 mkdir -p "${NAS_ROOT}/authentik/custom-templates" rsync -av "${RSYNC_METADATA_ARGS[@]}" --delete \ diff --git a/infra/synology/docker-compose.platform-http.yml b/infra/synology/docker-compose.platform-http.yml index f07b1a0..fc07c2d 100644 --- a/infra/synology/docker-compose.platform-http.yml +++ b/infra/synology/docker-compose.platform-http.yml @@ -142,6 +142,7 @@ services: 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_FALLBACK_URLS: ${AI_WORKSPACE_HUB_FALLBACK_URLS:-} + NDC_ONTOLOGY_LAUNCHER_BASE_URL: http://launcher:5173 expose: - "18082" ports: @@ -165,6 +166,7 @@ services: PORT: 18081 AI_WORKSPACE_HUB_TOKEN: ${AI_WORKSPACE_HUB_TOKEN:?ai workspace hub token required} 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 expose: - "18081" diff --git a/infra/synology/sync-ai-hub-relay.sh b/infra/synology/sync-ai-hub-relay.sh new file mode 100755 index 0000000..318dbe9 --- /dev/null +++ b/infra/synology/sync-ai-hub-relay.sh @@ -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 < 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. diff --git a/services/ai-workspace-assistant/src/scripts/smoke-run-profile.mjs b/services/ai-workspace-assistant/src/scripts/smoke-run-profile.mjs index 920ee89..8199f1d 100644 --- a/services/ai-workspace-assistant/src/scripts/smoke-run-profile.mjs +++ b/services/ai-workspace-assistant/src/scripts/smoke-run-profile.mjs @@ -44,6 +44,14 @@ const adapterPayload = { const appGrants = normalizeEntitlementAdapterAppGrants(adapterPayload, adapter); const mcpServers = runProfileMcpServersFromAppGrants({}, 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 = { schemaVersion: "ai-workspace.run-profile.v1", runId: randomUUID(), @@ -71,6 +79,7 @@ const runProfile = { mcpServers, mcpServerNames: mcpServers.map((server) => server.serverName), requiredMcpServerNames: mcpServers.filter((server) => server.required).map((server) => server.serverName), + assistantActions, }, diagnostics: { 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, ""); assert.equal(publicProfile.toolProfile.mcpServers[0].httpHeaders.Accept, ""); 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, ""); +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.match(runProfile.diagnostics.profileHash, /^[a-f0-9]{16}$/); @@ -114,7 +127,9 @@ console.log(JSON.stringify({ checks: [ "adapter_grant_normalized", "token_scoped_ops_mcp_in_run_profile", + "assistant_action_relay_in_run_profile", "public_run_profile_redacts_mcp_headers", + "public_run_profile_redacts_assistant_action_gateway_token", "stable_public_profile_hash", ], mcpServerNames: runProfile.toolProfile.mcpServerNames, @@ -276,6 +291,7 @@ function redactRunProfile(runProfile) { appGrants: redactForPublicDiagnostics(runProfile.appGrants), toolProfile: { ...(isPlainObject(runProfile.toolProfile) ? runProfile.toolProfile : {}), + assistantActions: redactAssistantActions(runProfile.toolProfile?.assistantActions), mcpServers: Array.isArray(runProfile.toolProfile?.mcpServers) ? 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: "" } : {}), + }; +} + function redactMcpServer(server) { if (!isPlainObject(server)) return {}; const headers = isPlainObject(server.httpHeaders) ? server.httpHeaders : {}; diff --git a/services/ai-workspace-assistant/src/server.mjs b/services/ai-workspace-assistant/src/server.mjs index 81c64f2..7fec28e 100644 --- a/services/ai-workspace-assistant/src/server.mjs +++ b/services/ai-workspace-assistant/src/server.mjs @@ -3,6 +3,8 @@ import { createHash, randomUUID, timingSafeEqual } from "node:crypto"; import { readFile } from "node:fs/promises"; import { createServer } from "node:http"; 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_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_TOOL_PACKS = new Set(["engine", "ops", "ndc-agent-core", "deploy", "docs"]); 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_MAX_MS = 12 * 60 * 60 * 1000; +let assistantActionIdsCache = { loadedAt: 0, actionIds: [] }; +let assistantActionRelayStopping = false; const config = readConfig(); 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) }); })); +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) => { const owner = getRequestOwner(req); const [executors, settings] = await Promise.all([ @@ -397,6 +525,7 @@ await recoverBridgeRunMirrors(); httpServer.listen(config.port, "0.0.0.0", () => { console.log(`NODE.DC AI Workspace Assistant listening on http://0.0.0.0:${config.port}`); + startAssistantActionRelayLoop(); }); 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 }) { const requestId = optionalString(bridge?.requestId); 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 mcpServers = runProfileMcpServersFromAppGrants(ownerSettings, grantResolution.appGrants); const mcpServerNames = mcpServers.map((server) => server.serverName).filter(Boolean); + const assistantActions = await assistantActionToolProfileForRun(); const requiredMcpServerNames = mcpServers .filter((server) => server.required === true) .map((server) => server.serverName) @@ -1464,6 +1650,8 @@ async function buildRunProfile({ owner, thread, executor, ownerSettings, bridgeP entitlementAdapters: grantResolution.diagnostics, mcpServerNames, requiredMcpServerNames, + assistantActionIds: assistantActions.actionIds, + assistantActionGatewayConfigured: Boolean(assistantActions.gatewayUrl && assistantActions.gatewayToken), missingContext: Array.isArray(context.missingContext) ? context.missingContext.map(optionalString).filter(Boolean) : [], @@ -1490,14 +1678,164 @@ async function buildRunProfile({ owner, thread, executor, ownerSettings, bridgeP mcpServers, mcpServerNames, requiredMcpServerNames, + assistantActions, }, - policyPrompt: buildRunProfilePolicyPrompt({ context, diagnostics }), + policyPrompt: buildRunProfilePolicyPrompt({ context, diagnostics, assistantActions }), diagnostics, }; runProfile.diagnostics.profileHash = runProfileHash(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 }) { const metadata = isPlainObject(ownerSettings?.metadata) ? ownerSettings.metadata : {}; const staticAppGrants = isPlainObject(metadata.appGrants) ? metadata.appGrants : {}; @@ -1709,7 +2047,7 @@ function summarizeRunAppGrants(metadata) { return out; } -function buildRunProfilePolicyPrompt({ context, diagnostics }) { +function buildRunProfilePolicyPrompt({ context, diagnostics, assistantActions }) { const lines = [ "AI Workspace dynamic run profile:", `- source surface: ${diagnostics.sourceSurface}`, @@ -1719,6 +2057,11 @@ function buildRunProfilePolicyPrompt({ context, diagnostics }) { `- entitlement source: ${diagnostics.entitlementAdapters?.source || "settings"}`, `- enabled tool packs: ${diagnostics.enabledToolPacks.length ? diagnostics.enabledToolPacks.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.", ]; const opsContext = isPlainObject(context?.contexts?.ops) ? context.contexts.ops : {}; @@ -1754,19 +2097,29 @@ function runProfileHash(runProfile) { function redactRunProfile(runProfile) { if (!isPlainObject(runProfile)) return null; + const toolProfile = isPlainObject(runProfile.toolProfile) ? runProfile.toolProfile : {}; return { ...runProfile, targetContexts: redactForPublicDiagnostics(runProfile.targetContexts), appGrants: redactForPublicDiagnostics(runProfile.appGrants), toolProfile: { - ...(isPlainObject(runProfile.toolProfile) ? runProfile.toolProfile : {}), - mcpServers: Array.isArray(runProfile.toolProfile?.mcpServers) - ? runProfile.toolProfile.mcpServers.map(redactMcpServer) + ...toolProfile, + assistantActions: redactAssistantActions(toolProfile.assistantActions), + mcpServers: Array.isArray(toolProfile.mcpServers) + ? toolProfile.mcpServers.map(redactMcpServer) : [], }, }; } +function redactAssistantActions(value) { + if (!isPlainObject(value)) return value; + return { + ...value, + ...(value.gatewayToken ? { gatewayToken: "" } : {}), + }; +} + function redactMcpServer(server) { if (!isPlainObject(server)) return {}; const headers = isPlainObject(server.httpHeaders) ? server.httpHeaders : {}; @@ -2520,12 +2873,62 @@ function sanitizeOwnerSettingsCommand(payload) { 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) { const text = normalizeKey(value || "all"); if (text === "shared" || text === "remote" || text === "all") return text; 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) { const source = isPlainObject(payload) ? payload : {}; 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) { return { 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) { if (!settings || !isPlainObject(settings)) return settings; return { @@ -2874,6 +3388,11 @@ function isTruthy(value) { 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) { const text = optionalString(value); return text ? text.toLowerCase() : null; @@ -3051,6 +3570,7 @@ function cleanHttpEndpoint(value) { } function readConfig() { + const nodedcEnv = normalizeKey(process.env.NODEDC_ENV || process.env.NDC_ENV || process.env.NODE_ENV || ""); const databaseUrl = process.env.DATABASE_URL || process.env.AI_WORKSPACE_ASSISTANT_DATABASE_URL || @@ -3070,8 +3590,44 @@ function readConfig() { optionalString(process.env.NDC_AI_WORKSPACE_HUB_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 { + nodedcEnv, port: Number(process.env.PORT || process.env.AI_WORKSPACE_ASSISTANT_PORT || "18082"), databaseUrl, databasePoolSize: Number(process.env.AI_WORKSPACE_ASSISTANT_DATABASE_POOL_SIZE || "10"), @@ -3079,6 +3635,21 @@ function readConfig() { hubInternalHttpUrl: hubInternalHttpUrl.replace(/\/+$/, ""), hubInternalAccessToken: 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( process.env.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_PLATFORM_SERVICE_TOKEN, ]), - entitlementAdapters: parseEntitlementAdapters(), + entitlementAdapters, }; } async function shutdown() { + assistantActionRelayStopping = true; try { httpServer.close(); await pool.end(); diff --git a/services/ai-workspace-assistant/src/templates/install-windows.ps1 b/services/ai-workspace-assistant/src/templates/install-windows.ps1 index 0ccd8a6..f0b8cca 100644 --- a/services/ai-workspace-assistant/src/templates/install-windows.ps1 +++ b/services/ai-workspace-assistant/src/templates/install-windows.ps1 @@ -879,6 +879,26 @@ function cleanRunKey(value, limit = 160) { 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 = () => {}) { const requestId = cleanRunKey(control.requestId, 120) const threadId = cleanRunKey(control.threadId, 160) @@ -898,14 +918,10 @@ function registerActiveCodexRun(control = {}, child, onEvent = () => {}) { try { run.onEvent({ kind: 'stopped', message: 'Codex stop requested.' }) } catch {} - try { - run.child.kill('SIGTERM') - } catch {} + terminateProcessTree(run.child, 'SIGTERM') run.stopTimer = setTimeout(() => { if (run.closed) return - try { - run.child.kill('SIGKILL') - } catch {} + terminateProcessTree(run.child, 'SIGKILL') }, CODEX_STOP_GRACE_MS) return true }, @@ -1049,6 +1065,13 @@ async function readConversations() { function buildPrompt(payload) { 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 assistantActionIds = Array.isArray(assistantActions.actionIds) + ? assistantActions.actionIds.map((item) => String(item || '').trim()).filter(Boolean) + : [] const runProfilePolicyPrompt = String(runProfile.policyPrompt || '').trim() const userMessage = String(payload?.message || '').trim() const history = payload?.resume ? [] : normalizeHistory(payload?.history || []) @@ -1116,7 +1139,7 @@ function buildPrompt(payload) { `- Selected agentNodeId: ${context.agentNodeId || 'unknown'}`, '- Read the second-level graph with GET /subworkflow?workflowId=&nodeId=.', '- 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.', '- 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.', @@ -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.', '- 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 ? [ '', @@ -1134,8 +1166,12 @@ function buildPrompt(payload) { `- Selected Ops project id: ${opsContext.opsProjectId || 'unknown'}`, `- Selected Ops project identifier: ${opsContext.opsProjectIdentifier || 'unknown'}`, '- 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.', - '- 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.', '- 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.', @@ -1480,6 +1516,11 @@ function insertBeforePromptStdin(args, extras) { function buildNdcAgentMcpContext(payload, cwd) { 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) return { workflowId: String(context.workflowId || '').trim(), @@ -1492,6 +1533,14 @@ function buildNdcAgentMcpContext(payload, cwd) { schemaVersion: 'v2.3.2', n8nMcpRefPath: NDC_AGENT_MCP_REF_PATH, 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 (hubDerived && isProtectedNodedcEngineUrl(explicit)) 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` 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) { try { const url = new URL(value) @@ -1854,7 +1919,7 @@ async function prepareRunCodexHome({ payload = {}, cwd = CODEX_CWD, mcpContext = : NDC_AGENT_CODEX_HOME await fs.mkdir(codexHome, { recursive: true }) await copyIfExists(path.join(CODEX_HOME, 'auth.json'), path.join(codexHome, 'auth.json')) - const legacyOpsMcpConfig = needsNdcAgentCore && !hasDynamicMcp ? await readOptionalOpsMcpConfig() : '' + const legacyOpsMcpConfig = '' const config = [ runtimeCodexConfig(), needsNdcAgentCore ? ndcAgentMcpServerConfig(mcpContext, cwd) : '', @@ -1878,6 +1943,8 @@ async function codexInvocationForPayload(baseArgs, payload, cwd) { NDC_AGENT_MCP_ROOT: CODEX_CWD, NDC_AGENT_MCP_CONTEXT: JSON.stringify(mcpContext), 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, } : {}), }, @@ -2399,7 +2466,7 @@ function runCodex(prompt, args = CODEX_ARGS, onEvent = () => {}, timeoutMs = MES const timeout = setTimeout(() => { killed = true onEvent({ kind: 'timeout', message: 'Codex process timed out.' }) - child.kill('SIGTERM') + terminateProcessTree(child, 'SIGTERM') }, timeoutMs) child.stdout.on('data', (chunk) => { @@ -2431,7 +2498,7 @@ function runCodex(prompt, args = CODEX_ARGS, onEvent = () => {}, timeoutMs = MES plainStdout += 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) => { const text = String(chunk) @@ -2441,7 +2508,7 @@ function runCodex(prompt, args = CODEX_ARGS, onEvent = () => {}, timeoutMs = MES } stderr += 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) => { 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_SCHEMA_VERSION = 'v2.3.2' 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 nodeCatalogCache = new Map() let transportMode = null @@ -3025,8 +3094,13 @@ function parseContext() { if (raw) { try { parsed = JSON.parse(raw) || {} } catch {} } + const assistantActions = parseObject( + parsed.assistantActions || + process.env.AI_WORKSPACE_ASSISTANT_ACTIONS || + {}, + ) return { - modeId: 'ndc-agent-core', + modeId: cleanString(parsed.modeId || 'ndc-agent-core'), workflowId: cleanString(parsed.workflowId || process.env.NDC_AGENT_MCP_WORKFLOW_ID || ''), workflowTitle: cleanString(parsed.workflowTitle || process.env.NDC_AGENT_MCP_WORKFLOW_TITLE || ''), 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), 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')), + 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) } +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) { const raw = cleanString(value || 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) { try { 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) { 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), ] 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]) } - if (envBase && !isLoopbackUrl(envBase)) { + if (envBase && !isPrivateNetworkUrl(envBase)) { return uniqueStrings([envBase, explicit]) } if (hubBases.some(Boolean)) { @@ -3191,6 +3345,88 @@ async function loadN8nMcpVersion() { 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) { return engineApiUrls(pathname)[0] || '' } @@ -3365,6 +3601,7 @@ async function handleGetContext() { ...context, apiBaseCandidates: engineApiBaseCandidates(), n8nMcpVersion, + assistantActionGatewayConfigured: Boolean(context.assistantActionGatewayUrl && context.assistantActionGatewayToken), contract: { sourceOfTruth: 'Engine second-level dc.subworkflow.json', 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) { const target = targetFromArgs(args) const q = new URLSearchParams(target) @@ -3571,6 +3842,22 @@ const tools = [ 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) { @@ -3580,6 +3867,7 @@ async function callTool(name, args) { if (name === 'ndc_search_nodes') return handleSearchNodes(args) if (name === 'ndc_get_node_definition') return handleGetNodeDefinition(args) if (name === 'ndc_validate_subworkflow') return handleValidateSubworkflow(args) + if (name === 'assistant_action_call') return handleAssistantActionCall(args) throw new Error(`unknown_tool:${name}`) } diff --git a/services/ai-workspace-hub/src/server.mjs b/services/ai-workspace-hub/src/server.mjs index c1e819c..adac661 100644 --- a/services/ai-workspace-hub/src/server.mjs +++ b/services/ai-workspace-hub/src/server.mjs @@ -8,12 +8,17 @@ const MAX_EVENTS_PER_AGENT = 5000; const MAX_REQUEST_META_PER_AGENT = 1000; const HEARTBEAT_INTERVAL_MS = 30000; 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 app = express(); const httpServer = createServer(app); const wss = new WebSocketServer({ noServer: true }); const agentsByCode = new Map(); +const assistantRelaysById = new Map(); let eventSeq = 0; app.disable("x-powered-by"); @@ -25,6 +30,7 @@ app.get("/healthz", (_req, res) => { service: "nodedc-ai-workspace-hub", wsPath: config.wsPath, agentsOnline: Array.from(agentsByCode.values()).filter(isAgentOnline).length, + assistantRelays: assistantRelaysById.size, 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 }); }); +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((error, _req, res, _next) => { @@ -256,6 +268,179 @@ async function proxyNdcAgentMcp(req, res) { 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 = {}) { const pairingCode = cleanPairingCode(pairingCodeRaw); const agent = agentsByCode.get(pairingCode); @@ -432,6 +617,12 @@ function sanitizeTimeoutMs(value, fallback) { 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) { const elapsed = Math.max(0, Date.now() - Number(startedAt || Date.now())); if (elapsed < 1000) return `${elapsed}ms`; @@ -473,6 +664,14 @@ function readConfig() { internalAccessTokens, engineInternalUrl: cleanString(process.env.NODEDC_ENGINE_INTERNAL_URL, 1000).replace(/\/+$/, ""), 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), }; }